using Microsoft.Extensions.Configuration; using StellaOps.Doctor.Models; using StellaOps.Doctor.Plugins; namespace StellaOps.Doctor.Plugins.Notify.Checks; /// /// Validates connectivity to configured notification channels. /// public sealed class NotifyChannelConnectivityCheck : IDoctorCheck { /// public string CheckId => "check.notify.channel.connectivity"; /// public string Name => "Notification Channel Connectivity"; /// public string Description => "Tests connectivity to configured notification channel endpoints"; /// public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn; /// public IReadOnlyList Tags => ["notify", "channel", "connectivity", "network"]; /// public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(10); /// public bool CanRun(DoctorPluginContext context) { // Only run if at least one channel is configured var emailSection = context.Configuration.GetSection("Notify:Channels:Email"); var slackSection = context.Configuration.GetSection("Notify:Channels:Slack"); var teamsSection = context.Configuration.GetSection("Notify:Channels:Teams"); var webhookSection = context.Configuration.GetSection("Notify:Channels:Webhook"); return emailSection.Exists() || slackSection.Exists() || teamsSection.Exists() || webhookSection.Exists(); } /// public async Task RunAsync(DoctorPluginContext context, CancellationToken ct) { var result = context.CreateResult(CheckId, "stellaops.doctor.notify", DoctorCategory.Notify.ToString()); var connectivityResults = new List<(string Channel, bool Connected, string? Error)>(); // Test Email connectivity (SMTP) var emailEnabled = context.Configuration.GetValue("Notify:Channels:Email:Enabled") ?? true; var smtpHost = context.Configuration.GetValue("Notify:Channels:Email:SmtpHost"); if (emailEnabled && !string.IsNullOrWhiteSpace(smtpHost)) { var smtpPort = context.Configuration.GetValue("Notify:Channels:Email:SmtpPort") ?? 587; var emailResult = await TestSmtpConnectivityAsync(smtpHost, smtpPort, ct); connectivityResults.Add(("Email (SMTP)", emailResult.Success, emailResult.Error)); } // Test Slack connectivity var slackEnabled = context.Configuration.GetValue("Notify:Channels:Slack:Enabled") ?? true; var slackWebhook = context.Configuration.GetValue("Notify:Channels:Slack:WebhookUrl"); if (slackEnabled && !string.IsNullOrWhiteSpace(slackWebhook)) { var slackResult = await TestHttpEndpointAsync(slackWebhook, "Slack", ct); connectivityResults.Add(("Slack", slackResult.Success, slackResult.Error)); } // Test Teams connectivity var teamsEnabled = context.Configuration.GetValue("Notify:Channels:Teams:Enabled") ?? true; var teamsWebhook = context.Configuration.GetValue("Notify:Channels:Teams:WebhookUrl"); if (teamsEnabled && !string.IsNullOrWhiteSpace(teamsWebhook)) { var teamsResult = await TestHttpEndpointAsync(teamsWebhook, "Teams", ct); connectivityResults.Add(("Teams", teamsResult.Success, teamsResult.Error)); } // Test Webhook connectivity var webhookEnabled = context.Configuration.GetValue("Notify:Channels:Webhook:Enabled") ?? true; var webhookEndpoint = context.Configuration.GetValue("Notify:Channels:Webhook:Endpoint"); if (webhookEnabled && !string.IsNullOrWhiteSpace(webhookEndpoint)) { var webhookResult = await TestHttpEndpointAsync(webhookEndpoint, "Webhook", ct); connectivityResults.Add(("Webhook", webhookResult.Success, webhookResult.Error)); } if (connectivityResults.Count == 0) { return result .Skip("No notification channels configured to test") .Build(); } var failedChannels = connectivityResults.Where(r => !r.Connected).ToList(); if (failedChannels.Count > 0) { return result .Warn($"{failedChannels.Count} of {connectivityResults.Count} channel(s) unreachable") .WithEvidence("Connectivity results", e => { foreach (var (channel, connected, error) in connectivityResults) { e.Add(channel, connected ? "Connected" : $"Failed: {error}"); } }) .WithCauses(failedChannels.Select(f => $"{f.Channel}: {f.Error}").ToArray()) .WithRemediation(r => { if (failedChannels.Any(f => f.Channel.Contains("SMTP"))) { r.AddManualStep(1, "Check SMTP server", "Verify SMTP server is accessible and credentials are correct"); } if (failedChannels.Any(f => f.Channel.Contains("Slack"))) { r.AddManualStep(2, "Check Slack webhook", "Verify Slack webhook URL is valid and not expired"); } if (failedChannels.Any(f => f.Channel.Contains("Teams"))) { r.AddManualStep(3, "Check Teams webhook", "Verify Teams webhook URL is valid"); } if (failedChannels.Any(f => f.Channel.Contains("Webhook"))) { r.AddManualStep(4, "Check webhook endpoint", "Verify webhook endpoint is accessible from this network"); } }) .WithVerification($"stella doctor --check {CheckId}") .Build(); } return result .Pass($"All {connectivityResults.Count} channel(s) reachable") .WithEvidence("Connectivity results", e => { foreach (var (channel, _, _) in connectivityResults) { e.Add(channel, "Connected"); } }) .Build(); } private static async Task<(bool Success, string? Error)> TestSmtpConnectivityAsync( string host, int port, CancellationToken ct) { try { using var client = new System.Net.Sockets.TcpClient(); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(5)); await client.ConnectAsync(host, port, cts.Token); return (true, null); } catch (OperationCanceledException) { return (false, "Connection timed out"); } catch (Exception ex) { return (false, ex.Message); } } private static async Task<(bool Success, string? Error)> TestHttpEndpointAsync( string url, string channelType, CancellationToken ct) { try { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { return (false, "Invalid URL format"); } // Just test TCP connectivity to the host/port, don't send actual requests var port = uri.Port > 0 ? uri.Port : (uri.Scheme == "https" ? 443 : 80); using var client = new System.Net.Sockets.TcpClient(); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(5)); await client.ConnectAsync(uri.Host, port, cts.Token); return (true, null); } catch (OperationCanceledException) { return (false, "Connection timed out"); } catch (Exception ex) { return (false, ex.Message); } } }