From 8e90e9091857ac66c0026fe982cde06e05271f93 Mon Sep 17 00:00:00 2001 From: "TJ @ Ozark Connect" <109822114+tvancott42@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:03:13 -0600 Subject: [PATCH 1/3] Skip hardware-disabled ports and improve WAN detection via ethernet_overrides (#401) * Skip hardware-disabled ports (enable: false) in audit rules Ports disabled at the hardware level via the UniFi enable flag were still being evaluated by UnusedPortRule and AccessPortVlanRule because only forward mode "disabled" was checked. This caused false positives on ports prepped for future use (e.g. SFP+ for upcoming GPON). * Revert AccessPortVlanRule skip - VLAN exposure should flag on disabled ports Hardware-disabled ports should still warn about excessive tagged VLANs since enabling the port later would immediately expose all VLANs. * Use ethernet_overrides to detect WAN ports on gateways The existing check only looked at network_name on the port, which can show "lan" even when the port is assigned to a WAN networkgroup via ethernet_overrides. Now also checks the device-level ethernet_overrides array to identify WAN-assigned interfaces (e.g. eth6 -> WAN4 for GPON). --- .../Analyzers/PortSecurityAnalyzer.cs | 30 +++++++-- src/NetworkOptimizer.Audit/Models/PortInfo.cs | 6 ++ .../Rules/UnusedPortRule.cs | 4 +- .../Rules/UnusedPortRuleTests.cs | 64 ++++++++++++++++++- 4 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/NetworkOptimizer.Audit/Analyzers/PortSecurityAnalyzer.cs b/src/NetworkOptimizer.Audit/Analyzers/PortSecurityAnalyzer.cs index 8fbf77c17..65df386f8 100644 --- a/src/NetworkOptimizer.Audit/Analyzers/PortSecurityAnalyzer.cs +++ b/src/NetworkOptimizer.Audit/Analyzers/PortSecurityAnalyzer.cs @@ -393,8 +393,26 @@ public List ExtractSwitches(JsonElement deviceData, List WAN lookup from ethernet_overrides (gateways only) + HashSet? wanIfnames = null; + if (device.TryGetProperty("ethernet_overrides", out var ethOverrides) && + ethOverrides.ValueKind == JsonValueKind.Array) + { + foreach (var ov in ethOverrides.EnumerateArray()) + { + var ifn = ov.GetStringOrNull("ifname"); + var ng = ov.GetStringOrNull("networkgroup"); + if (!string.IsNullOrEmpty(ifn) && !string.IsNullOrEmpty(ng) && + ng.StartsWith("WAN", StringComparison.OrdinalIgnoreCase)) + { + wanIfnames ??= new HashSet(StringComparer.OrdinalIgnoreCase); + wanIfnames.Add(ifn); + } + } + } + var ports = device.GetArrayOrEmpty("port_table") - .Select(port => ParsePort(port, switchInfoPlaceholder, networks, clientsByPort, historyByPort, portProfiles, deviceUplinkLookup)) + .Select(port => ParsePort(port, switchInfoPlaceholder, networks, clientsByPort, historyByPort, portProfiles, deviceUplinkLookup, wanIfnames)) .Where(p => p != null) .Cast() .ToList(); @@ -488,7 +506,7 @@ private SwitchCapabilities ParseSwitchCapabilities(JsonElement device) /// Parse a single port from JSON /// private PortInfo? ParsePort(JsonElement port, SwitchInfo switchInfo, List networks, Dictionary<(string, int), UniFiClientResponse> clientsByPort, Dictionary<(string, int), UniFiClientDetailResponse>? historyByPort = null) - => ParsePort(port, switchInfo, networks, clientsByPort, historyByPort, portProfiles: null, deviceUplinkLookup: null); + => ParsePort(port, switchInfo, networks, clientsByPort, historyByPort, portProfiles: null, deviceUplinkLookup: null, wanIfnames: null); /// /// Parse a single port from JSON with port profile resolution and device uplink detection @@ -500,7 +518,8 @@ private SwitchCapabilities ParseSwitchCapabilities(JsonElement device) Dictionary<(string, int), UniFiClientResponse> clientsByPort, Dictionary<(string, int), UniFiClientDetailResponse>? historyByPort, Dictionary? portProfiles, - Dictionary<(string, int), string>? deviceUplinkLookup) + Dictionary<(string, int), string>? deviceUplinkLookup, + HashSet? wanIfnames = null) { var portIdx = port.GetIntOrDefault("port_idx", -1); if (portIdx < 0) @@ -599,7 +618,9 @@ private SwitchCapabilities ParseSwitchCapabilities(JsonElement device) forwardMode = "custom"; var networkName = port.GetStringOrNull("network_name")?.ToLowerInvariant(); - var isWan = networkName?.StartsWith("wan") ?? false; + var ifname = port.GetStringOrNull("ifname"); + var isWan = (networkName?.StartsWith("wan") ?? false) || + (wanIfnames != null && !string.IsNullOrEmpty(ifname) && wanIfnames.Contains(ifname)); var poeEnable = port.GetBoolOrDefault("poe_enable"); var portPoe = port.GetBoolOrDefault("port_poe"); @@ -650,6 +671,7 @@ private SwitchCapabilities ParseSwitchCapabilities(JsonElement device) { PortIndex = portIdx, Name = portName, + IsEnabled = port.GetBoolOrDefault("enable", defaultValue: true), IsUp = port.GetBoolOrDefault("up"), Speed = port.GetIntOrDefault("speed"), ForwardMode = forwardMode, diff --git a/src/NetworkOptimizer.Audit/Models/PortInfo.cs b/src/NetworkOptimizer.Audit/Models/PortInfo.cs index 79e674525..0b5eaa7ed 100644 --- a/src/NetworkOptimizer.Audit/Models/PortInfo.cs +++ b/src/NetworkOptimizer.Audit/Models/PortInfo.cs @@ -17,6 +17,12 @@ public class PortInfo /// public string? Name { get; init; } + /// + /// Whether the port is administratively enabled (hardware-level enable/disable). + /// Defaults to true when not present in the API response. + /// + public bool IsEnabled { get; init; } = true; + /// /// Whether the port link is up /// diff --git a/src/NetworkOptimizer.Audit/Rules/UnusedPortRule.cs b/src/NetworkOptimizer.Audit/Rules/UnusedPortRule.cs index 25c2e928a..c846ba9fd 100644 --- a/src/NetworkOptimizer.Audit/Rules/UnusedPortRule.cs +++ b/src/NetworkOptimizer.Audit/Rules/UnusedPortRule.cs @@ -51,8 +51,8 @@ public static void SetThresholds(int unusedPortDays, int namedPortDays) if (port.IsUplink || port.IsWan) return null; - // Check if port is disabled - if (port.ForwardMode == "disabled") + // Check if port is disabled (either via forward mode or hardware enable flag) + if (port.ForwardMode == "disabled" || !port.IsEnabled) return null; // Correctly configured // Skip if port has an intentional unrestricted access profile diff --git a/tests/NetworkOptimizer.Audit.Tests/Rules/UnusedPortRuleTests.cs b/tests/NetworkOptimizer.Audit.Tests/Rules/UnusedPortRuleTests.cs index 56b38b963..79c906f88 100644 --- a/tests/NetworkOptimizer.Audit.Tests/Rules/UnusedPortRuleTests.cs +++ b/tests/NetworkOptimizer.Audit.Tests/Rules/UnusedPortRuleTests.cs @@ -648,6 +648,66 @@ public void SetThresholds_ChangesNamedPortThreshold() #endregion + #region Evaluate Tests - Hardware-Disabled Ports (enable: false) + + [Fact] + public void Evaluate_HardwareDisabledPort_ReturnsNull() + { + // Arrange - Port with enable=false (hardware-disabled, e.g. prepped SFP+ port) + var port = CreatePort(portName: "SFP+ 2", isUp: false, forwardMode: "all", isEnabled: false); + var networks = CreateNetworkList(); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert + result.Should().BeNull("hardware-disabled port (enable=false) should not be flagged"); + } + + [Fact] + public void Evaluate_HardwareDisabledPort_NativeMode_ReturnsNull() + { + // Arrange - Disabled port with native forward mode + var port = CreatePort(portName: "Port 3", isUp: false, forwardMode: "native", isEnabled: false); + var networks = CreateNetworkList(); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert + result.Should().BeNull("hardware-disabled port should not be flagged regardless of forward mode"); + } + + [Fact] + public void Evaluate_HardwareEnabledPort_StillFlagged() + { + // Arrange - Enabled port that is down and not disabled via forward mode + var port = CreatePort(portName: "Port 5", isUp: false, forwardMode: "native", isEnabled: true); + var networks = CreateNetworkList(); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert + result.Should().NotBeNull("enabled port that is down should still be flagged"); + } + + [Fact] + public void Evaluate_IsEnabledDefaultsToTrue() + { + // Arrange - Port created without specifying IsEnabled (defaults to true) + var port = CreatePort(portName: "Port 5", isUp: false, forwardMode: "native"); + var networks = CreateNetworkList(); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert + result.Should().NotBeNull("port with default IsEnabled=true should still be flagged when unused"); + } + + #endregion + #region Intentional Unrestricted Profile Detection [Fact] @@ -736,7 +796,8 @@ private static PortInfo CreatePort( string? networkId = "default-net", string switchName = "Test Switch", long? lastConnectionSeen = null, - UniFiPortProfile? assignedProfile = null) + UniFiPortProfile? assignedProfile = null, + bool isEnabled = true) { var switchInfo = new SwitchInfo { @@ -749,6 +810,7 @@ private static PortInfo CreatePort( { PortIndex = portIndex, Name = portName, + IsEnabled = isEnabled, IsUp = isUp, ForwardMode = forwardMode, IsUplink = isUplink, From 12022e5776741ab8c132d9c1928273cdd6a591e4 Mon Sep 17 00:00:00 2001 From: "TJ @ Ozark Connect" <109822114+tvancott42@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:52:12 -0600 Subject: [PATCH 2/3] Fix textarea/input cursor jumping to end while typing notes (#402) * Fix notes textarea cursor jumping to end during save and sync notes across detail/table views * Use @bind instead of value= to prevent cursor reset while allowing status indicator updates * Update Result.Notes before EventCallback so other instances sync immediately --- .../Components/Pages/UpnpInspector.razor | 5 ++-- .../Components/Shared/SpeedTestDetails.razor | 24 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/NetworkOptimizer.Web/Components/Pages/UpnpInspector.razor b/src/NetworkOptimizer.Web/Components/Pages/UpnpInspector.razor index d4d8f29b0..8cf5a9e88 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/UpnpInspector.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/UpnpInspector.razor @@ -172,8 +172,9 @@ @if (IsNoteSaving(GetNoteKey(rule))) diff --git a/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor b/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor index bd1eac629..33f3bccf0 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor @@ -184,8 +184,10 @@
@if (_notesSaving) @@ -482,6 +484,7 @@ // Notes state private string _notesInput = ""; + private bool _notesFocused; private bool _notesSaving; private bool _notesSaved; private System.Timers.Timer? _notesDebounceTimer; @@ -532,6 +535,13 @@ } else { + // Sync notes from Result if changed externally (e.g., edited in another SpeedTestDetails instance) + if (!_notesFocused && !_notesSaving) + { + var resultNotes = Result.Notes ?? ""; + if (_notesInput != resultNotes) + _notesInput = resultNotes; + } // Only use Result.PathAnalysis if no separate PathAnalysis was passed PathAnalysis ??= Result.PathAnalysis; } @@ -1101,14 +1111,14 @@ _notesDebounceTimer.Start(); } - private void OnNotesInput(ChangeEventArgs e) + private void OnNotesFocus() { - _notesInput = e.Value?.ToString() ?? ""; - DebounceSaveNotes(); + _notesFocused = true; } private async Task OnNotesBlur() { + _notesFocused = false; // Cancel any pending debounce and save immediately _notesDebounceTimer?.Stop(); _notesDebounceTimer?.Dispose(); @@ -1131,8 +1141,10 @@ try { - await OnNotesChanged.InvokeAsync((Result.Id, string.IsNullOrEmpty(newNotes) ? null : newNotes)); + // Update Result.Notes before invoking callback so other SpeedTestDetails instances + // see the new value when the parent re-renders during the await Result.Notes = string.IsNullOrEmpty(newNotes) ? null : newNotes; + await OnNotesChanged.InvokeAsync((Result.Id, string.IsNullOrEmpty(newNotes) ? null : newNotes)); _notesSaved = true; // Hide "Saved" after 2 seconds From 7e44cf3a209424d650c9798037076241197425da Mon Sep 17 00:00:00 2001 From: "TJ @ Ozark Connect" <109822114+tvancott42@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:42:16 -0600 Subject: [PATCH 3/3] Add ntfy.sh as a notification channel (#377) (#403) Adds a dedicated ntfy.sh delivery channel using the JSON publishing API with markdown messages, priority mapping, emoji tags, and support for Bearer token and Basic auth. Also adds contextual webhook URL placeholders per channel type. --- .../Delivery/NtfyChannelConfig.cs | 10 + .../Delivery/NtfyDeliveryChannel.cs | 212 ++++++++++++++++++ .../Models/DeliveryChannel.cs | 3 +- .../Components/Pages/Settings.razor | 136 ++++++++++- src/NetworkOptimizer.Web/Program.cs | 5 + .../Delivery/NtfyDeliveryChannelTests.cs | 62 +++++ 6 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 src/NetworkOptimizer.Alerts/Delivery/NtfyChannelConfig.cs create mode 100644 src/NetworkOptimizer.Alerts/Delivery/NtfyDeliveryChannel.cs create mode 100644 tests/NetworkOptimizer.Alerts.Tests/Delivery/NtfyDeliveryChannelTests.cs diff --git a/src/NetworkOptimizer.Alerts/Delivery/NtfyChannelConfig.cs b/src/NetworkOptimizer.Alerts/Delivery/NtfyChannelConfig.cs new file mode 100644 index 000000000..f04c3d39b --- /dev/null +++ b/src/NetworkOptimizer.Alerts/Delivery/NtfyChannelConfig.cs @@ -0,0 +1,10 @@ +namespace NetworkOptimizer.Alerts.Delivery; + +public class NtfyChannelConfig +{ + public string ServerUrl { get; set; } = "https://ntfy.sh"; + public string Topic { get; set; } = string.Empty; + public string? AccessToken { get; set; } // Stored encrypted, for Bearer auth + public string? Username { get; set; } + public string? Password { get; set; } // Stored encrypted, for Basic auth +} diff --git a/src/NetworkOptimizer.Alerts/Delivery/NtfyDeliveryChannel.cs b/src/NetworkOptimizer.Alerts/Delivery/NtfyDeliveryChannel.cs new file mode 100644 index 000000000..c7806157d --- /dev/null +++ b/src/NetworkOptimizer.Alerts/Delivery/NtfyDeliveryChannel.cs @@ -0,0 +1,212 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using NetworkOptimizer.Alerts.Events; +using NetworkOptimizer.Alerts.Models; +using NetworkOptimizer.Core.Enums; + +namespace NetworkOptimizer.Alerts.Delivery; + +/// +/// Delivery channel for ntfy.sh push notifications using the JSON publishing API. +/// Supports both public ntfy.sh and self-hosted instances. +/// +public class NtfyDeliveryChannel : IAlertDeliveryChannel +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly ISecretDecryptor _secretDecryptor; + + public DeliveryChannelType ChannelType => DeliveryChannelType.Ntfy; + + public NtfyDeliveryChannel(ILogger logger, HttpClient httpClient, ISecretDecryptor secretDecryptor) + { + _logger = logger; + _httpClient = httpClient; + _secretDecryptor = secretDecryptor; + } + + public async Task SendAsync(AlertEvent alertEvent, AlertHistoryEntry historyEntry, DeliveryChannel channel, CancellationToken cancellationToken = default) + { + var config = JsonSerializer.Deserialize(channel.ConfigJson); + if (config == null || string.IsNullOrEmpty(config.Topic)) return false; + + var message = FormatMessage(alertEvent); + + var payload = JsonSerializer.Serialize(new + { + topic = config.Topic, + title = alertEvent.Title, + message, + priority = MapPriority(alertEvent.Severity), + tags = new[] { MapTag(alertEvent.Severity) }, + markdown = true + }); + + return await PostAsync(config, payload, cancellationToken); + } + + public async Task SendDigestAsync(IReadOnlyList alerts, DeliveryChannel channel, DigestSummary summary, CancellationToken cancellationToken = default) + { + var config = JsonSerializer.Deserialize(channel.ConfigJson); + if (config == null || string.IsNullOrEmpty(config.Topic)) return false; + + var sb = new StringBuilder(); + sb.AppendLine($"**{summary.TotalCount} alerts** in this period"); + if (summary.CriticalCount > 0) sb.AppendLine($"- Critical: {summary.CriticalCount}"); + if (summary.ErrorCount > 0) sb.AppendLine($"- Error: {summary.ErrorCount}"); + if (summary.WarningCount > 0) sb.AppendLine($"- Warning: {summary.WarningCount}"); + if (summary.InfoCount > 0) sb.AppendLine($"- Info: {summary.InfoCount}"); + + sb.AppendLine(); + + foreach (var alert in alerts.OrderByDescending(a => a.Severity).Take(10)) + { + sb.AppendLine($"- **{alert.Title}** - {alert.Source} ({TimestampFormatter.FormatLocalShort(alert.TriggeredAt)})"); + } + + if (alerts.Count > 10) + sb.AppendLine($"\n...and {alerts.Count - 10} more alerts"); + + // Use highest severity for priority + var maxSeverity = alerts.Max(a => a.Severity); + + var payload = JsonSerializer.Serialize(new + { + topic = config.Topic, + title = "Alert Digest", + message = sb.ToString().TrimEnd(), + priority = MapPriority(maxSeverity), + tags = new[] { "bell" }, + markdown = true + }); + + return await PostAsync(config, payload, cancellationToken); + } + + public async Task<(bool Success, string? Error)> TestAsync(DeliveryChannel channel, CancellationToken cancellationToken = default) + { + try + { + var config = JsonSerializer.Deserialize(channel.ConfigJson); + if (config == null || string.IsNullOrEmpty(config.Topic)) + return (false, "Invalid channel configuration"); + + var payload = JsonSerializer.Serialize(new + { + topic = config.Topic, + title = "Network Optimizer - Test", + message = "Alert channel test successful.", + priority = 3, + tags = new[] { "white_check_mark" }, + markdown = true + }); + + var success = await PostAsync(config, payload, cancellationToken); + return success ? (true, null) : (false, "ntfy POST failed"); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } + + private async Task PostAsync(NtfyChannelConfig config, string payload, CancellationToken cancellationToken) + { + var url = $"{config.ServerUrl.TrimEnd('/')}"; + + const int maxRetries = 2; + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + + // Add auth header if configured + if (!string.IsNullOrEmpty(config.AccessToken)) + { + var token = _secretDecryptor.Decrypt(config.AccessToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + else if (!string.IsNullOrEmpty(config.Username) && !string.IsNullOrEmpty(config.Password)) + { + var password = _secretDecryptor.Decrypt(config.Password); + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{config.Username}:{password}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + + var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) + { + _logger.LogDebug("ntfy message delivered to {Topic}", config.Topic); + return true; + } + + _logger.LogWarning("ntfy POST returned {StatusCode}", response.StatusCode); + if (attempt < maxRetries) + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt + 1)), cancellationToken); + } + catch (Exception ex) when (attempt < maxRetries) + { + _logger.LogWarning("ntfy attempt {Attempt} failed: {Error}", attempt + 1, ex.Message); + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt + 1)), cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deliver to ntfy"); + return false; + } + } + + return false; + } + + private static string FormatMessage(AlertEvent alertEvent) + { + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(alertEvent.Message)) + sb.AppendLine(alertEvent.Message); + + if (alertEvent.MetricValue.HasValue) + sb.AppendLine($"**Value:** {alertEvent.MetricValue}{(alertEvent.ThresholdValue.HasValue ? $" (threshold: {alertEvent.ThresholdValue})" : "")}"); + + if (!string.IsNullOrEmpty(alertEvent.DeviceName)) + sb.AppendLine($"**Device:** {alertEvent.DeviceName}"); + + if (!string.IsNullOrEmpty(alertEvent.DeviceIp)) + sb.AppendLine($"**IP:** {alertEvent.DeviceIp}"); + + sb.AppendLine($"**Source:** {alertEvent.Source}"); + sb.AppendLine($"**Severity:** {alertEvent.Severity}"); + + foreach (var ctx in alertEvent.Context) + sb.AppendLine($"**{ctx.Key}:** {ctx.Value}"); + + return sb.Length > 0 ? sb.ToString().TrimEnd() : alertEvent.EventType; + } + + /// + /// Map AlertSeverity to ntfy priority (1-5). + /// 5=max, 4=high, 3=default, 2=low, 1=min. + /// + internal static int MapPriority(AlertSeverity severity) => severity switch + { + AlertSeverity.Critical => 5, + AlertSeverity.Error => 4, + AlertSeverity.Warning => 3, + _ => 2 + }; + + /// + /// Map AlertSeverity to ntfy emoji shortcode tag. + /// + internal static string MapTag(AlertSeverity severity) => severity switch + { + AlertSeverity.Critical => "rotating_light", + AlertSeverity.Error => "red_circle", + AlertSeverity.Warning => "warning", + _ => "information_source" + }; +} diff --git a/src/NetworkOptimizer.Alerts/Models/DeliveryChannel.cs b/src/NetworkOptimizer.Alerts/Models/DeliveryChannel.cs index 694155c85..f5fbdbac0 100644 --- a/src/NetworkOptimizer.Alerts/Models/DeliveryChannel.cs +++ b/src/NetworkOptimizer.Alerts/Models/DeliveryChannel.cs @@ -11,7 +11,8 @@ public enum DeliveryChannelType Webhook, Slack, Discord, - Teams + Teams, + Ntfy } /// diff --git a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor index 7f3597033..a2ab1540f 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor @@ -1129,7 +1129,7 @@

- Configure notification channels for the alert engine. Alerts are delivered via email, webhooks, Slack, Discord, or Microsoft Teams. + Configure notification channels for the alert engine. Alerts are delivered via email, webhooks, Slack, Discord, Microsoft Teams, or ntfy.sh.

@if (alertChannels?.Count > 0) @@ -1187,6 +1187,7 @@ +
@@ -1231,11 +1232,64 @@ Comma-separated email addresses } + else if (channelFormType == "Ntfy") + { +
+ + + Default is ntfy.sh. Change for self-hosted instances. +
+
+ + +
+
+ + @if (clearNtfyToken) + { +
+ Token will be cleared on save. + Undo +
+ } + else + { + + @if (hasNtfyToken && string.IsNullOrEmpty(channelNtfyAccessToken)) + { + Clear existing token + } + } +
+
+ + +
+
+ + @if (clearNtfyPassword) + { +
+ Password will be cleared on save. + Undo +
+ } + else + { + + @if (hasNtfyPassword && string.IsNullOrEmpty(channelNtfyPassword)) + { + Clear existing password + } + } +
+ Use access token OR username/password, not both. + } else {
- +
@if (channelFormType == "Webhook") { @@ -1829,6 +1883,15 @@ private string channelWebhookSecret = ""; private bool hasWebhookSecret = false; private bool clearWebhookSecret = false; + private string channelNtfyServerUrl = "https://ntfy.sh"; + private string channelNtfyTopic = ""; + private string channelNtfyAccessToken = ""; + private bool hasNtfyToken = false; + private bool clearNtfyToken = false; + private string channelNtfyUsername = ""; + private string channelNtfyPassword = ""; + private bool hasNtfyPassword = false; + private bool clearNtfyPassword = false; private string channelMinSeverity = "Warning"; private bool channelEnabled = true; private bool channelDigestEnabled = false; @@ -3262,6 +3325,14 @@ channelSmtpPort = channelSmtpStartTls ? 587 : 465; } + private string GetWebhookPlaceholder() => channelFormType switch + { + "Slack" => "https://hooks.slack.com/services/...", + "Discord" => "https://discord.com/api/webhooks/...", + "Teams" => "https://outlook.office.com/webhook/...", + _ => "https://example.com/webhook" + }; + private void StartAddChannel() { editingChannelId = null; @@ -3279,6 +3350,15 @@ channelWebhookSecret = ""; hasWebhookSecret = false; clearWebhookSecret = false; + channelNtfyServerUrl = "https://ntfy.sh"; + channelNtfyTopic = ""; + channelNtfyAccessToken = ""; + hasNtfyToken = false; + clearNtfyToken = false; + channelNtfyUsername = ""; + channelNtfyPassword = ""; + hasNtfyPassword = false; + clearNtfyPassword = false; channelMinSeverity = "Warning"; channelEnabled = true; channelDigestEnabled = false; @@ -3330,6 +3410,22 @@ channelWebhookSecret = ""; // Don't expose stored secret clearWebhookSecret = false; } + else if (ch.ChannelType == DeliveryChannelType.Ntfy) + { + var cfg = System.Text.Json.JsonSerializer.Deserialize(ch.ConfigJson); + if (cfg != null) + { + channelNtfyServerUrl = cfg.ServerUrl; + channelNtfyTopic = cfg.Topic; + channelNtfyUsername = cfg.Username ?? ""; + hasNtfyToken = !string.IsNullOrEmpty(cfg.AccessToken); + hasNtfyPassword = !string.IsNullOrEmpty(cfg.Password); + } + channelNtfyAccessToken = ""; + clearNtfyToken = false; + channelNtfyPassword = ""; + clearNtfyPassword = false; + } else { // Slack, Discord, Teams - just need the webhook URL @@ -3467,6 +3563,15 @@ } } } + else if (channelType == DeliveryChannelType.Ntfy) + { + if (string.IsNullOrWhiteSpace(channelNtfyTopic)) + { + alertChannelMessage = "Topic is required."; + alertChannelMessageClass = "warning"; + return; + } + } else { // Webhook, Slack, Discord, Teams all require a URL @@ -3577,6 +3682,33 @@ return System.Text.Json.JsonSerializer.Serialize(cfg); } + if (type == DeliveryChannelType.Ntfy) + { + string? token = clearNtfyToken + ? null + : !string.IsNullOrEmpty(channelNtfyAccessToken) + ? SecretDecryptor.Encrypt(channelNtfyAccessToken) + : GetExistingSecret(existingConfigJson, c => c.AccessToken); + if (string.IsNullOrEmpty(token)) token = null; + + string? password = clearNtfyPassword + ? null + : !string.IsNullOrEmpty(channelNtfyPassword) + ? SecretDecryptor.Encrypt(channelNtfyPassword) + : GetExistingSecret(existingConfigJson, c => c.Password); + if (string.IsNullOrEmpty(password)) password = null; + + var cfg = new NetworkOptimizer.Alerts.Delivery.NtfyChannelConfig + { + ServerUrl = channelNtfyServerUrl, + Topic = channelNtfyTopic, + AccessToken = token, + Username = string.IsNullOrWhiteSpace(channelNtfyUsername) ? null : channelNtfyUsername, + Password = password + }; + return System.Text.Json.JsonSerializer.Serialize(cfg); + } + // Slack, Discord, Teams all use webhook URL return System.Text.Json.JsonSerializer.Serialize(new { WebhookUrl = channelWebhookUrl }); } diff --git a/src/NetworkOptimizer.Web/Program.cs b/src/NetworkOptimizer.Web/Program.cs index 562abccc1..b31baf9a2 100644 --- a/src/NetworkOptimizer.Web/Program.cs +++ b/src/NetworkOptimizer.Web/Program.cs @@ -249,6 +249,11 @@ new NetworkOptimizer.Alerts.Delivery.TeamsDeliveryChannel( sp.GetRequiredService>(), sp.GetRequiredService().CreateClient())); +builder.Services.AddSingleton(sp => + new NetworkOptimizer.Alerts.Delivery.NtfyDeliveryChannel( + sp.GetRequiredService>(), + sp.GetRequiredService().CreateClient(), + sp.GetRequiredService())); // Register Threat Intelligence services builder.Services.AddSingleton(); diff --git a/tests/NetworkOptimizer.Alerts.Tests/Delivery/NtfyDeliveryChannelTests.cs b/tests/NetworkOptimizer.Alerts.Tests/Delivery/NtfyDeliveryChannelTests.cs new file mode 100644 index 000000000..ad69663ca --- /dev/null +++ b/tests/NetworkOptimizer.Alerts.Tests/Delivery/NtfyDeliveryChannelTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using NetworkOptimizer.Alerts.Delivery; +using NetworkOptimizer.Core.Enums; +using Xunit; + +namespace NetworkOptimizer.Alerts.Tests.Delivery; + +public class NtfyDeliveryChannelTests +{ + [Theory] + [InlineData(AlertSeverity.Critical, 5)] + [InlineData(AlertSeverity.Error, 4)] + [InlineData(AlertSeverity.Warning, 3)] + [InlineData(AlertSeverity.Info, 2)] + public void MapPriority_ReturnsExpectedNtfyPriority(AlertSeverity severity, int expected) + { + NtfyDeliveryChannel.MapPriority(severity).Should().Be(expected); + } + + [Theory] + [InlineData(AlertSeverity.Critical, "rotating_light")] + [InlineData(AlertSeverity.Error, "red_circle")] + [InlineData(AlertSeverity.Warning, "warning")] + [InlineData(AlertSeverity.Info, "information_source")] + public void MapTag_ReturnsExpectedEmojiShortcode(AlertSeverity severity, string expected) + { + NtfyDeliveryChannel.MapTag(severity).Should().Be(expected); + } + + [Fact] + public void MapPriority_CriticalIsHighest() + { + var critical = NtfyDeliveryChannel.MapPriority(AlertSeverity.Critical); + var error = NtfyDeliveryChannel.MapPriority(AlertSeverity.Error); + var warning = NtfyDeliveryChannel.MapPriority(AlertSeverity.Warning); + var info = NtfyDeliveryChannel.MapPriority(AlertSeverity.Info); + + critical.Should().BeGreaterThan(error); + error.Should().BeGreaterThan(warning); + warning.Should().BeGreaterThan(info); + } + + [Fact] + public void MapPriority_AllValuesInNtfyRange() + { + foreach (var severity in Enum.GetValues()) + { + var priority = NtfyDeliveryChannel.MapPriority(severity); + priority.Should().BeInRange(1, 5, $"ntfy priorities must be 1-5, got {priority} for {severity}"); + } + } + + [Fact] + public void MapTag_AllSeveritiesReturnNonEmpty() + { + foreach (var severity in Enum.GetValues()) + { + NtfyDeliveryChannel.MapTag(severity).Should().NotBeNullOrEmpty( + $"severity {severity} should map to a tag"); + } + } +}