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);
}
}
}