diff --git a/src/NetworkOptimizer.Reports/MarkdownReportGenerator.cs b/src/NetworkOptimizer.Reports/MarkdownReportGenerator.cs index 6afbafdb1..aea2e615b 100644 --- a/src/NetworkOptimizer.Reports/MarkdownReportGenerator.cs +++ b/src/NetworkOptimizer.Reports/MarkdownReportGenerator.cs @@ -317,11 +317,51 @@ private void ComposeThreatSummary(StringBuilder sb, ReportData data) sb.AppendLine(); sb.AppendLine("| IP Address | Country | ASN | Events |"); sb.AppendLine("|-----------|---------|-----|--------|"); - foreach (var source in threat.TopSources.Take(5)) + foreach (var source in threat.TopSources) { sb.AppendLine($"| {source.Ip} | {source.CountryCode ?? "-"} | {source.AsnOrg ?? "-"} | {source.EventCount:N0} |"); } sb.AppendLine(); + + // Suppressed-count footnote + if (threat.SuppressedEventCount > 0) + { + var infraCount = threat.InfrastructureSources.Sum(s => s.EventCount); + var trustedCount = threat.TrustedUserSources.Sum(s => s.EventCount); + var parts = new List(); + if (infraCount > 0) parts.Add($"{infraCount:N0} from known infrastructure"); + if (trustedCount > 0) parts.Add($"{trustedCount:N0} from trusted user devices"); + sb.AppendLine($"*Excludes {string.Join(" and ", parts)} (see categorized tables below).*"); + sb.AppendLine(); + } + } + + // Known Infrastructure Activity + if (threat.InfrastructureSources.Any()) + { + sb.AppendLine("### Known Infrastructure Activity"); + sb.AppendLine(); + sb.AppendLine("| IP Address | Label | Country | Events |"); + sb.AppendLine("|-----------|-------|---------|--------|"); + foreach (var source in threat.InfrastructureSources) + { + sb.AppendLine($"| {source.Ip} | {source.Label ?? "-"} | {source.CountryCode ?? "-"} | {source.EventCount:N0} |"); + } + sb.AppendLine(); + } + + // Trusted User Activity + if (threat.TrustedUserSources.Any()) + { + sb.AppendLine("### Trusted User Activity"); + sb.AppendLine(); + sb.AppendLine("| IP Address | Label | Country | Events |"); + sb.AppendLine("|-----------|-------|---------|--------|"); + foreach (var source in threat.TrustedUserSources) + { + sb.AppendLine($"| {source.Ip} | {source.Label ?? "-"} | {source.CountryCode ?? "-"} | {source.EventCount:N0} |"); + } + sb.AppendLine(); } // Exposed services diff --git a/src/NetworkOptimizer.Reports/PdfReportGenerator.cs b/src/NetworkOptimizer.Reports/PdfReportGenerator.cs index 83482f91b..3dcd250b9 100644 --- a/src/NetworkOptimizer.Reports/PdfReportGenerator.cs +++ b/src/NetworkOptimizer.Reports/PdfReportGenerator.cs @@ -1205,7 +1205,7 @@ private void ComposeThreatSummary(IContainer container, ReportData data) .Text("Events").Bold().FontSize(8); }); - foreach (var source in threat.TopSources.Take(5)) + foreach (var source in threat.TopSources) { table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4) .Text(source.Ip).FontSize(8); @@ -1217,6 +1217,48 @@ private void ComposeThreatSummary(IContainer container, ReportData data) .Text(source.EventCount.ToString("N0")).FontSize(8); } }); + + // Suppressed-count footnote: tells the reader the table above excludes + // events from internal infrastructure and trusted user devices, with + // the actual numbers shown in the sub-tables that follow. + if (threat.SuppressedEventCount > 0) + { + var infraCount = threat.InfrastructureSources.Sum(s => s.EventCount); + var trustedCount = threat.TrustedUserSources.Sum(s => s.EventCount); + var parts = new List(); + if (infraCount > 0) parts.Add($"{infraCount:N0} from known infrastructure"); + if (trustedCount > 0) parts.Add($"{trustedCount:N0} from trusted user devices"); + var footnote = $"Excludes {string.Join(" and ", parts)} (see categorized tables below)."; + + column.Item() + .PaddingTop(4) + .Text(footnote) + .FontSize(8) + .Italic() + .FontColor(Colors.Grey.Medium); + } + } + + // Known Infrastructure Activity + if (threat.InfrastructureSources.Any()) + { + ComposeCategorizedSourceTable( + column, + "Known Infrastructure Activity", + threat.InfrastructureSources, + primaryColor, + lightGray); + } + + // Trusted User Activity + if (threat.TrustedUserSources.Any()) + { + ComposeCategorizedSourceTable( + column, + "Trusted User Activity", + threat.TrustedUserSources, + primaryColor, + lightGray); } // Exposed services @@ -1273,6 +1315,62 @@ private void ComposeThreatSummary(IContainer container, ReportData data) }); } + /// + /// Renders a small sub-table beneath the main "Top Threat Sources" table for + /// a categorized group (Known Infrastructure Activity or Trusted User Activity). + /// Adds a Label column so the reader can see what each source actually is. + /// + private void ComposeCategorizedSourceTable( + ColumnDescriptor column, + string title, + List sources, + string primaryColor, + string lightGray) + { + column.Item() + .PaddingTop(10) + .PaddingBottom(4) + .Text(title) + .FontSize(10) + .Bold() + .FontColor(primaryColor); + + column.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(2f); // IP + columns.RelativeColumn(2.5f); // Label + columns.RelativeColumn(1f); // Country + columns.RelativeColumn(1f); // Events + }); + + table.Header(header => + { + header.Cell().Background(lightGray).Padding(4) + .Text("IP Address").Bold().FontSize(8); + header.Cell().Background(lightGray).Padding(4) + .Text("Label").Bold().FontSize(8); + header.Cell().Background(lightGray).Padding(4) + .Text("Country").Bold().FontSize(8); + header.Cell().Background(lightGray).Padding(4) + .Text("Events").Bold().FontSize(8); + }); + + foreach (var source in sources) + { + table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4) + .Text(source.Ip).FontSize(8); + table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4) + .Text(source.Label ?? "-").FontSize(8); + table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4) + .Text(source.CountryCode ?? "-").FontSize(8); + table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4) + .Text(source.EventCount.ToString("N0")).FontSize(8); + } + }); + } + private void ComposePortSecuritySummary(IContainer container, ReportData data) { var primaryColor = GetColor(_branding.Colors.Primary); diff --git a/src/NetworkOptimizer.Reports/ReportData.cs b/src/NetworkOptimizer.Reports/ReportData.cs index be9de2553..34a4dd039 100644 --- a/src/NetworkOptimizer.Reports/ReportData.cs +++ b/src/NetworkOptimizer.Reports/ReportData.cs @@ -559,6 +559,26 @@ public class ThreatSummaryData public Dictionary ByKillChain { get; set; } = new(); public List TopSources { get; set; } = new(); public List ExposedServices { get; set; } = new(); + + /// + /// Source IPs matching enabled Infrastructure-category noise filters. Shown + /// in a separate "Known Infrastructure Activity" sub-table so the user can + /// see what was excluded from the main Top Threat Sources table. + /// + public List InfrastructureSources { get; set; } = new(); + + /// + /// Source IPs matching enabled TrustedUser-category noise filters. Shown + /// in a separate "Trusted User Activity" sub-table. + /// + public List TrustedUserSources { get; set; } = new(); + + /// + /// Aggregate event count for events suppressed from the top threat sources + /// table because they matched Infrastructure or TrustedUser filters. Used + /// for the "X events suppressed" header line. + /// + public int SuppressedEventCount { get; set; } } public class ThreatSourceEntry @@ -567,6 +587,13 @@ public class ThreatSourceEntry public string? CountryCode { get; set; } public string? AsnOrg { get; set; } public int EventCount { get; set; } + + /// + /// Human-readable label from the matching noise filter for categorized + /// sub-tables (e.g., "Network Optimizer (self)"). Null for the main + /// Top Threat Sources table. + /// + public string? Label { get; set; } } public class ExposedServiceEntry diff --git a/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.Designer.cs b/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.Designer.cs new file mode 100644 index 000000000..7c0761c99 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.Designer.cs @@ -0,0 +1,2479 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetworkOptimizer.Storage.Models; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + [DbContext(typeof(NetworkOptimizerDbContext))] + [Migration("20260526120000_AddDestGeoAndFilterCategory")] + partial class AddDestGeoAndFilterCategory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuditVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AuditDate") + .HasColumnType("TEXT"); + + b.Property("ComplianceScore") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FailedChecks") + .HasColumnType("INTEGER"); + + b.Property("FindingsJson") + .HasColumnType("TEXT"); + + b.Property("ReportDataJson") + .HasColumnType("TEXT"); + + b.Property("FirmwareVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PassedChecks") + .HasColumnType("INTEGER"); + + b.Property("TotalChecks") + .HasColumnType("INTEGER"); + + b.Property("WarningChecks") + .HasColumnType("INTEGER"); + + b.Property("IsScheduled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AuditDate"); + + b.HasIndex("DeviceId", "AuditDate"); + + b.ToTable("AuditResults", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmBaseline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgBytesIn") + .HasColumnType("INTEGER"); + + b.Property("AvgBytesOut") + .HasColumnType("INTEGER"); + + b.Property("AvgJitter") + .HasColumnType("REAL"); + + b.Property("AvgLatency") + .HasColumnType("REAL"); + + b.Property("AvgPacketLoss") + .HasColumnType("REAL"); + + b.Property("AvgUtilization") + .HasColumnType("REAL"); + + b.Property("BaselineEnd") + .HasColumnType("TEXT"); + + b.Property("BaselineHours") + .HasColumnType("INTEGER"); + + b.Property("BaselineStart") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HourlyDataJson") + .HasColumnType("TEXT"); + + b.Property("InterfaceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InterfaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxJitter") + .HasColumnType("REAL"); + + b.Property("MaxPacketLoss") + .HasColumnType("REAL"); + + b.Property("MedianBytesIn") + .HasColumnType("INTEGER"); + + b.Property("MedianBytesOut") + .HasColumnType("INTEGER"); + + b.Property("P95Latency") + .HasColumnType("REAL"); + + b.Property("P99Latency") + .HasColumnType("REAL"); + + b.Property("PeakBytesIn") + .HasColumnType("INTEGER"); + + b.Property("PeakBytesOut") + .HasColumnType("INTEGER"); + + b.Property("PeakLatency") + .HasColumnType("REAL"); + + b.Property("PeakUtilization") + .HasColumnType("REAL"); + + b.Property("RecommendedDownloadMbps") + .HasColumnType("REAL"); + + b.Property("RecommendedUploadMbps") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("InterfaceId"); + + b.HasIndex("BaselineStart"); + + b.HasIndex("DeviceId", "InterfaceId") + .IsUnique(); + + b.ToTable("SqmBaselines", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AgentConfiguration", b => + { + b.Property("AgentId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("AdditionalSettingsJson") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AuditEnabled") + .HasColumnType("INTEGER"); + + b.Property("AuditIntervalHours") + .HasColumnType("INTEGER"); + + b.Property("BatchSize") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FlushIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("MetricsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SqmEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("AgentId"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("LastSeenAt"); + + b.ToTable("AgentConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ClientSignalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApChannel") + .HasColumnType("INTEGER"); + + b.Property("ApClientCount") + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("ApModel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ApRadioBand") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ApTxPower") + .HasColumnType("INTEGER"); + + b.Property("Band") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("BottleneckLinkSpeedMbps") + .HasColumnType("REAL"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("ChannelWidth") + .HasColumnType("INTEGER"); + + b.Property("ClientIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("ClientMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopCount") + .HasColumnType("INTEGER"); + + b.Property("IsMlo") + .HasColumnType("INTEGER"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("MloLinksJson") + .HasColumnType("TEXT"); + + b.Property("NoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("RxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("SignalDbm") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TraceHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TraceJson") + .HasColumnType("TEXT"); + + b.Property("TxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraceHash"); + + b.HasIndex("ClientMac", "Timestamp"); + + b.ToTable("ClientSignalLogs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.LicenseInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("FeaturesJson") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IssueDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LicenseType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LicensedTo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MaxAgents") + .HasColumnType("INTEGER"); + + b.Property("MaxDevices") + .HasColumnType("INTEGER"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("LicenseKey") + .IsUnique(); + + b.ToTable("Licenses", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiSshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiSshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.GatewaySshSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3Port") + .HasColumnType("INTEGER"); + + b.Property("TcMonitorPort") + .HasColumnType("INTEGER"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GatewaySshSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DismissedIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IssueKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DismissedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IssueKey") + .IsUnique(); + + b.ToTable("DismissedIssues", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ExternalSpeedTestServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Scheme") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("ServerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ServerId") + .IsUnique(); + + b.ToTable("ExternalSpeedTestServers", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ModemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastPolled") + .HasColumnType("TEXT"); + + b.Property("ModemType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("PrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("QmiDevice") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Host"); + + b.HasIndex("Enabled"); + + b.ToTable("ModemConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FastPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbLongtermBucket") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbOrg") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InfluxDbReachable") + .HasColumnType("INTEGER"); + + b.Property("InfluxDbToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("InfluxDbUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbCheck") + .HasColumnType("TEXT"); + + b.Property("LastInfluxDbError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastSnmpDetection") + .HasColumnType("TEXT"); + + b.Property("LastSnmpSuccess") + .HasColumnType("TEXT"); + + b.Property("LastUpstreamDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("UpstreamDiscoveryNeedsReview") + .HasColumnType("INTEGER"); + + b.Property("MediumPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SlowPollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("SnmpCommunity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpDetectionState") + .HasColumnType("INTEGER"); + + b.Property("SnmpV3AuthPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SnmpV3Username") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SnmpVersion") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WanNeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MonitoringSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoringTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AutoLabel") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AutoDiscovered") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscoveryMethod") + .HasColumnType("INTEGER"); + + b.Property("DiscoveredProbeMode") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PingCount") + .HasColumnType("INTEGER"); + + b.Property("PollIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProbeMode") + .HasColumnType("INTEGER"); + + b.Property("PtrHostname") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .HasColumnType("INTEGER"); + + b.Property("VantagePoint") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("TargetId") + .IsUnique(); + + b.HasIndex("TargetType"); + + b.HasIndex("WanInterface"); + + b.ToTable("MonitoringTargets", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDiscoveryContext", b => + { + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AccessTechnology") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("L2NeighborMac") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("L2NeighborOui") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LastDiscoveryAt") + .HasColumnType("TEXT"); + + b.Property("NeedsReview") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("WanInterface"); + + b.ToTable("WanDiscoveryContexts", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.InterfaceNameMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IfIndex") + .HasColumnType("INTEGER"); + + b.Property("IfAlias") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IfName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsWan") + .HasColumnType("INTEGER"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("PortNumber") + .HasColumnType("INTEGER"); + + b.Property("SpeedMbps") + .HasColumnType("INTEGER"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceMac"); + + b.HasIndex("DeviceMac", "IfName") + .IsUnique(); + + b.ToTable("InterfaceNameMaps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpstreamDiscovery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AsnNumber") + .HasColumnType("INTEGER"); + + b.Property("AsnName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HopIp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HopNumber") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTracerouteAt") + .HasColumnType("TEXT"); + + b.Property("LastValidated") + .HasColumnType("TEXT"); + + b.Property("MonitoringTargetId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("WanInterface") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AsnNumber"); + + b.HasIndex("IsActive"); + + b.HasIndex("MonitoringTargetId"); + + b.ToTable("UpstreamDiscoveries", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.MonitoredSfp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceMac") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsPon") + .HasColumnType("INTEGER"); + + b.Property("IsMonitoredOnt") + .HasColumnType("INTEGER"); + + b.Property("PortName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SfpPart") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SfpVendor") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMonitoredOnt"); + + b.HasIndex("DeviceMac", "PortName") + .IsUnique(); + + b.ToTable("MonitoredSfps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.OuiVendor", b => + { + b.Property("OuiPrefix") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("VendorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("OuiPrefix"); + + b.ToTable("OuiVendors", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.DeviceSshConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Iperf3BinaryPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Iperf3DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("Iperf3ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SshPassword") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshPrivateKeyPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SshUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartIperf3Server") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Host"); + + b.HasIndex("Enabled"); + + b.ToTable("DeviceSshConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Iperf3Result", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientMac") + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("DeviceHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("DownloadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("DownloadBytes") + .HasColumnType("INTEGER"); + + b.Property("DownloadJitterMs") + .HasColumnType("REAL"); + + b.Property("DownloadLatencyMs") + .HasColumnType("REAL"); + + b.Property("DownloadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("JitterMs") + .HasColumnType("REAL"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("LocationAccuracyMeters") + .HasColumnType("INTEGER"); + + b.Property("LocalIp") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("ParallelStreams") + .HasColumnType("INTEGER"); + + b.Property("PathAnalysisJson") + .HasColumnType("TEXT"); + + b.Property("PingMs") + .HasColumnType("REAL"); + + b.Property("RawDownloadJson") + .HasColumnType("TEXT"); + + b.Property("RawUploadJson") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.Property("TestTime") + .HasColumnType("TEXT"); + + b.Property("UploadBitsPerSecond") + .HasColumnType("REAL"); + + b.Property("UploadBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadJitterMs") + .HasColumnType("REAL"); + + b.Property("UploadLatencyMs") + .HasColumnType("REAL"); + + b.Property("UploadRetransmits") + .HasColumnType("INTEGER"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExternalServerName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("WanNetworkGroup") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WifiChannel") + .HasColumnType("INTEGER"); + + b.Property("WifiNoiseDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiRadioProto") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiRadio") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("WifiIsMlo") + .HasColumnType("INTEGER"); + + b.Property("WifiMloLinksJson") + .HasColumnType("TEXT"); + + b.Property("WifiSignalDbm") + .HasColumnType("INTEGER"); + + b.Property("WifiTxRateKbps") + .HasColumnType("INTEGER"); + + b.Property("WifiRxRateKbps") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceHost"); + + b.HasIndex("Direction"); + + b.HasIndex("TestTime"); + + b.HasIndex("DeviceHost", "TestTime"); + + b.ToTable("Iperf3Results", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Value") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("SystemSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UniFiConnectionSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ControllerUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IgnoreControllerSSLErrors") + .HasColumnType("INTEGER"); + + b.Property("IsConfigured") + .HasColumnType("INTEGER"); + + b.Property("LastConnectedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RememberCredentials") + .HasColumnType("INTEGER"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UniFiConnectionSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.SqmWanConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BaselineLatencyMs") + .HasColumnType("REAL"); + + b.Property("CongestionSeverity") + .HasColumnType("REAL"); + + b.Property("ConnectionType") + .HasColumnType("INTEGER"); + + b.Property("LatencyThresholdMs") + .HasColumnType("REAL"); + + b.Property("LinkSpeedOverrideMbps") + .HasColumnType("INTEGER"); + + b.Property("BootDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interface") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NominalDownloadMbps") + .HasColumnType("INTEGER"); + + b.Property("NominalUploadMbps") + .HasColumnType("INTEGER"); + + b.Property("PingHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SpeedtestEveningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningHour") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("SpeedtestServerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WanNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WanNumber") + .IsUnique(); + + b.ToTable("SqmWanConfigurations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminSettings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.UpnpNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("HostIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Port") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostIp", "Port", "Protocol") + .IsUnique(); + + b.ToTable("UpnpNotes", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.ApLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApMac") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("MountType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApMac") + .IsUnique(); + + b.ToTable("ApLocations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CenterLatitude") + .HasColumnType("REAL"); + + b.Property("CenterLongitude") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Buildings", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuildingId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorMaterial") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("FloorNumber") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.ToTable("FloorPlans", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CropJson") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FloorPlanId") + .HasColumnType("INTEGER"); + + b.Property("ImagePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NeLatitude") + .HasColumnType("REAL"); + + b.Property("NeLongitude") + .HasColumnType("REAL"); + + b.Property("Opacity") + .HasColumnType("REAL"); + + b.Property("RotationDeg") + .HasColumnType("REAL"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SwLatitude") + .HasColumnType("REAL"); + + b.Property("SwLongitude") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FloorPlanId"); + + b.ToTable("FloorPlanImages", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PerfTweakSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsManuallyDeployed") + .HasColumnType("INTEGER"); + + b.Property("TweakId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TweakId") + .IsUnique(); + + b.ToTable("PerfTweakSettings"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.PlannedAp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("Floor") + .HasColumnType("INTEGER"); + + b.Property("OrientationDeg") + .HasColumnType("INTEGER"); + + b.Property("MountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TxPower24Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower5Dbm") + .HasColumnType("INTEGER"); + + b.Property("TxPower6Dbm") + .HasColumnType("INTEGER"); + + b.Property("AntennaMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedAps", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.Building", "Building") + .WithMany("Floors") + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlanImage", b => + { + b.HasOne("NetworkOptimizer.Storage.Models.FloorPlan", "FloorPlan") + .WithMany("Images") + .HasForeignKey("FloorPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FloorPlan"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.FloorPlan", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.Building", b => + { + b.Navigation("Floors"); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CooldownSeconds") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestOnly") + .HasColumnType("INTEGER"); + + b.Property("EscalationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EscalationSeverity") + .HasColumnType("INTEGER"); + + b.Property("EventTypePattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("TargetDevices") + .HasColumnType("TEXT"); + + b.Property("ThresholdPercent") + .HasColumnType("REAL"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.DeliveryChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelType") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("DigestSchedule") + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MinSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DeliveryChannels", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcknowledgedAt") + .HasColumnType("TEXT"); + + b.Property("ContextJson") + .HasColumnType("TEXT"); + + b.Property("DeliveredToChannels") + .HasColumnType("TEXT"); + + b.Property("DeliveryError") + .HasColumnType("TEXT"); + + b.Property("DeliverySucceeded") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("DeviceIp") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IncidentId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("INTEGER"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IncidentId"); + + b.HasIndex("RuleId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.HasIndex("Source", "TriggeredAt"); + + b.ToTable("AlertHistory", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.AlertIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlertCount") + .HasColumnType("INTEGER"); + + b.Property("CorrelationKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationKey"); + + b.HasIndex("Status"); + + b.ToTable("AlertIncidents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.CrowdSecReputation", b => + { + b.Property("Ip") + .HasColumnType("TEXT"); + + b.Property("ReputationJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.HasKey("Ip"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("CrowdSecReputations", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatNoiseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("SourceIp") + .HasColumnType("TEXT"); + + b.Property("DestIp") + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("Label") + .HasColumnType("TEXT"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Category", "Enabled"); + + b.HasIndex("Category", "SourceIp"); + + b.ToTable("ThreatNoiseFilters", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PatternType") + .HasColumnType("INTEGER"); + + b.Property("DetectedAt") + .HasColumnType("TEXT"); + + b.Property("DedupKey") + .HasColumnType("TEXT"); + + b.Property("SourceIpsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetPort") + .HasColumnType("INTEGER"); + + b.Property("EventCount") + .HasColumnType("INTEGER"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("LastAlertedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PatternType", "DetectedAt"); + + b.ToTable("ThreatPatterns", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("SourceIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourcePort") + .HasColumnType("INTEGER"); + + b.Property("DestIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DestPort") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SignatureId") + .HasColumnType("INTEGER"); + + b.Property("SignatureName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("InnerAlertId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("Asn") + .HasColumnType("INTEGER"); + + b.Property("AsnOrg") + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("REAL"); + + b.Property("Longitude") + .HasColumnType("REAL"); + + b.Property("DestCountryCode") + .HasColumnType("TEXT"); + + b.Property("DestCity") + .HasColumnType("TEXT"); + + b.Property("DestAsn") + .HasColumnType("INTEGER"); + + b.Property("DestAsnOrg") + .HasColumnType("TEXT"); + + b.Property("DestLatitude") + .HasColumnType("REAL"); + + b.Property("DestLongitude") + .HasColumnType("REAL"); + + b.Property("GeoEnriched") + .HasColumnType("INTEGER"); + + b.Property("KillChainStage") + .HasColumnType("INTEGER"); + + b.Property("EventSource") + .HasColumnType("INTEGER"); + + b.Property("Domain") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("TEXT"); + + b.Property("BytesTotal") + .HasColumnType("INTEGER"); + + b.Property("FlowDurationMs") + .HasColumnType("INTEGER"); + + b.Property("NetworkName") + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("TEXT"); + + b.Property("PatternId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SourceIp", "Timestamp"); + + b.HasIndex("DestPort", "Timestamp"); + + b.HasIndex("KillChainStage"); + + b.HasIndex("EventSource"); + + b.HasIndex("InnerAlertId") + .IsUnique(); + + b.HasIndex("PatternId"); + + b.ToTable("ThreatEvents", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatEvent", b => + { + b.HasOne("NetworkOptimizer.Threats.Models.ThreatPattern", "Pattern") + .WithMany("Events") + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("NetworkOptimizer.Threats.Models.ThreatPattern", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("NetworkOptimizer.Alerts.Models.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TaskType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomMorningMinute") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningHour") + .HasColumnType("INTEGER"); + + b.Property("CustomEveningMinute") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetConfig") + .HasColumnType("TEXT"); + + b.Property("LastRunAt") + .HasColumnType("TEXT"); + + b.Property("NextRunAt") + .HasColumnType("TEXT"); + + b.Property("LastStatus") + .HasColumnType("TEXT"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastResultSummary") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TaskType"); + + b.HasIndex("Enabled"); + + b.HasIndex("NextRunAt"); + + b.ToTable("ScheduledTasks", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ManualAdjustmentGb") + .HasColumnType("REAL"); + + b.Property("DataCapGb") + .HasColumnType("REAL"); + + b.Property("WarningThresholdPercent") + .HasColumnType("INTEGER"); + + b.Property("BillingCycleDayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey") + .IsUnique(); + + b.ToTable("WanDataUsageConfigs", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanDataUsageSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("WanKey") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RxBytes") + .HasColumnType("INTEGER"); + + b.Property("TxBytes") + .HasColumnType("INTEGER"); + + b.Property("IsCounterReset") + .HasColumnType("INTEGER"); + + b.Property("IsBaseline") + .HasColumnType("INTEGER"); + + b.Property("GatewayBootTime") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WanKey", "Timestamp"); + + b.ToTable("WanDataUsageSnapshots", (string)null); + }); + + modelBuilder.Entity("NetworkOptimizer.Storage.Models.WanSteerTrafficClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DstCidrsJson") + .HasColumnType("TEXT"); + + b.Property("DstPortsJson") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Probability") + .HasColumnType("REAL"); + + b.Property("Protocol") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SrcCidrsJson") + .HasColumnType("TEXT"); + + b.Property("SrcMacsJson") + .HasColumnType("TEXT"); + + b.Property("SrcPortsJson") + .HasColumnType("TEXT"); + + b.Property("TargetWanKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("WanSteerTrafficClasses"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.cs b/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.cs new file mode 100644 index 000000000..12a0dd116 --- /dev/null +++ b/src/NetworkOptimizer.Storage/Migrations/20260526120000_AddDestGeoAndFilterCategory.cs @@ -0,0 +1,154 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NetworkOptimizer.Storage.Migrations +{ + /// + /// Splits source/destination geo enrichment on ThreatEvents (preventing + /// destination ASNs from appearing on source-IP groupings) and adds + /// Category/Label/IsSystem to ThreatNoiseFilters so the audit report + /// can surface Infrastructure and TrustedUser activity in separate + /// categorized sub-tables. + /// + public partial class AddDestGeoAndFilterCategory : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + // --- ThreatEvents: destination geo/ASN columns --- + migrationBuilder.AddColumn( + name: "DestCountryCode", + table: "ThreatEvents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "DestCity", + table: "ThreatEvents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "DestAsn", + table: "ThreatEvents", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "DestAsnOrg", + table: "ThreatEvents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "DestLatitude", + table: "ThreatEvents", + type: "REAL", + nullable: true); + + migrationBuilder.AddColumn( + name: "DestLongitude", + table: "ThreatEvents", + type: "REAL", + nullable: true); + + // Tracks whether geo enrichment has been attempted on a row. Lets the + // backfill loop skip events whose source is RFC1918 (which legitimately + // have null source geo and would otherwise be re-processed forever). + migrationBuilder.AddColumn( + name: "GeoEnriched", + table: "ThreatEvents", + type: "INTEGER", + nullable: false, + defaultValue: false); + + // Backfill: existing rows with non-null CountryCode or DestCountryCode have + // already been through the old enrichment path. Mark them as enriched so + // the new flag-driven backfill does not re-process them. + migrationBuilder.Sql( + "UPDATE ThreatEvents SET GeoEnriched = 1 WHERE CountryCode IS NOT NULL;"); + + // Data integrity: pre-fix RFC1918 source rows have the destination's ASN/Country + // written to their source-geo fields (the bug being fixed). Null those fields + // and clear GeoEnriched so the backfill re-runs against the corrected logic + // and the source rows end up with empty source-geo (the truthful answer). + // SQLite has no CIDR functions so we enumerate RFC1918, loopback, and link-local + // prefixes explicitly. + migrationBuilder.Sql(@" + UPDATE ThreatEvents + SET CountryCode = NULL, + City = NULL, + Asn = NULL, + AsnOrg = NULL, + Latitude = NULL, + Longitude = NULL, + GeoEnriched = 0 + WHERE SourceIp LIKE '10.%' + OR SourceIp LIKE '192.168.%' + OR SourceIp LIKE '127.%' + OR SourceIp LIKE '169.254.%' + OR SourceIp LIKE '172.16.%' OR SourceIp LIKE '172.17.%' + OR SourceIp LIKE '172.18.%' OR SourceIp LIKE '172.19.%' + OR SourceIp LIKE '172.20.%' OR SourceIp LIKE '172.21.%' + OR SourceIp LIKE '172.22.%' OR SourceIp LIKE '172.23.%' + OR SourceIp LIKE '172.24.%' OR SourceIp LIKE '172.25.%' + OR SourceIp LIKE '172.26.%' OR SourceIp LIKE '172.27.%' + OR SourceIp LIKE '172.28.%' OR SourceIp LIKE '172.29.%' + OR SourceIp LIKE '172.30.%' OR SourceIp LIKE '172.31.%';"); + + // --- ThreatNoiseFilters: category, label, system flag --- + migrationBuilder.AddColumn( + name: "Category", + table: "ThreatNoiseFilters", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Label", + table: "ThreatNoiseFilters", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsSystem", + table: "ThreatNoiseFilters", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateIndex( + name: "IX_ThreatNoiseFilters_Category_Enabled", + table: "ThreatNoiseFilters", + columns: new[] { "Category", "Enabled" }); + + migrationBuilder.CreateIndex( + name: "IX_ThreatNoiseFilters_Category_SourceIp", + table: "ThreatNoiseFilters", + columns: new[] { "Category", "SourceIp" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ThreatNoiseFilters_Category_SourceIp", + table: "ThreatNoiseFilters"); + + migrationBuilder.DropIndex( + name: "IX_ThreatNoiseFilters_Category_Enabled", + table: "ThreatNoiseFilters"); + + migrationBuilder.DropColumn(name: "IsSystem", table: "ThreatNoiseFilters"); + migrationBuilder.DropColumn(name: "Label", table: "ThreatNoiseFilters"); + migrationBuilder.DropColumn(name: "Category", table: "ThreatNoiseFilters"); + + migrationBuilder.DropColumn(name: "DestLongitude", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "DestLatitude", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "DestAsnOrg", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "DestAsn", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "DestCity", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "DestCountryCode", table: "ThreatEvents"); + migrationBuilder.DropColumn(name: "GeoEnriched", table: "ThreatEvents"); + } + } +} diff --git a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs index 434cc2645..74a45ac15 100644 --- a/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs +++ b/src/NetworkOptimizer.Storage/Migrations/NetworkOptimizerDbContextModelSnapshot.cs @@ -2045,6 +2045,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("Label") + .HasColumnType("TEXT"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + b.Property("Enabled") .HasColumnType("INTEGER"); @@ -2053,6 +2062,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Category", "Enabled"); + + b.HasIndex("Category", "SourceIp"); + b.ToTable("ThreatNoiseFilters", (string)null); }); @@ -2170,6 +2183,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Longitude") .HasColumnType("REAL"); + b.Property("DestCountryCode") + .HasColumnType("TEXT"); + + b.Property("DestCity") + .HasColumnType("TEXT"); + + b.Property("DestAsn") + .HasColumnType("INTEGER"); + + b.Property("DestAsnOrg") + .HasColumnType("TEXT"); + + b.Property("DestLatitude") + .HasColumnType("REAL"); + + b.Property("DestLongitude") + .HasColumnType("REAL"); + + b.Property("GeoEnriched") + .HasColumnType("INTEGER"); + b.Property("KillChainStage") .HasColumnType("INTEGER"); diff --git a/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs b/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs index 4fe5be44f..288f55f1d 100644 --- a/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs +++ b/src/NetworkOptimizer.Storage/Models/NetworkOptimizerDbContext.cs @@ -385,6 +385,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.ToTable("ThreatNoiseFilters"); + entity.Property(e => e.Category).HasConversion(); + entity.HasIndex(e => new { e.Category, e.Enabled }); + entity.HasIndex(e => new { e.Category, e.SourceIp }); }); // ScheduledTask configuration diff --git a/src/NetworkOptimizer.Storage/Repositories/ThreatRepository.cs b/src/NetworkOptimizer.Storage/Repositories/ThreatRepository.cs index 5b67f620f..9c16f8c73 100644 --- a/src/NetworkOptimizer.Storage/Repositories/ThreatRepository.cs +++ b/src/NetworkOptimizer.Storage/Repositories/ThreatRepository.cs @@ -310,6 +310,111 @@ public async Task> GetTopSourcesAsync(DateTime from, DateT } } + public async Task> GetSourcesByCategoryAsync(DateTime from, DateTime to, + ThreatFilterCategory category, int count = 20, CancellationToken cancellationToken = default) + { + try + { + // Load enabled filters of this category (bypasses _noiseFilters which would + // exclude them - we want to surface exactly these source IPs). + var filters = await _context.ThreatNoiseFilters + .AsNoTracking() + .Where(f => f.Enabled && f.Category == category && f.SourceIp != null) + .ToListAsync(cancellationToken); + + if (filters.Count == 0) + return new List(); + + // Partition filter source IPs into exact matches and CIDR prefixes so the + // query can OR them in a single Where clause (translates to a small set + // of OR conditions on real providers and works on the in-memory provider). + var exactIps = filters + .Where(f => f.SourceIp != null && !f.SourceIp.Contains('/')) + .Select(f => f.SourceIp!) + .Distinct() + .ToList(); + + var cidrPrefixes = filters + .Select(f => ToCidrPrefix(f.SourceIp)) + .Where(p => p != null) + .Select(p => p!) + .Distinct() + .ToList(); + + if (exactIps.Count == 0 && cidrPrefixes.Count == 0) + return new List(); + + // Build a base query that does NOT apply noise filtering (the whole point + // is to surface what was suppressed). Severity filter still applies if set. + var baseQ = _context.ThreatEvents + .AsNoTracking() + .Where(e => e.Timestamp >= from && e.Timestamp <= to); + + if (_severityFilter is { Length: > 0 }) + baseQ = baseQ.Where(e => _severityFilter.Contains(e.Severity)); + + // Build one sub-query per match shape (exact IPs in one Contains, then one + // Where per CIDR prefix) and Union them. cidrPrefixes.Any(p => e.SourceIp.StartsWith(p)) + // would be more concise but the EF Core InMemory provider used by tests cannot + // translate that lambda pattern; per-prefix Union works on both InMemory and SQL. + var queries = new List>(); + if (exactIps.Count > 0) + queries.Add(baseQ.Where(e => exactIps.Contains(e.SourceIp))); + foreach (var prefix in cidrPrefixes) + { + var p = prefix; // capture per loop iteration + queries.Add(baseQ.Where(e => e.SourceIp.StartsWith(p))); + } + + var matched = queries.Aggregate((a, b) => a.Union(b)); + + var rows = await matched + .GroupBy(e => e.SourceIp) + .Select(g => new + { + SourceIp = g.Key, + EventCount = g.Count(), + CountryCode = g.First().CountryCode, + City = g.First().City, + Asn = g.First().Asn, + AsnOrg = g.First().AsnOrg, + MaxSeverity = g.Max(e => e.Severity) + }) + .OrderByDescending(s => s.EventCount) + .Take(count) + .ToListAsync(cancellationToken); + + // Attach the filter's label to each source by re-matching the IP locally. + // Exact-match filters take precedence over CIDR matches when both apply. + return rows.Select(r => + { + var label = filters + .Where(f => f.Matches(r.SourceIp, null, null)) + .OrderBy(f => f.SourceIp != null && f.SourceIp.Contains('/') ? 1 : 0) + .Select(f => f.Label) + .FirstOrDefault(l => !string.IsNullOrEmpty(l)); + + return new SourceIpSummary + { + SourceIp = r.SourceIp, + EventCount = r.EventCount, + CountryCode = r.CountryCode, + City = r.City, + Asn = r.Asn, + AsnOrg = r.AsnOrg, + MaxSeverity = r.MaxSeverity, + Label = label, + MatchedFilterCategory = category + }; + }).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get sources by category {Category}", category); + throw; + } + } + public async Task> GetTopTargetedPortsAsync(DateTime from, DateTime to, int count = 10, CancellationToken cancellationToken = default) { @@ -698,9 +803,11 @@ public async Task BackfillGeoDataAsync(Action> enrichActi { try { - // Get events with null geo data (tracked so changes are saved) + // Pick events that have not yet been processed by the enrichment service. + // Pre-fix events (where GeoEnriched defaults to false) get a one-time pass; + // RFC1918 sources will end up with null source geo, which is correct. var events = await _context.ThreatEvents - .Where(e => e.CountryCode == null) + .Where(e => !e.GeoEnriched) .OrderByDescending(e => e.Timestamp) .Take(batchSize) .ToListAsync(cancellationToken); @@ -976,6 +1083,30 @@ await _context.ThreatNoiseFilters .ExecuteUpdateAsync(s => s.SetProperty(f => f.Enabled, enabled), cancellationToken); } + public async Task DemoteAndDisableSystemFilterAsync(int filterId, CancellationToken cancellationToken = default) + { + // Load-modify-save instead of ExecuteUpdateAsync: ExecuteUpdate is not + // supported by the EF Core InMemory provider used by the test suite. + // SQLite supports it in production but cross-provider compatibility wins + // here over the marginal efficiency of a single statement. + var filter = await _context.ThreatNoiseFilters + .FirstOrDefaultAsync(f => f.Id == filterId, cancellationToken); + if (filter == null) return; + filter.IsSystem = false; + filter.Enabled = false; + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task PromoteToSystemFilterAsync(int filterId, CancellationToken cancellationToken = default) + { + var filter = await _context.ThreatNoiseFilters + .FirstOrDefaultAsync(f => f.Id == filterId, cancellationToken); + if (filter == null) return; + filter.IsSystem = true; + filter.Enabled = true; + await _context.SaveChangesAsync(cancellationToken); + } + #endregion } diff --git a/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecClient.cs b/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecClient.cs index 7afae8df1..4bece58c9 100644 --- a/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecClient.cs +++ b/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecClient.cs @@ -13,6 +13,8 @@ public enum CrowdSecLookupOutcome QuotaExhausted, /// Burst throttle ("Too Many Requests"). Transient - caller should retry later. BurstThrottled, + /// IP is RFC1918/loopback/link-local - reputation lookup not applicable. + NotApplicable, Error } diff --git a/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecEnrichmentService.cs b/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecEnrichmentService.cs index c1aa0a6c5..82abf2875 100644 --- a/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecEnrichmentService.cs +++ b/src/NetworkOptimizer.Threats/CrowdSec/CrowdSecEnrichmentService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.Logging; +using NetworkOptimizer.Core.Helpers; using NetworkOptimizer.Threats.Interfaces; using NetworkOptimizer.Threats.Models; @@ -26,6 +27,9 @@ public CrowdSecEnrichmentService( /// Positive hits are cached for (default 720 = 30 days). /// Negative hits (IP not in CrowdSec DB) are cached for 24 hours to avoid wasting API calls. /// Rate-limited or errored lookups are NOT cached so the IP can be retried later. + /// RFC1918/loopback/link-local IPs short-circuit with NotApplicable before any cache + /// or API call, since CrowdSec's CTI database is for public IPs only and a private-IP + /// lookup would burn quota for no value. /// public async Task<(CrowdSecIpInfo? Info, CrowdSecLookupOutcome Outcome)> GetReputationAsync( string ipAddress, @@ -34,6 +38,12 @@ public CrowdSecEnrichmentService( int cacheTtlHours = 720, CancellationToken cancellationToken = default) { + // Centralized guard: private/loopback/link-local IPs cannot have public reputation, + // so we short-circuit before touching cache or the API. Inline guards at call sites + // remain as defense-in-depth. + if (NetworkUtilities.IsPrivateIpAddress(ipAddress)) + return (null, CrowdSecLookupOutcome.NotApplicable); + // Check cache first var cached = await repository.GetCrowdSecCacheAsync(ipAddress, cancellationToken); if (cached != null) diff --git a/src/NetworkOptimizer.Threats/Enrichment/GeoEnrichmentService.cs b/src/NetworkOptimizer.Threats/Enrichment/GeoEnrichmentService.cs index 49347eda1..f83db9313 100644 --- a/src/NetworkOptimizer.Threats/Enrichment/GeoEnrichmentService.cs +++ b/src/NetworkOptimizer.Threats/Enrichment/GeoEnrichmentService.cs @@ -145,9 +145,12 @@ public GeoInfo Enrich(string ipAddress) } /// - /// Batch-enrich threat events with geo/ASN data. - /// For flow events where the source IP is internal (RFC1918), enriches on the destination IP - /// instead, since the external endpoint is what needs geo data. + /// Batch-enrich threat events with geo/ASN data. The source IP is enriched into + /// the source fields (Asn/AsnOrg/CountryCode/...) and the destination IP is + /// enriched into the destination fields (DestAsn/DestAsnOrg/DestCountryCode/...). + /// For RFC1918 addresses the lookup returns Empty, which is the correct answer. + /// Keeping source and destination enrichment in separate fields prevents the + /// "internal source tagged with external ASN" bug in source-IP groupings. /// public void EnrichEvents(List events) { @@ -157,31 +160,36 @@ public void EnrichEvents(List events) // Cache lookups per IP within the batch var cache = new Dictionary(); - foreach (var evt in events) + GeoInfo LookupCached(string? ip) { - // For flow events with internal source, enrich on the destination IP - var enrichIp = evt.SourceIp; - if (evt.EventSource == EventSource.TrafficFlow && - !string.IsNullOrEmpty(evt.SourceIp) && - IPAddress.TryParse(evt.SourceIp, out var srcIp) && - NetworkUtilities.IsPrivateIpAddress(srcIp) && - !string.IsNullOrEmpty(evt.DestIp)) - { - enrichIp = evt.DestIp; - } - - if (!cache.TryGetValue(enrichIp, out var geo)) + if (string.IsNullOrEmpty(ip)) return GeoInfo.Empty; + if (!cache.TryGetValue(ip, out var g)) { - geo = Enrich(enrichIp); - cache[enrichIp] = geo; + g = Enrich(ip); + cache[ip] = g; } + return g; + } - evt.CountryCode = geo.CountryCode; - evt.City = geo.City; - evt.Latitude = geo.Latitude; - evt.Longitude = geo.Longitude; - evt.Asn = geo.Asn; - evt.AsnOrg = geo.AsnOrg; + foreach (var evt in events) + { + var srcGeo = LookupCached(evt.SourceIp); + evt.CountryCode = srcGeo.CountryCode; + evt.City = srcGeo.City; + evt.Latitude = srcGeo.Latitude; + evt.Longitude = srcGeo.Longitude; + evt.Asn = srcGeo.Asn; + evt.AsnOrg = srcGeo.AsnOrg; + + var dstGeo = LookupCached(evt.DestIp); + evt.DestCountryCode = dstGeo.CountryCode; + evt.DestCity = dstGeo.City; + evt.DestLatitude = dstGeo.Latitude; + evt.DestLongitude = dstGeo.Longitude; + evt.DestAsn = dstGeo.Asn; + evt.DestAsnOrg = dstGeo.AsnOrg; + + evt.GeoEnriched = true; } } diff --git a/src/NetworkOptimizer.Threats/Interfaces/IThreatRepository.cs b/src/NetworkOptimizer.Threats/Interfaces/IThreatRepository.cs index 509358d93..d6066fe54 100644 --- a/src/NetworkOptimizer.Threats/Interfaces/IThreatRepository.cs +++ b/src/NetworkOptimizer.Threats/Interfaces/IThreatRepository.cs @@ -26,6 +26,15 @@ public interface IThreatRepository Task> GetEventsAsync(DateTime from, DateTime to, string? sourceIp = null, int? destPort = null, KillChainStage? stage = null, int limit = 1000, CancellationToken cancellationToken = default); Task GetThreatSummaryAsync(DateTime from, DateTime to, CancellationToken cancellationToken = default); Task> GetTopSourcesAsync(DateTime from, DateTime to, int count = 10, CancellationToken cancellationToken = default); + + /// + /// Return aggregated event counts grouped by source IP, restricted to source IPs that + /// match an enabled noise filter of the given category. Bypasses the BaseQuery noise + /// filter exclusion (since the whole point is to see what was suppressed). Used by + /// the audit report to render the "Known Infrastructure Activity" and "Trusted User + /// Activity" sub-tables. + /// + Task> GetSourcesByCategoryAsync(DateTime from, DateTime to, ThreatFilterCategory category, int count = 20, CancellationToken cancellationToken = default); Task> GetTopTargetedPortsAsync(DateTime from, DateTime to, int count = 10, CancellationToken cancellationToken = default); Task> GetCountryDistributionAsync(DateTime from, DateTime to, ThreatAction? actionFilter = null, CancellationToken cancellationToken = default); Task> GetTimelineAsync(DateTime from, DateTime to, int bucketMinutes = 60, CancellationToken cancellationToken = default); @@ -64,6 +73,21 @@ public interface IThreatRepository Task SaveNoiseFilterAsync(ThreatNoiseFilter filter, CancellationToken cancellationToken = default); Task DeleteNoiseFilterAsync(int filterId, CancellationToken cancellationToken = default); Task ToggleNoiseFilterAsync(int filterId, bool enabled, CancellationToken cancellationToken = default); + + /// + /// Demote a system filter (IsSystem=false) and disable it (Enabled=false). Used when + /// the optimizer's self-IP changes - the prior entry is no longer "the optimizer's + /// own IP" so we strip the system lock and disable it, but keep the row for audit + /// history. User can delete via UI when ready. + /// + Task DemoteAndDisableSystemFilterAsync(int filterId, CancellationToken cancellationToken = default); + + /// + /// Re-promote a demoted filter back to IsSystem=true and re-enable it. Used when the + /// optimizer's self-IP changes back to a previously-known address rather than creating + /// a duplicate row. + /// + Task PromoteToSystemFilterAsync(int filterId, CancellationToken cancellationToken = default); } /// @@ -91,6 +115,18 @@ public record SourceIpSummary public string? AsnOrg { get; set; } public int MaxSeverity { get; init; } + /// + /// When returned from GetSourcesByCategoryAsync, the human-readable label from + /// the matching noise filter (e.g., "Network Optimizer (self)"). + /// + public string? Label { get; set; } + + /// + /// When returned from GetSourcesByCategoryAsync, the category of the matching + /// noise filter. Null for results from GetTopSourcesAsync. + /// + public ThreatFilterCategory? MatchedFilterCategory { get; set; } + // CrowdSec CTI enrichment (populated post-query by dashboard service) public string? CrowdSecReputation { get; set; } public int? ThreatScore { get; set; } diff --git a/src/NetworkOptimizer.Threats/Models/ThreatEvent.cs b/src/NetworkOptimizer.Threats/Models/ThreatEvent.cs index 89b49a439..eee9ce0f8 100644 --- a/src/NetworkOptimizer.Threats/Models/ThreatEvent.cs +++ b/src/NetworkOptimizer.Threats/Models/ThreatEvent.cs @@ -49,7 +49,8 @@ public class ThreatEvent /// public string InnerAlertId { get; set; } = string.Empty; - // --- Geo/ASN enrichment --- + // --- Source IP geo/ASN enrichment --- + // These reflect the SOURCE IP. For RFC1918 sources, all fields remain null. public string? CountryCode { get; set; } public string? City { get; set; } public int? Asn { get; set; } @@ -57,6 +58,25 @@ public class ThreatEvent public double? Latitude { get; set; } public double? Longitude { get; set; } + // --- Destination IP geo/ASN enrichment --- + // These reflect the DEST IP. Populated for traffic-flow events where the + // external endpoint is the destination. Kept separate from the source fields + // so source-IP grouping (Top Threat Sources) does not display destination + // ASNs as if they belonged to the source. + public string? DestCountryCode { get; set; } + public string? DestCity { get; set; } + public int? DestAsn { get; set; } + public string? DestAsnOrg { get; set; } + public double? DestLatitude { get; set; } + public double? DestLongitude { get; set; } + + /// + /// True once geo enrichment has been attempted on this event. Drives the + /// backfill loop's predicate so RFC1918 events (which will always have null + /// source geo) are not re-processed forever. + /// + public bool GeoEnriched { get; set; } + /// /// Kill chain classification assigned by the classifier. /// diff --git a/src/NetworkOptimizer.Threats/Models/ThreatFilterCategory.cs b/src/NetworkOptimizer.Threats/Models/ThreatFilterCategory.cs new file mode 100644 index 000000000..189fff5bd --- /dev/null +++ b/src/NetworkOptimizer.Threats/Models/ThreatFilterCategory.cs @@ -0,0 +1,30 @@ +namespace NetworkOptimizer.Threats.Models; + +/// +/// Classifies a noise filter to control how matched events surface in reports +/// and dashboards. All categories still exclude events from BaseQuery, but the +/// audit PDF surfaces Infrastructure and TrustedUser in separate categorized +/// sub-tables so the user can see what was suppressed and why, instead of having +/// it silently disappear from the threat table. +/// +public enum ThreatFilterCategory +{ + /// + /// Generic noise. Hidden everywhere with no further accounting. + /// + Noise = 0, + + /// + /// Known infrastructure (the optimizer LXC itself, local DNS proxies, etc.). + /// Hidden from the top threat sources table, surfaced separately as + /// "Known Infrastructure Activity" with event counts and labels. + /// + Infrastructure = 1, + + /// + /// Trusted user devices (workstations, phones). These generate recon-class + /// events from normal browsing and discovery. Hidden from the top threat + /// sources table, surfaced separately as "Trusted User Activity". + /// + TrustedUser = 2 +} diff --git a/src/NetworkOptimizer.Threats/Models/ThreatNoiseFilter.cs b/src/NetworkOptimizer.Threats/Models/ThreatNoiseFilter.cs index d892f6716..4fb869f5a 100644 --- a/src/NetworkOptimizer.Threats/Models/ThreatNoiseFilter.cs +++ b/src/NetworkOptimizer.Threats/Models/ThreatNoiseFilter.cs @@ -34,6 +34,25 @@ public class ThreatNoiseFilter /// public string Description { get; set; } = string.Empty; + /// + /// Classifies the filter so the audit report can surface Infrastructure and + /// TrustedUser matches in separate sub-tables, while Noise stays fully hidden. + /// + public ThreatFilterCategory Category { get; set; } = ThreatFilterCategory.Noise; + + /// + /// Optional short label shown alongside the IP in categorized sub-tables + /// (e.g., "Network Optimizer (self)", "DNS proxy"). Distinct from + /// Description, which is shown in the management UI. + /// + public string? Label { get; set; } + + /// + /// System-managed entry that the UI prevents deleting or disabling. Used for + /// the auto-detected self entry created at startup. + /// + public bool IsSystem { get; set; } + public bool Enabled { get; set; } = true; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/src/NetworkOptimizer.Threats/ThreatCollectionService.cs b/src/NetworkOptimizer.Threats/ThreatCollectionService.cs index a398966fa..9233c1bde 100644 --- a/src/NetworkOptimizer.Threats/ThreatCollectionService.cs +++ b/src/NetworkOptimizer.Threats/ThreatCollectionService.cs @@ -1,3 +1,6 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -98,6 +101,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // Attempt auto-download of MaxMind databases if configured and missing/stale await TryAutoDownloadGeoDatabasesAsync(stoppingToken); + // Ensure a locked Infrastructure-category noise filter exists for our own IP + // so the optimizer's own scanning traffic does not dominate the audit report's + // Top Threat Sources table. + await EnsureSelfInfrastructureFilterAsync(stoppingToken); + while (!stoppingToken.IsCancellationRequested) { try @@ -742,6 +750,138 @@ private async Task LoadConfigAsync(IThreatSettingsAccessor settings, Cancellatio _retentionDays = days; } + /// + /// Detect the primary local IPv4 address and ensure an enabled, system-managed + /// Infrastructure-category noise filter exists for it. This stops the optimizer's + /// own port scans and probes from dominating the Top Threat Sources table in + /// audit PDFs. The entry is marked IsSystem so the UI prevents deletion while + /// the LXC is at this IP. + /// + /// On IP change (e.g., new DHCP lease) the prior system entry is demoted to + /// IsSystem=false and disabled, but kept in the table so the user can review + /// it for audit-history purposes and clean up when ready. If the same IP later + /// returns, the existing entry is re-promoted to IsSystem=true and re-enabled + /// rather than creating a duplicate. + /// + private async Task EnsureSelfInfrastructureFilterAsync(CancellationToken cancellationToken) + { + try + { + var selfIp = GetPrimaryLocalIpv4(); + if (string.IsNullOrEmpty(selfIp)) + { + _logger.LogDebug("Could not determine primary local IPv4 address, skipping self-filter registration"); + return; + } + + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var existing = await repository.GetNoiseFiltersAsync(cancellationToken); + + // If an active system entry for the current IP already exists, nothing to do. + var activeSystemMatch = existing.FirstOrDefault(f => + f.IsSystem && + f.Enabled && + f.Category == ThreatFilterCategory.Infrastructure && + string.Equals(f.SourceIp, selfIp, StringComparison.Ordinal)); + if (activeSystemMatch != null) + return; + + // Step 1: demote and disable any active system self-entries for OTHER IPs. + // The IP changed, so the old entry no longer represents this host. Keep the + // row for audit history; user can delete via UI once it's no longer needed. + var staleSystemEntries = existing + .Where(f => f.IsSystem && + f.Category == ThreatFilterCategory.Infrastructure && + (f.Label ?? string.Empty).Contains("(self)", StringComparison.OrdinalIgnoreCase) && + !string.Equals(f.SourceIp, selfIp, StringComparison.Ordinal)) + .ToList(); + + foreach (var stale in staleSystemEntries) + { + await repository.DemoteAndDisableSystemFilterAsync(stale.Id, cancellationToken); + _logger.LogInformation( + "Demoted stale system self-filter for {OldIp} (IP changed to {NewIp})", + stale.SourceIp, selfIp); + } + + // Step 2: if there is an existing entry for the current IP that was previously + // demoted (we came back to this IP), re-promote and re-enable it rather than + // creating a duplicate. Match on exact IP and Infrastructure category. + var revivable = existing.FirstOrDefault(f => + f.Category == ThreatFilterCategory.Infrastructure && + string.Equals(f.SourceIp, selfIp, StringComparison.Ordinal) && + (f.Label ?? string.Empty).Contains("(self)", StringComparison.OrdinalIgnoreCase)); + + if (revivable != null) + { + await repository.PromoteToSystemFilterAsync(revivable.Id, cancellationToken); + _logger.LogInformation("Re-promoted existing self-filter for {SelfIp}", selfIp); + return; + } + + // Step 3: no existing entry, create a fresh one. + var filter = new ThreatNoiseFilter + { + SourceIp = selfIp, + Category = ThreatFilterCategory.Infrastructure, + Label = "Network Optimizer (self)", + Description = "Auto-detected. Suppresses the optimizer's own scanning traffic from threat tables.", + IsSystem = true, + Enabled = true + }; + + await repository.SaveNoiseFilterAsync(filter, cancellationToken); + _logger.LogInformation("Registered system Infrastructure filter for self IP {SelfIp}", selfIp); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to ensure self infrastructure filter"); + } + } + + /// + /// Return the primary local IPv4 address by inspecting the route used for + /// outbound traffic. Uses a UDP socket connect (no packets actually sent) + /// to a public-routed address to ask the OS which interface would be used. + /// + private static string? GetPrimaryLocalIpv4() + { + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + // Connect on UDP does not transmit anything; it just selects the route. + // 1.1.1.1 is a stable public IP for route selection. + socket.Connect("1.1.1.1", 65530); + if (socket.LocalEndPoint is IPEndPoint ep) + return ep.Address.ToString(); + } + catch + { + // Fall through to interface enumeration. + } + + // Fallback: pick the first up, non-loopback, non-virtual interface with an IPv4 address. + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up) continue; + if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + if (nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue; + + foreach (var addr in nic.GetIPProperties().UnicastAddresses) + { + if (addr.Address.AddressFamily == AddressFamily.InterNetwork && + !IPAddress.IsLoopback(addr.Address)) + { + return addr.Address.ToString(); + } + } + } + + return null; + } + private async Task TryAutoDownloadGeoDatabasesAsync(CancellationToken cancellationToken) { if (_geoService.IsCityAvailable && _geoService.IsAsnAvailable) diff --git a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor index db3d70903..19dde0c3a 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor @@ -1655,6 +1655,32 @@ +

Audit Report Display Limits

+

+ How many source IPs appear in each section of the audit PDF / Markdown report. +

+ +
+ + + Maximum rows in the main "Top Threat Sources" table. Default: 5. +
+ +
+ + + Maximum rows in each categorized sub-table (Known Infrastructure Activity, Trusted User Activity). Default: 10. +
+ +
+ + + Candidate row count fetched from the DB per category before trimming to the display limit above. Default: 20. +
+
GeoLite2 Database: @@ -2296,6 +2322,9 @@ private bool threatCollectionEnabled = true; private int threatPollInterval = 5; private int threatRetentionDays = 90; + private int threatTopSourcesMainLimit = 5; + private int threatTopSourcesCategoryLimit = 10; + private int threatSourcesByCategoryQueryLimit = 20; private string threatGeoStatus = "Checking..."; private bool savingThreatSettings = false; private string threatSettingsMessage = ""; @@ -4421,6 +4450,15 @@ var retention = await SystemSettings.GetAsync("threats.retention_days"); if (int.TryParse(retention, out var r)) threatRetentionDays = r; + var topSrcMain = await SystemSettings.GetAsync("threats.top_sources_main_limit"); + if (int.TryParse(topSrcMain, out var tsm) && tsm > 0) threatTopSourcesMainLimit = tsm; + + var topSrcCat = await SystemSettings.GetAsync("threats.top_sources_category_limit"); + if (int.TryParse(topSrcCat, out var tsc) && tsc > 0) threatTopSourcesCategoryLimit = tsc; + + var srcByCat = await SystemSettings.GetAsync("threats.sources_by_category_query_limit"); + if (int.TryParse(srcByCat, out var sbc) && sbc > 0) threatSourcesByCategoryQueryLimit = sbc; + // Build geo database status from service properties if (GeoService.IsCityAvailable && GeoService.IsAsnAvailable) threatGeoStatus = "City and ASN databases loaded"; @@ -4468,6 +4506,18 @@ await SystemSettings.SetAsync("threats.poll_interval_minutes", threatPollInterval.ToString()); await SystemSettings.SetAsync("threats.retention_days", threatRetentionDays.ToString()); + // Clamp to sane ranges before persisting. UI input enforces min/max but a + // direct DB write could bypass that, so guard again here. + var mainLimit = Math.Clamp(threatTopSourcesMainLimit, 1, 100); + var categoryLimit = Math.Clamp(threatTopSourcesCategoryLimit, 1, 100); + var categoryQueryLimit = Math.Clamp(threatSourcesByCategoryQueryLimit, 1, 500); + threatTopSourcesMainLimit = mainLimit; + threatTopSourcesCategoryLimit = categoryLimit; + threatSourcesByCategoryQueryLimit = categoryQueryLimit; + await SystemSettings.SetAsync("threats.top_sources_main_limit", mainLimit.ToString()); + await SystemSettings.SetAsync("threats.top_sources_category_limit", categoryLimit.ToString()); + await SystemSettings.SetAsync("threats.sources_by_category_query_limit", categoryQueryLimit.ToString()); + if (!string.IsNullOrEmpty(maxmindAccountId)) { var encAcct = CredentialService.Encrypt(maxmindAccountId); diff --git a/src/NetworkOptimizer.Web/Components/Pages/ThreatDashboard.razor b/src/NetworkOptimizer.Web/Components/Pages/ThreatDashboard.razor index 5b53701e6..3adf23e48 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/ThreatDashboard.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/ThreatDashboard.razor @@ -195,13 +195,26 @@
+
+ + +
+
+ + +
- @if (!string.IsNullOrEmpty(_newFilterSourceIp) || !string.IsNullOrEmpty(_newFilterDestIp) || !string.IsNullOrEmpty(_newFilterDestPort) || !string.IsNullOrEmpty(_newFilterDescription)) + @if (!string.IsNullOrEmpty(_newFilterSourceIp) || !string.IsNullOrEmpty(_newFilterDestIp) || !string.IsNullOrEmpty(_newFilterDestPort) || !string.IsNullOrEmpty(_newFilterDescription) || !string.IsNullOrEmpty(_newFilterLabel)) { } @@ -217,8 +230,10 @@ Source IP Dest IP Dest Port + Category + Label Description - Actions + Actions @@ -228,15 +243,25 @@ @(filter.SourceIp ?? "*") @(filter.DestIp ?? "*") @(filter.DestPort?.ToString() ?? "*") - @filter.Description + @FormatCategory(filter.Category) + @(filter.Label ?? "") + + @filter.Description + @if (filter.IsSystem) + { + system + } +
+ disabled="@filter.IsSystem" + data-tooltip="@(filter.IsSystem ? "System entry - cannot delete" : "Delete filter")" data-tooltip-hover-only>X
@@ -457,6 +482,7 @@ else ASN Events Severity + Category @if (_crowdSecEnabled) { Reputation @@ -475,6 +501,20 @@ else @GetSeverityLabel(source.MaxSeverity) + + @if (source.MatchedFilterCategory == ThreatFilterCategory.Infrastructure) + { + Infrastructure + } + else if (source.MatchedFilterCategory == ThreatFilterCategory.TrustedUser) + { + Trusted User + } + else + { + - + } + @if (_crowdSecEnabled) { @@ -1755,6 +1795,8 @@ else .category-direction { background: rgba(59, 130, 246, 0.1); color: var(--text-secondary); } .category-type { background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); } .category-neutral { background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); } + .category-infrastructure { background: rgba(127, 119, 221, 0.15); color: #7f77dd; } + .category-trusted-user { background: rgba(29, 158, 117, 0.15); color: #1d9e75; } /* Noise filter active count badge */ .noise-filter-badge { @@ -2205,6 +2247,8 @@ else private string _newFilterDestIp = ""; private string _newFilterDestPort = ""; private string _newFilterDescription = ""; + private ThreatFilterCategory _newFilterCategory = ThreatFilterCategory.Noise; + private string _newFilterLabel = ""; // Search state private string _searchText = ""; @@ -3751,17 +3795,25 @@ else SourceIp = string.IsNullOrWhiteSpace(_newFilterSourceIp) ? null : _newFilterSourceIp.Trim(), DestIp = string.IsNullOrWhiteSpace(_newFilterDestIp) ? null : _newFilterDestIp.Trim(), DestPort = int.TryParse(_newFilterDestPort, out var p) ? p : null, + Category = _newFilterCategory, + Label = string.IsNullOrWhiteSpace(_newFilterLabel) ? null : _newFilterLabel.Trim(), Description = string.IsNullOrWhiteSpace(_newFilterDescription) ? BuildFilterDescription() : _newFilterDescription.Trim() }; // Must have at least one field if (filter.SourceIp == null && filter.DestIp == null && filter.DestPort == null) return; + // Infrastructure and TrustedUser categories require a source IP - otherwise there + // is no way to surface the matched events in the categorized sub-tables. + if (filter.Category != ThreatFilterCategory.Noise && filter.SourceIp == null) return; + await DashboardService.SaveNoiseFilterAsync(filter); _newFilterSourceIp = ""; _newFilterDestIp = ""; _newFilterDestPort = ""; _newFilterDescription = ""; + _newFilterLabel = ""; + _newFilterCategory = ThreatFilterCategory.Noise; await LoadNoiseFiltersAsync(); await LoadDataAsync(); } @@ -3772,6 +3824,8 @@ else _newFilterDestIp = ""; _newFilterDestPort = ""; _newFilterDescription = ""; + _newFilterLabel = ""; + _newFilterCategory = ThreatFilterCategory.Noise; } private string BuildFilterDescription() @@ -3785,6 +3839,8 @@ else private async Task ToggleFilterAsync(ThreatNoiseFilter filter) { + // System entries cannot be toggled. UI also disables the button. + if (filter.IsSystem) return; await DashboardService.ToggleNoiseFilterAsync(filter.Id, !filter.Enabled); await LoadNoiseFiltersAsync(); await LoadDataAsync(); @@ -3792,11 +3848,20 @@ else private async Task DeleteFilterAsync(ThreatNoiseFilter filter) { + // System entries cannot be deleted. UI also disables the button. + if (filter.IsSystem) return; await DashboardService.DeleteNoiseFilterAsync(filter.Id); await LoadNoiseFiltersAsync(); await LoadDataAsync(); } + private static string FormatCategory(ThreatFilterCategory category) => category switch + { + ThreatFilterCategory.Infrastructure => "Infrastructure", + ThreatFilterCategory.TrustedUser => "Trusted User", + _ => "Noise" + }; + private async Task QuickFilterFromDrilldown(string? sourceIp, string? destIp, int? destPort) { _newFilterSourceIp = sourceIp ?? ""; diff --git a/src/NetworkOptimizer.Web/Services/AuditService.cs b/src/NetworkOptimizer.Web/Services/AuditService.cs index 27a493ac2..ce3329b33 100644 --- a/src/NetworkOptimizer.Web/Services/AuditService.cs +++ b/src/NetworkOptimizer.Web/Services/AuditService.cs @@ -699,12 +699,41 @@ await _alertEventBus.PublishAsync(new AlertEvent { var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); var now = DateTime.UtcNow; + + // Display limits are configurable via system settings. Defaults preserve + // historical behavior: 5 rows in the main Top Threat Sources table, 10 rows + // in each categorized sub-table, and 20 candidate sources fetched from the + // category query before trimming to the sub-table display cap. + var topSourcesLimit = await GetIntSettingAsync("threats.top_sources_main_limit", 5); + var categorySubTableLimit = await GetIntSettingAsync("threats.top_sources_category_limit", 10); + var categoryQueryLimit = await GetIntSettingAsync("threats.sources_by_category_query_limit", 20); + + // Apply enabled noise filters so the audit report matches what the user + // sees on the threat dashboard. Without this, the audit always sees raw + // events including infrastructure self-scans and trusted-user noise. + var allFilters = await _threatRepository.GetNoiseFiltersAsync(); + var enabledFilters = allFilters.Where(f => f.Enabled).ToList(); + _threatRepository.SetNoiseFilters(enabledFilters); + var summary = await _threatRepository.GetThreatSummaryAsync(thirtyDaysAgo, now); if (summary.TotalEvents == 0) return null; - var topSources = await _threatRepository.GetTopSourcesAsync(thirtyDaysAgo, now, 5); + var topSources = await _threatRepository.GetTopSourcesAsync(thirtyDaysAgo, now, topSourcesLimit); var killChain = await _threatRepository.GetKillChainDistributionAsync(thirtyDaysAgo, now); + // Categorized sub-tables: events matching Infrastructure / TrustedUser + // filters are excluded from TopSources above but surfaced here so the + // user can see what was suppressed and why. + var infraSources = enabledFilters.Any(f => f.Category == ThreatFilterCategory.Infrastructure && f.SourceIp != null) + ? await _threatRepository.GetSourcesByCategoryAsync(thirtyDaysAgo, now, ThreatFilterCategory.Infrastructure, categoryQueryLimit) + : new List(); + + var trustedSources = enabledFilters.Any(f => f.Category == ThreatFilterCategory.TrustedUser && f.SourceIp != null) + ? await _threatRepository.GetSourcesByCategoryAsync(thirtyDaysAgo, now, ThreatFilterCategory.TrustedUser, categoryQueryLimit) + : new List(); + + var suppressedEventCount = infraSources.Sum(s => s.EventCount) + trustedSources.Sum(s => s.EventCount); + return new Reports.ThreatSummaryData { TotalEvents = summary.TotalEvents, @@ -719,7 +748,24 @@ await _alertEventBus.PublishAsync(new AlertEvent CountryCode = s.CountryCode, AsnOrg = s.AsnOrg, EventCount = s.EventCount - }).ToList() + }).ToList(), + InfrastructureSources = infraSources.Take(categorySubTableLimit).Select(s => new Reports.ThreatSourceEntry + { + Ip = s.SourceIp, + CountryCode = s.CountryCode, + AsnOrg = s.AsnOrg, + EventCount = s.EventCount, + Label = s.Label + }).ToList(), + TrustedUserSources = trustedSources.Take(categorySubTableLimit).Select(s => new Reports.ThreatSourceEntry + { + Ip = s.SourceIp, + CountryCode = s.CountryCode, + AsnOrg = s.AsnOrg, + EventCount = s.EventCount, + Label = s.Label + }).ToList(), + SuppressedEventCount = suppressedEventCount }; } catch (Exception ex) @@ -729,6 +775,15 @@ await _alertEventBus.PublishAsync(new AlertEvent } } + /// + /// Read an integer setting with a default if missing or unparseable. + /// + private async Task GetIntSettingAsync(string key, int defaultValue) + { + var raw = await _settingsService.GetAsync(key); + return int.TryParse(raw, out var v) && v > 0 ? v : defaultValue; + } + public Reports.ReportData BuildReportData(AuditResult result, string? clientName = null, Reports.ThreatSummaryData? threatSummary = null) { // Derive client name from gateway device if not provided diff --git a/src/NetworkOptimizer.Web/Services/ThreatDashboardService.cs b/src/NetworkOptimizer.Web/Services/ThreatDashboardService.cs index d93b251f6..c49a78a34 100644 --- a/src/NetworkOptimizer.Web/Services/ThreatDashboardService.cs +++ b/src/NetworkOptimizer.Web/Services/ThreatDashboardService.cs @@ -65,14 +65,35 @@ public async Task GetDashboardDataAsync(DateTime from, Date { try { - await ApplyNoiseFiltersToRepository(cancellationToken); + // Load all enabled filters once. Top Sources uses Noise-only so categorized + // sources (Infrastructure / TrustedUser) appear in the main table with a + // badge. Other dashboard widgets (kill chain, ports, patterns) keep the + // full-filter behavior so categorized noise does not dominate charts. + var allEnabled = (await _repository.GetNoiseFiltersAsync(cancellationToken)) + .Where(f => f.Enabled) + .ToList(); + var nonNoiseFilters = allEnabled + .Where(f => f.Category != ThreatFilterCategory.Noise) + .ToList(); + + // Top Sources: respect the master filter-active toggle, but when filters are + // active, only Noise-category exclusions apply here. Infrastructure and + // TrustedUser rows remain in the table for the badge to surface. + if (FiltersDisabled) + { + _repository.SetNoiseFilters([]); + } + else + { + _repository.SetNoiseFilters( + allEnabled.Where(f => f.Category == ThreatFilterCategory.Noise).ToList()); + } _repository.SetSeverityFilter(SeverityFilter); - var summary = await _repository.GetThreatSummaryAsync(from, to, cancellationToken); - var killChain = await _repository.GetKillChainDistributionAsync(from, to, cancellationToken); var topSources = await _repository.GetTopSourcesAsync(from, to, 10, cancellationToken); - // Re-enrich geo data directly on source IPs. - // Event-level CountryCode/AsnOrg may reflect the destination for flow events with private sources. + // Re-enrich geo data directly on source IPs. Event-level CountryCode/AsnOrg + // is null for RFC1918 sources post-fix, but this also handles older rows + // that might still have stale values until the migration scrub runs. foreach (var source in topSources) { var geo = _geoService.Enrich(source.SourceIp); @@ -80,10 +101,27 @@ public async Task GetDashboardDataAsync(DateTime from, Date source.City = geo.City; source.Asn = geo.Asn; source.AsnOrg = geo.AsnOrg; + + // Attach matched category for badge rendering. Exact-match filters take + // precedence over CIDR matches when both apply. + var match = nonNoiseFilters + .Where(f => f.Matches(source.SourceIp, null, null)) + .OrderBy(f => f.SourceIp != null && f.SourceIp.Contains('/') ? 1 : 0) + .FirstOrDefault(); + if (match != null) + { + source.MatchedFilterCategory = match.Category; + source.Label = match.Label; + } } + // All other dashboard queries: apply the master toggle uniformly with the + // full filter set, so categorized noise stays out of charts and pattern lists. + await ApplyNoiseFiltersToRepository(cancellationToken); var topPorts = await _repository.GetTopTargetedPortsAsync(from, to, 10, cancellationToken); var patterns = await _repository.GetPatternsAsync(from, to, limit: 20, cancellationToken: cancellationToken); + var summary = await _repository.GetThreatSummaryAsync(from, to, cancellationToken); + var killChain = await _repository.GetKillChainDistributionAsync(from, to, cancellationToken); _repository.SetSeverityFilter(null); // Enrich from DB cache (instant, no API calls) so previously looked-up IPs show badges diff --git a/tests/NetworkOptimizer.Storage.Tests/ThreatNoiseFilterPersistenceTests.cs b/tests/NetworkOptimizer.Storage.Tests/ThreatNoiseFilterPersistenceTests.cs new file mode 100644 index 000000000..85876fe64 --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/ThreatNoiseFilterPersistenceTests.cs @@ -0,0 +1,120 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Storage.Repositories; +using NetworkOptimizer.Threats.Models; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +/// +/// Verifies persistence of the new Category / Label / IsSystem fields on +/// ThreatNoiseFilter through the repository layer. +/// +public class ThreatNoiseFilterPersistenceTests : IDisposable +{ + private readonly NetworkOptimizerDbContext _context; + private readonly ThreatRepository _repository; + + public ThreatNoiseFilterPersistenceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _context = new NetworkOptimizerDbContext(options); + var logger = new Mock>(); + _repository = new ThreatRepository(_context, logger.Object); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task SaveNoiseFilterAsync_RoundTripsCategoryLabelAndIsSystem() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "Network Optimizer (self)", + Description = "Auto-detected.", + IsSystem = true, + Enabled = true + }; + + await _repository.SaveNoiseFilterAsync(filter); + + var stored = (await _repository.GetNoiseFiltersAsync()).Single(); + stored.Category.Should().Be(ThreatFilterCategory.Infrastructure); + stored.Label.Should().Be("Network Optimizer (self)"); + stored.IsSystem.Should().BeTrue(); + } + + [Fact] + public async Task SaveNoiseFilterAsync_LegacyFilterDefaultsToNoiseCategory() + { + // A pre-PR filter written without an explicit Category should land as Noise. + // Documents the implicit default that pre-existing rows pick up via the + // migration's defaultValue: 0 on the Category column. + var filter = new ThreatNoiseFilter + { + SourceIp = "198.51.100.50", + Description = "legacy filter" + }; + + await _repository.SaveNoiseFilterAsync(filter); + + var stored = (await _repository.GetNoiseFiltersAsync()).Single(); + stored.Category.Should().Be(ThreatFilterCategory.Noise); + stored.IsSystem.Should().BeFalse(); + stored.Label.Should().BeNull(); + } + + [Fact] + public async Task DemoteAndDisableSystemFilterAsync_StripsIsSystemAndDisables() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "Network Optimizer (self)", + Description = "self", + IsSystem = true, + Enabled = true + }; + await _repository.SaveNoiseFilterAsync(filter); + var stored = (await _repository.GetNoiseFiltersAsync()).Single(); + + await _repository.DemoteAndDisableSystemFilterAsync(stored.Id); + + var after = (await _repository.GetNoiseFiltersAsync()).Single(); + after.IsSystem.Should().BeFalse("system lock removed so user can manage it"); + after.Enabled.Should().BeFalse("disabled since the IP no longer represents this host"); + after.SourceIp.Should().Be("192.0.2.10", "row is kept in the table for audit history"); + after.Label.Should().Be("Network Optimizer (self)", "label preserved so user knows what it was"); + } + + [Fact] + public async Task PromoteToSystemFilterAsync_RestoresIsSystemAndEnables() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "Network Optimizer (self)", + Description = "self", + IsSystem = false, + Enabled = false + }; + await _repository.SaveNoiseFilterAsync(filter); + var stored = (await _repository.GetNoiseFiltersAsync()).Single(); + + await _repository.PromoteToSystemFilterAsync(stored.Id); + + var after = (await _repository.GetNoiseFiltersAsync()).Single(); + after.IsSystem.Should().BeTrue(); + after.Enabled.Should().BeTrue(); + } +} diff --git a/tests/NetworkOptimizer.Storage.Tests/ThreatRepositoryCategoryTests.cs b/tests/NetworkOptimizer.Storage.Tests/ThreatRepositoryCategoryTests.cs new file mode 100644 index 000000000..d5b300a27 --- /dev/null +++ b/tests/NetworkOptimizer.Storage.Tests/ThreatRepositoryCategoryTests.cs @@ -0,0 +1,286 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using NetworkOptimizer.Storage.Models; +using NetworkOptimizer.Storage.Repositories; +using NetworkOptimizer.Threats.Models; +using Xunit; + +namespace NetworkOptimizer.Storage.Tests; + +/// +/// Covers the source/destination geo separation in GetTopSourcesAsync and the new +/// GetSourcesByCategoryAsync entry point that powers the audit report's categorized +/// "Known Infrastructure Activity" and "Trusted User Activity" sub-tables. +/// +public class ThreatRepositoryCategoryTests : IDisposable +{ + private readonly NetworkOptimizerDbContext _context; + private readonly ThreatRepository _repository; + private readonly DateTime _now = DateTime.UtcNow; + + public ThreatRepositoryCategoryTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _context = new NetworkOptimizerDbContext(options); + var logger = new Mock>(); + _repository = new ThreatRepository(_context, logger.Object); + } + + public void Dispose() => _context.Dispose(); + + private ThreatEvent MakeEvent(string sourceIp, string destIp = "203.0.113.10", + string? sourceCountry = null, string? sourceAsnOrg = null, + string? destCountry = "US", string? destAsnOrg = "Example LLC", + int severity = 2) + { + return new ThreatEvent + { + Timestamp = _now, + SourceIp = sourceIp, + DestIp = destIp, + DestPort = 443, + Protocol = "tcp", + SignatureName = "test", + Category = "test", + Severity = severity, + Action = ThreatAction.Blocked, + InnerAlertId = Guid.NewGuid().ToString(), + CountryCode = sourceCountry, + AsnOrg = sourceAsnOrg, + DestCountryCode = destCountry, + DestAsnOrg = destAsnOrg, + GeoEnriched = true + }; + } + + // --- GetTopSourcesAsync: source vs destination separation --- + + [Fact] + public async Task GetTopSourcesAsync_PrivateSource_DoesNotInheritDestinationAsn() + { + // This is the bug the PR fixes. Pre-fix, the destination ASN (NextDNS, Google) + // was written onto the event's source-row fields and surfaced here. Post-fix, + // source enrichment is null for RFC1918 sources, which is the truthful answer. + _context.ThreatEvents.Add(MakeEvent( + sourceIp: "192.0.2.20", + destIp: "198.51.100.65", + sourceCountry: null, sourceAsnOrg: null, + destCountry: "US", destAsnOrg: "NextDNS, Inc.")); + await _context.SaveChangesAsync(); + + var result = await _repository.GetTopSourcesAsync(_now.AddHours(-1), _now.AddHours(1), 10); + + result.Should().HaveCount(1); + result[0].SourceIp.Should().Be("192.0.2.20"); + result[0].AsnOrg.Should().BeNull("an RFC1918 source has no public ASN"); + result[0].CountryCode.Should().BeNull("an RFC1918 source has no country"); + } + + [Fact] + public async Task GetTopSourcesAsync_PublicSource_ReturnsSourceEnrichment() + { + _context.ThreatEvents.Add(MakeEvent( + sourceIp: "8.8.8.8", + sourceCountry: "US", sourceAsnOrg: "Google LLC")); + await _context.SaveChangesAsync(); + + var result = await _repository.GetTopSourcesAsync(_now.AddHours(-1), _now.AddHours(1), 10); + + result.Should().HaveCount(1); + result[0].AsnOrg.Should().Be("Google LLC"); + result[0].CountryCode.Should().Be("US"); + } + + // --- GetSourcesByCategoryAsync --- + + [Fact] + public async Task GetSourcesByCategoryAsync_NoFiltersOfCategory_ReturnsEmpty() + { + _context.ThreatEvents.Add(MakeEvent("192.0.2.10")); + await _context.SaveChangesAsync(); + + var result = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetSourcesByCategoryAsync_ExactMatch_ReturnsMatchingSources() + { + _context.ThreatEvents.AddRange( + MakeEvent("192.0.2.10"), + MakeEvent("192.0.2.10"), + MakeEvent("192.0.2.30")); + _context.ThreatNoiseFilters.Add(new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "Network Optimizer (self)", + Description = "self", + Enabled = true + }); + await _context.SaveChangesAsync(); + + var result = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + + result.Should().HaveCount(1); + result[0].SourceIp.Should().Be("192.0.2.10"); + result[0].EventCount.Should().Be(2); + result[0].Label.Should().Be("Network Optimizer (self)"); + result[0].MatchedFilterCategory.Should().Be(ThreatFilterCategory.Infrastructure); + } + + [Fact] + public async Task GetSourcesByCategoryAsync_CidrFilter_MatchesSubnet() + { + _context.ThreatEvents.AddRange( + MakeEvent("192.0.2.10"), + MakeEvent("192.0.2.20"), + MakeEvent("198.51.100.25")); // outside /24 + _context.ThreatNoiseFilters.Add(new ThreatNoiseFilter + { + SourceIp = "192.0.2.0/24", + Category = ThreatFilterCategory.Infrastructure, + Label = "Servers VLAN infrastructure", + Description = "servers vlan", + Enabled = true + }); + await _context.SaveChangesAsync(); + + var result = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + + result.Should().HaveCount(2); + result.Select(r => r.SourceIp).Should().BeEquivalentTo(new[] { "192.0.2.10", "192.0.2.20" }); + result.All(r => r.Label == "Servers VLAN infrastructure").Should().BeTrue(); + } + + [Fact] + public async Task GetSourcesByCategoryAsync_DisabledFilter_Excluded() + { + _context.ThreatEvents.Add(MakeEvent("192.0.2.10")); + _context.ThreatNoiseFilters.Add(new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Description = "self", + Enabled = false + }); + await _context.SaveChangesAsync(); + + var result = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetSourcesByCategoryAsync_BypassesNoiseFilterExclusion() + { + // The whole point: even though SetNoiseFilters would normally hide events + // from this source, GetSourcesByCategoryAsync surfaces them so the audit + // report can render them in the categorized sub-table. + _context.ThreatEvents.Add(MakeEvent("192.0.2.10")); + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "self", + Description = "self", + Enabled = true + }; + _context.ThreatNoiseFilters.Add(filter); + await _context.SaveChangesAsync(); + + // Activate the filter on the repo (this is what AuditService.BuildThreatSummaryAsync does). + _repository.SetNoiseFilters(new List { filter }); + + var topSources = await _repository.GetTopSourcesAsync(_now.AddHours(-1), _now.AddHours(1)); + var infraSources = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + + topSources.Should().BeEmpty("the noise filter excludes this source from the main table"); + infraSources.Should().HaveCount(1, "the category-specific query surfaces it back"); + } + + [Fact] + public async Task GetSourcesByCategoryAsync_OnlyReturnsRequestedCategory() + { + _context.ThreatEvents.AddRange( + MakeEvent("192.0.2.10"), + MakeEvent("203.0.113.10")); + _context.ThreatNoiseFilters.AddRange( + new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "self", + Description = "infra", + Enabled = true + }, + new ThreatNoiseFilter + { + SourceIp = "203.0.113.10", + Category = ThreatFilterCategory.TrustedUser, + Label = "Engineer workstation", + Description = "trusted", + Enabled = true + }); + await _context.SaveChangesAsync(); + + var infra = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.Infrastructure); + var trusted = await _repository.GetSourcesByCategoryAsync( + _now.AddHours(-1), _now.AddHours(1), ThreatFilterCategory.TrustedUser); + + infra.Should().ContainSingle().Which.SourceIp.Should().Be("192.0.2.10"); + trusted.Should().ContainSingle().Which.SourceIp.Should().Be("203.0.113.10"); + } + + [Fact] + public async Task GetTopSourcesAsync_NoiseOnlyFilterSet_AllowsInfrastructureRowsThrough() + { + // The dashboard's Option B layout depends on this guarantee: when only + // Noise-category filters are applied to the repo, Infrastructure and + // TrustedUser sources must still appear in GetTopSourcesAsync so the + // razor can attach a Category badge and show them in the main table. + _context.ThreatEvents.AddRange( + MakeEvent("192.0.2.10"), + MakeEvent("192.0.2.10"), + MakeEvent("198.51.100.99")); + var infraFilter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "self", + Description = "infra", + Enabled = true + }; + var noiseFilter = new ThreatNoiseFilter + { + SourceIp = "198.51.100.99", + Category = ThreatFilterCategory.Noise, + Description = "junk", + Enabled = true + }; + _context.ThreatNoiseFilters.AddRange(infraFilter, noiseFilter); + await _context.SaveChangesAsync(); + + // Simulate the dashboard service applying ONLY Noise-category filters + // so Infrastructure rows remain visible in the main table. + _repository.SetNoiseFilters(new List { noiseFilter }); + + var topSources = await _repository.GetTopSourcesAsync(_now.AddHours(-1), _now.AddHours(1), 10); + + topSources.Should().HaveCount(1, "the Noise-tagged source is excluded, but the Infrastructure one is not"); + topSources[0].SourceIp.Should().Be("192.0.2.10"); + topSources[0].EventCount.Should().Be(2); + } +} diff --git a/tests/NetworkOptimizer.Threats.Tests/CrowdSecEnrichmentServiceTests.cs b/tests/NetworkOptimizer.Threats.Tests/CrowdSecEnrichmentServiceTests.cs new file mode 100644 index 000000000..13db28be9 --- /dev/null +++ b/tests/NetworkOptimizer.Threats.Tests/CrowdSecEnrichmentServiceTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using NetworkOptimizer.Threats.CrowdSec; +using NetworkOptimizer.Threats.Interfaces; +using NetworkOptimizer.Threats.Models; +using Xunit; + +namespace NetworkOptimizer.Threats.Tests; + +/// +/// Tests the centralized private-IP guard on CrowdSecEnrichmentService. +/// Private IPs must short-circuit BEFORE any cache read or API call - otherwise +/// background hydration on RFC1918 sources would burn through the daily quota for +/// reputation data that CrowdSec's CTI does not have. +/// +public class CrowdSecEnrichmentServiceTests +{ + private readonly Mock> _clientLogger = new(); + private readonly Mock> _serviceLogger = new(); + private readonly Mock _httpFactory = new(); + + private CrowdSecEnrichmentService MakeService() + { + var client = new CrowdSecClient(_httpFactory.Object, _clientLogger.Object); + return new CrowdSecEnrichmentService(client, _serviceLogger.Object); + } + + [Theory] + [InlineData("10.99.99.99")] + [InlineData("192.168.1.5")] + [InlineData("172.16.5.5")] + [InlineData("127.0.0.1")] + [InlineData("169.254.10.10")] + public async Task GetReputationAsync_PrivateIp_ReturnsNotApplicable_WithoutTouchingCacheOrApi(string ip) + { + var svc = MakeService(); + var repo = new Mock(MockBehavior.Strict); + // Strict mock: any unconfigured call throws. If the guard fails to fire, + // GetCrowdSecCacheAsync would be called and this test would fail. + + var (info, outcome) = await svc.GetReputationAsync(ip, "fake-api-key", repo.Object); + + info.Should().BeNull(); + outcome.Should().Be(CrowdSecLookupOutcome.NotApplicable); + repo.Verify(r => r.GetCrowdSecCacheAsync(It.IsAny(), It.IsAny()), Times.Never); + repo.Verify(r => r.SaveCrowdSecCacheAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetReputationAsync_PublicIp_DoesNotShortCircuit() + { + // Verifies the guard is RFC1918-only and does not over-block. A public IP + // hits the cache first (which returns null here) then would query the API. + // We do not run the API since there is no HttpClient wired up; the test just + // verifies the cache layer was consulted before falling through. + var svc = MakeService(); + var repo = new Mock(); + repo.Setup(r => r.GetCrowdSecCacheAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((CrowdSecReputation?)null); + + var (_, outcome) = await svc.GetReputationAsync("8.8.8.8", "fake-key", repo.Object); + + repo.Verify(r => r.GetCrowdSecCacheAsync("8.8.8.8", It.IsAny()), Times.Once); + outcome.Should().NotBe(CrowdSecLookupOutcome.NotApplicable); + } +} diff --git a/tests/NetworkOptimizer.Threats.Tests/GeoEnrichmentServiceTests.cs b/tests/NetworkOptimizer.Threats.Tests/GeoEnrichmentServiceTests.cs new file mode 100644 index 000000000..3d8836dda --- /dev/null +++ b/tests/NetworkOptimizer.Threats.Tests/GeoEnrichmentServiceTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using NetworkOptimizer.Threats.Enrichment; +using NetworkOptimizer.Threats.Models; +using Xunit; + +namespace NetworkOptimizer.Threats.Tests; + +/// +/// Tests the source/destination geo enrichment contract. Full MaxMind lookups need +/// .mmdb files on disk so these tests exercise the boundaries that do not depend on +/// a real database: private-IP filtering, no-DB no-op behavior, and the per-event +/// invariants the bug fix relies on. +/// +public class GeoEnrichmentServiceTests +{ + private GeoEnrichmentService Make() => + new(new Mock>().Object); + + private ThreatEvent MakeEvent(string sourceIp, string destIp) => new() + { + Timestamp = DateTime.UtcNow, + SourceIp = sourceIp, + DestIp = destIp, + DestPort = 443, + Protocol = "tcp", + SignatureName = "test", + Category = "test", + InnerAlertId = Guid.NewGuid().ToString() + }; + + [Fact] + public void Enrich_PrivateIpv4_ReturnsEmpty() + { + var svc = Make(); + + svc.Enrich("10.99.99.99").Should().Be(GeoInfo.Empty); + svc.Enrich("192.168.1.1").Should().Be(GeoInfo.Empty); + svc.Enrich("172.16.5.5").Should().Be(GeoInfo.Empty); + } + + [Fact] + public void Enrich_LoopbackAndLinkLocal_ReturnsEmpty() + { + var svc = Make(); + + svc.Enrich("127.0.0.1").Should().Be(GeoInfo.Empty); + svc.Enrich("169.254.1.1").Should().Be(GeoInfo.Empty); + } + + [Fact] + public void Enrich_InvalidIp_ReturnsEmpty() + { + var svc = Make(); + + svc.Enrich("not-an-ip").Should().Be(GeoInfo.Empty); + svc.Enrich("").Should().Be(GeoInfo.Empty); + } + + [Fact] + public void EnrichEvents_EmptyList_DoesNotThrow() + { + var svc = Make(); + var act = () => svc.EnrichEvents(new List()); + act.Should().NotThrow(); + } + + [Fact] + public void EnrichEvents_NoDatabaseLoaded_LeavesFieldsNull() + { + // With no .mmdb files on disk, both readers are null and EnrichEvents is a + // no-op. The pre-fix code path would still write destination ASN onto source + // fields under these conditions if the redirect logic had run - this test + // documents that none of that happens anymore. + var svc = Make(); + var events = new List + { + MakeEvent("192.0.2.20", "198.51.100.65") + }; + + svc.EnrichEvents(events); + + events[0].CountryCode.Should().BeNull(); + events[0].AsnOrg.Should().BeNull(); + events[0].DestCountryCode.Should().BeNull(); + events[0].DestAsnOrg.Should().BeNull(); + events[0].GeoEnriched.Should().BeFalse( + "the backfill loop relies on this flag to avoid re-processing when no DB is available"); + } + + [Fact] + public void IsCityAvailable_NoInit_ReturnsFalse() + { + var svc = Make(); + svc.IsCityAvailable.Should().BeFalse(); + svc.IsAsnAvailable.Should().BeFalse(); + } +} diff --git a/tests/NetworkOptimizer.Threats.Tests/ThreatNoiseFilterTests.cs b/tests/NetworkOptimizer.Threats.Tests/ThreatNoiseFilterTests.cs new file mode 100644 index 000000000..f64e2b822 --- /dev/null +++ b/tests/NetworkOptimizer.Threats.Tests/ThreatNoiseFilterTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using NetworkOptimizer.Threats.Models; +using Xunit; + +namespace NetworkOptimizer.Threats.Tests; + +/// +/// Verifies ThreatNoiseFilter's new Category, Label, IsSystem fields and that the +/// existing Matches() contract is unchanged by their addition. +/// +public class ThreatNoiseFilterTests +{ + [Fact] + public void DefaultCategory_IsNoise() + { + var filter = new ThreatNoiseFilter(); + filter.Category.Should().Be(ThreatFilterCategory.Noise); + } + + [Fact] + public void DefaultIsSystem_IsFalse() + { + var filter = new ThreatNoiseFilter(); + filter.IsSystem.Should().BeFalse(); + } + + [Fact] + public void Matches_ExactSourceIp_StillWorksWithCategorySet() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + Category = ThreatFilterCategory.Infrastructure, + Label = "self" + }; + + filter.Matches("192.0.2.10", null, null).Should().BeTrue(); + filter.Matches("192.0.2.11", null, null).Should().BeFalse(); + } + + [Fact] + public void Matches_CidrSourceIp_StillWorks() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.0/24", + Category = ThreatFilterCategory.Infrastructure + }; + + filter.Matches("192.0.2.10", null, null).Should().BeTrue(); + filter.Matches("192.0.2.20", null, null).Should().BeTrue(); + filter.Matches("198.51.100.25", null, null).Should().BeFalse(); + } + + [Fact] + public void Matches_SourceAndDestAndPort_AllRequired() + { + var filter = new ThreatNoiseFilter + { + SourceIp = "192.0.2.10", + DestIp = "8.8.8.8", + DestPort = 53, + Category = ThreatFilterCategory.Noise + }; + + filter.Matches("192.0.2.10", "8.8.8.8", 53).Should().BeTrue(); + filter.Matches("192.0.2.10", "8.8.8.8", 443).Should().BeFalse(); + filter.Matches("192.0.2.10", "1.1.1.1", 53).Should().BeFalse(); + } +}