diff --git a/ThingConnect.Pulse.Server/Services/ConfigurationParser.cs b/ThingConnect.Pulse.Server/Services/ConfigurationParser.cs index 8653f3f..93dbf5d 100644 --- a/ThingConnect.Pulse.Server/Services/ConfigurationParser.cs +++ b/ThingConnect.Pulse.Server/Services/ConfigurationParser.cs @@ -78,6 +78,46 @@ public static async Task CreateAsync(ILogger(yamlContent); + var ipv6Errors = new List(); + + foreach (var target in config.Targets) + { + if (!string.IsNullOrEmpty(target.Cidr) && target.Cidr.Contains(":")) + { + string[] parts = target.Cidr.Split('/'); + if (parts.Length != 2 || !int.TryParse(parts[1], out int prefix)) + { + ipv6Errors.Add(new ValidationError + { + Path = "cidr", + Message = $"Invalid IPv6 CIDR format: {target.Cidr}", + Value = target.Cidr + }); + continue; + } + + if (prefix < 120 || prefix > 128) + { + ipv6Errors.Add(new ValidationError + { + Path = "cidr", + Message = $"IPv6 prefix /{prefix} too large to expand practically (supported: /120–/128)", + Value = target.Cidr + }); + } + } + } + + if (ipv6Errors.Any()) + { + return Task.FromResult<(ConfigurationYaml?, ValidationErrorsDto?)>( + (null, + new ValidationErrorsDto + { + Message = "IPv6 expansion validation failed", + Errors = ipv6Errors + })); + } // Convert back to JSON for schema validation ISerializer serializer = new SerializerBuilder() diff --git a/ThingConnect.Pulse.Server/Services/Monitoring/DiscoveryService.cs b/ThingConnect.Pulse.Server/Services/Monitoring/DiscoveryService.cs index 44a97fe..e09d067 100644 --- a/ThingConnect.Pulse.Server/Services/Monitoring/DiscoveryService.cs +++ b/ThingConnect.Pulse.Server/Services/Monitoring/DiscoveryService.cs @@ -6,13 +6,16 @@ namespace ThingConnect.Pulse.Server.Services.Monitoring; /// -/// Implementation of discovery service for expanding network targets. +/// Implementation of discovery service for expanding network targets, now supporting IPv6. /// public sealed class DiscoveryService : IDiscoveryService { private readonly ILogger _logger; - private static readonly Regex CidrRegex = new(@"^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/(\d{1,2})$", + // Regex for CIDR parsing + private static readonly Regex Ipv6CidrRegex = new Regex(@"^([0-9a-fA-F:]+)(?:/([0-9]{1,3}))?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex Ipv4CidrRegex = new Regex(@"^(\d{1,3}(?:\.\d{1,3}){3})(?:/([0-9]{1,2}))?$", RegexOptions.Compiled); private static readonly Regex WildcardRegex = new(@"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\*$", RegexOptions.Compiled); @@ -24,45 +27,56 @@ public DiscoveryService(ILogger logger) public IEnumerable ExpandCidr(string cidr) { - Match match = CidrRegex.Match(cidr); - if (!match.Success) + // IPv4 CIDR + Match matchV4 = Ipv4CidrRegex.Match(cidr); + if (matchV4.Success) { - _logger.LogWarning("Invalid CIDR format: {Cidr}", cidr); - yield break; - } + string baseIp = matchV4.Groups[1].Value; + int prefixLength = int.Parse(matchV4.Groups[2].Value); - string baseIp = match.Groups[1].Value; - int prefixLength = int.Parse(match.Groups[2].Value); + if (prefixLength < 0 || prefixLength > 32) + { + _logger.LogWarning("Invalid CIDR prefix length: {PrefixLength}", prefixLength); + yield break; + } - if (prefixLength < 0 || prefixLength > 32) - { - _logger.LogWarning("Invalid CIDR prefix length: {PrefixLength}", prefixLength); - yield break; - } + if (!IPAddress.TryParse(baseIp, out IPAddress? ipAddress)) + { + _logger.LogWarning("Invalid IP address in CIDR: {BaseIp}", baseIp); + yield break; + } - if (!IPAddress.TryParse(baseIp, out IPAddress? ipAddress)) - { - _logger.LogWarning("Invalid IP address in CIDR: {BaseIp}", baseIp); - yield break; - } + byte[] addressBytes = ipAddress.GetAddressBytes(); + uint addressInt = BitConverter.ToUInt32(addressBytes.Reverse().ToArray(), 0); + int hostBits = 32 - prefixLength; + uint hostCount = (uint)(1 << hostBits); + uint networkAddress = addressInt & (0xFFFFFFFF << hostBits); - byte[] addressBytes = ipAddress.GetAddressBytes(); - uint addressInt = BitConverter.ToUInt32(addressBytes.Reverse().ToArray(), 0); + // Skip network and broadcast addresses for practical use + uint startAddress = networkAddress + 1; + uint endAddress = networkAddress + hostCount - 1; - int hostBits = 32 - prefixLength; - uint hostCount = (uint)(1 << hostBits); - uint networkAddress = addressInt & (0xFFFFFFFF << hostBits); + for (uint address = startAddress; address < endAddress && address > networkAddress; address++) + { + byte[] bytes = BitConverter.GetBytes(address).Reverse().ToArray(); + yield return new IPAddress(bytes).ToString(); + } - // Skip network and broadcast addresses for practical use - uint startAddress = networkAddress + 1; - uint endAddress = networkAddress + hostCount - 1; + yield break; + } - for (uint address = startAddress; address < endAddress && address > networkAddress; address++) + // IPv6 CIDR using helper expander + Match matchV6 = Ipv6CidrRegex.Match(cidr); + if (matchV6.Success) { - byte[] bytes = BitConverter.GetBytes(address).Reverse().ToArray(); - var ip = new IPAddress(bytes); - yield return ip.ToString(); + foreach (var ip in Ipv6CidrExpander.Expand(cidr)) + { + yield return ip; + } + yield break; } + + _logger.LogWarning("Invalid CIDR format: {Cidr}", cidr); } public IEnumerable ExpandWildcard(string wildcard, int startRange = 1, int endRange = 254) @@ -96,8 +110,12 @@ public async Task> ResolveHostnameAsync(string hostname, Can { IPAddress[] addresses = await Dns.GetHostAddressesAsync(hostname); return addresses - .Where(addr => addr.AddressFamily == AddressFamily.InterNetwork) // IPv4 only for now - .Select(addr => addr.ToString()) + .Select(addr => + { + if (addr.AddressFamily == AddressFamily.InterNetworkV6 && addr.ScopeId != 0) + return $"{addr}%{addr.ScopeId}"; + return addr.ToString(); + }) .ToList(); } catch (Exception ex) @@ -208,4 +226,58 @@ private static Data.Endpoint CreateEndpointFromTarget(dynamic target, string hos return endpoint; } + + private sealed class NetworkRange + { + public string BaseAddress { get; } + public int PrefixLength { get; } + + public NetworkRange(string baseAddress, int prefixLength) + { + BaseAddress = baseAddress; + PrefixLength = prefixLength; + } + } + + private sealed class Ipv6CidrExpander + { + public static IEnumerable Expand(string cidr) + { + var match = Ipv6CidrRegex.Match(cidr); + if (!match.Success) throw new ArgumentException($"Invalid IPv6 CIDR: {cidr}"); + + string baseAddress = match.Groups[1].Value; + int prefixLength = match.Groups[2].Success ? int.Parse(match.Groups[2].Value) : 64; + + // Handle zone/index if present (e.g., fe80::1%eth0) + string? zone = null; + int percentIndex = baseAddress.IndexOf('%'); + if (percentIndex >= 0) + { + zone = baseAddress.Substring(percentIndex + 1); + baseAddress = baseAddress.Substring(0, percentIndex); + } + + if (!IPAddress.TryParse(baseAddress, out IPAddress? ipAddress) || ipAddress.AddressFamily != AddressFamily.InterNetworkV6) + throw new ArgumentException($"Invalid IPv6 address: {baseAddress}"); + + byte[] bytes = ipAddress.GetAddressBytes(); + + string FormatAddress(byte[] addrBytes) + => zone != null ? $"{new IPAddress(addrBytes)}%{zone}" : new IPAddress(addrBytes).ToString(); + + if (prefixLength < 120 || prefixLength > 128) + throw new ArgumentException($"IPv6 prefix /{prefixLength} not supported for practical expansion. Use /120–/128."); + + int hostCount = 1 << (128 - prefixLength); + if (hostCount > 256) hostCount = 256; + + for (int i = 0; i < hostCount; i++) + { + byte[] copy = (byte[])bytes.Clone(); + copy[15] += (byte)i; + yield return FormatAddress(copy); + } + } + } } diff --git a/ThingConnect.Pulse.Server/config.schema.json b/ThingConnect.Pulse.Server/config.schema.json index b1ba274..61ca4cd 100644 --- a/ThingConnect.Pulse.Server/config.schema.json +++ b/ThingConnect.Pulse.Server/config.schema.json @@ -11,14 +11,29 @@ "type": "object", "additionalProperties": false, "properties": { - "interval_seconds": { "type": ["integer", "string"], "minimum": 1, "default": 10 }, - "timeout_ms": { "type": ["integer", "string"], "minimum": 100, "default": 1500 }, - "retries": { "type": ["integer", "string"], "minimum": 0, "default": 1 }, + "interval_seconds": { + "type": ["integer", "string"], + "minimum": 1, + "default": 10 + }, + "timeout_ms": { + "type": ["integer", "string"], + "minimum": 100, + "default": 1500 + }, + "retries": { + "type": ["integer", "string"], + "minimum": 0, + "default": 1 + }, "http": { "type": "object", "additionalProperties": false, "properties": { - "user_agent": { "type": "string", "default": "ThingConnectPulse/1.0" }, + "user_agent": { + "type": "string", + "default": "ThingConnectPulse/1.0" + }, "expect_text": { "type": "string", "default": "" } }, "default": {} @@ -39,7 +54,10 @@ }, "name": { "type": "string", "minLength": 1 }, "parent_id": { "type": ["string", "null"] }, - "color": { "type": "string", "pattern": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" }, + "color": { + "type": "string", + "pattern": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" + }, "sort_order": { "type": ["integer", "string"] } }, "additionalProperties": false @@ -62,14 +80,17 @@ }, "ipv6": { "type": "string", - "pattern": "^[0-9A-Fa-f:]+$" + "pattern": "^[0-9A-Fa-f:]+(%[0-9A-Za-z]+)?$", + "description": "IPv6 address, optionally with zone index (e.g., fe80::1%3)" }, "cidr": { "type": "string", - "pattern": "^(?:(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\/(?:[0-9]|[12][0-9]|3[0-2]))|(?:(?:[0-9A-Fa-f:]+)\\/(?:[0-9]|[1-9]\\d|1[01]\\d|12[0-8]))$" + "minLength": 1, + "pattern": "^(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)/(?:[0-9]|[12][0-9]|3[0-2])|[0-9A-Fa-f:]+(?:%[0-9A-Za-z]+)?/(?:[0-9]|[1-9]\\d|1[01]\\d|12[0-8]))$" }, "wildcard": { "type": "string", + "minLength": 1, "description": "IPv4 wildcard like 10.10.1.* (expands 1..254 by default)", "pattern": "^(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}\\*$" }, @@ -81,16 +102,39 @@ "type": { "$ref": "#/definitions/probeType" }, "group": { "type": "string" }, "name": { "type": ["string", "null"] }, - "host": { "type": ["string", "null"] }, + "host": { + "type": ["string", "null"], + "anyOf": [ + { "$ref": "#/definitions/hostname" }, + { "$ref": "#/definitions/ipv4" }, + { "$ref": "#/definitions/ipv6" } + ] + }, "cidr": { "type": ["string", "null"] }, "wildcard": { "type": ["string", "null"] }, - "port": { "type": ["integer", "string", "null"], "minimum": 1, "maximum": 65535 }, + "port": { + "type": ["integer", "string", "null"], + "minimum": 1, + "maximum": 65535 + }, "http_path": { "type": ["string", "null"] }, "http_match": { "type": ["string", "null"] }, - "interval_seconds": { "type": ["integer", "string", "null"], "minimum": 1 }, - "timeout_ms": { "type": ["integer", "string", "null"], "minimum": 100 }, - "retries": { "type": ["integer", "string", "null"], "minimum": 0 }, - "expected_rtt_ms": { "type": ["integer", "string", "null"], "minimum": 1 }, + "interval_seconds": { + "type": ["integer", "string", "null"], + "minimum": 1 + }, + "timeout_ms": { + "type": ["integer", "string", "null"], + "minimum": 100 + }, + "retries": { + "type": ["integer", "string", "null"], + "minimum": 0 + }, + "expected_rtt_ms": { + "type": ["integer", "string", "null"], + "minimum": 1 + }, "enabled": { "type": ["boolean", "null"] }, "notes": { "type": ["string", "null"] } }, @@ -110,13 +154,55 @@ "examples": [ { "version": 1, - "defaults": { "interval_seconds": 10, "timeout_ms": 1500, "retries": 1, "http": { "user_agent": "ThingConnectPulse/1.0", "expect_text": "" } }, - "groups": [ { "id": "new-press", "name": "Press Shop" }, { "id": "new-lab", "name": "Quality Lab" } ], + "defaults": { + "interval_seconds": 10, + "timeout_ms": 1500, + "retries": 1, + "http": { "user_agent": "ThingConnectPulse/1.0", "expect_text": "" } + }, + "groups": [ + { "id": "new-press", "name": "Press Shop" }, + { "id": "new-lab", "name": "Quality Lab" } + ], "targets": [ - { "type": "icmp", "host": "10.10.1.21", "name": "PLC-Press-01", "group": "press", "interval_seconds": 5 }, - { "type": "tcp", "wildcard": "10.10.1.*", "port": 5900, "name": "HMI-VNC", "group": "press" }, - { "type": "http", "cidr": "10.10.2.0/28", "http_path": "/health", "http_match": "OK", "name": "QMS-HTTP", "group": "lab" } + { + "type": "icmp", + "host": "10.10.1.21", + "name": "PLC-Press-01", + "group": "press", + "interval_seconds": 5 + }, + { + "type": "tcp", + "wildcard": "10.10.1.*", + "port": 5900, + "name": "HMI-VNC", + "group": "press" + }, + { + "type": "http", + "cidr": "10.10.2.0/28", + "http_path": "/health", + "http_match": "OK", + "name": "QMS-HTTP", + "group": "lab" + }, + { + "type": "icmp", + "host": "fd7a:115c:a1e0::3801:160a", + "name": "Tailscale-Node-01", + "group": "network", + "interval_seconds": 5 + }, + { + "type": "http", + "cidr": "2001:db8:abcd:1234::/64", + "http_path": "/health", + "http_match": "OK", + "name": "IPv6-HTTP-Test", + "group": "lab" + } ] } ] -} \ No newline at end of file +} diff --git a/ThingConnect.Pulse.Tests/ConfigurationValidationTests.cs b/ThingConnect.Pulse.Tests/ConfigurationValidationTests.cs index 5941efd..c210510 100644 --- a/ThingConnect.Pulse.Tests/ConfigurationValidationTests.cs +++ b/ThingConnect.Pulse.Tests/ConfigurationValidationTests.cs @@ -33,146 +33,213 @@ public async Task OneTimeSetUp() .Build(); } + #region Positive Tests + [Test] public async Task TestConfigurationValidation_WithValidYaml_ShouldPassValidation() { - // Arrange string yamlPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "test-config.yaml"); - Assert.That(File.Exists(yamlPath), Is.True, $"Test YAML file not found at: {yamlPath}"); - string yamlContent = await File.ReadAllTextAsync(yamlPath); - // Act object config = _yamlDeserializer.Deserialize(yamlContent); string configJson = _yamlSerializer.Serialize(config); - ICollection validationResults = _schema!.Validate(configJson); + var errors = _schema!.Validate(configJson); - // Assert - Assert.That(validationResults.Count, Is.EqualTo(0), - $"Validation should pass but found {validationResults.Count} errors: {string.Join(", ", validationResults)}"); + Assert.That(errors.Count, Is.EqualTo(0), $"Validation failed: {string.Join(", ", errors)}"); } [Test] - public void TestCidrExpansion_With24Subnet_ShouldReturn254IPs() + public async Task TestConfigurationValidation_WithMinimalYaml_ShouldPassValidation() { - // Arrange - string cidr = "10.18.8.0/24"; + string yamlPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "test-config-minimal.yaml"); + string yamlContent = await File.ReadAllTextAsync(yamlPath); - // Act - List expandedIPs = ExpandCidrForTesting(cidr); + object config = _yamlDeserializer.Deserialize(yamlContent); + string configJson = _yamlSerializer.Serialize(config); + var errors = _schema!.Validate(configJson); - // Assert - Assert.That(expandedIPs.Count, Is.EqualTo(254), "CIDR /24 should expand to 254 IPs (skip .0 and .255)"); - Assert.That(expandedIPs[0], Is.EqualTo("10.18.8.1"), "First IP should be .1"); - Assert.That(expandedIPs[253], Is.EqualTo("10.18.8.254"), "Last IP should be .254"); + Assert.That(errors.Count, Is.EqualTo(0), $"Validation failed: {string.Join(", ", errors)}"); } [Test] - public void TestCidrExpansion_With30Subnet_ShouldReturn2IPs() + public void TestCidrExpansion_With24Subnet_ShouldReturn254IPs() { - // Arrange - string cidr = "192.168.1.0/30"; - - // Act - List expandedIPs = ExpandCidrForTesting(cidr); + List expandedIPs = ExpandCidrForTesting("10.18.8.0/24"); + Assert.That(expandedIPs.Count, Is.EqualTo(254)); + Assert.That(expandedIPs[0], Is.EqualTo("10.18.8.1")); + Assert.That(expandedIPs[253], Is.EqualTo("10.18.8.254")); + } - // Assert - Assert.That(expandedIPs.Count, Is.EqualTo(2), "CIDR /30 should expand to 2 IPs"); + [Test] + public void TestCidrExpansion_With30Subnet_ShouldReturn2IPs() + { + List expandedIPs = ExpandCidrForTesting("192.168.1.0/30"); + Assert.That(expandedIPs.Count, Is.EqualTo(2)); Assert.That(expandedIPs[0], Is.EqualTo("192.168.1.1")); Assert.That(expandedIPs[1], Is.EqualTo("192.168.1.2")); } [Test] - public void TestCidrExpansion_WithInvalidFormat_ShouldReturnEmpty() + public void TestWildcardExpansion_ShouldReturn254IPs() { - // Arrange - string invalidCidr = "invalid-cidr"; - - // Act & Assert - List expandedIPs = ExpandCidrForTesting(invalidCidr); - Assert.That(expandedIPs.Count, Is.EqualTo(0), "Invalid CIDR should return empty list"); + List expandedIPs = ExpandWildcardForTesting("10.10.1.*"); + Assert.That(expandedIPs.Count, Is.EqualTo(254)); + Assert.That(expandedIPs[0], Is.EqualTo("10.10.1.1")); + Assert.That(expandedIPs[253], Is.EqualTo("10.10.1.254")); } [Test] - public async Task TestConfigurationValidation_WithMinimalYaml_ShouldFailWithOldSchema() + public void TestCidrExpansion_WithIPv6_ShouldReturnMockIPs() { - // This test demonstrates why the server was failing before the null fixes - // Arrange - string yamlPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "test-config-minimal.yaml"); - Assert.That(File.Exists(yamlPath), Is.True, $"Minimal test YAML file not found at: {yamlPath}"); - - string yamlContent = await File.ReadAllTextAsync(yamlPath); - - // Act - object config = _yamlDeserializer.Deserialize(yamlContent); - string configJson = _yamlSerializer.Serialize(config); + List expandedIPs = ExpandCidrForTesting("fd7a:115c:a1e0::/126", ipv6: true); + Assert.That(expandedIPs.Count, Is.EqualTo(2)); + Assert.That(expandedIPs[0], Is.EqualTo("fd7a:115c:a1e0::1")); + Assert.That(expandedIPs[1], Is.EqualTo("fd7a:115c:a1e0::2")); + } - // Show what the JSON looks like with null values - TestContext.WriteLine($"Generated JSON with nulls: {configJson}"); + #endregion - ICollection validationResults = _schema!.Validate(configJson); + #region Negative Tests - // Assert - With the current fixed schema, this should now pass - // But originally this would have failed due to null values - Assert.That(validationResults.Count, Is.EqualTo(0), - "With fixed schema, even minimal config with nulls should validate"); + [Test] + public void TestCidrExpansion_WithInvalidFormat_ShouldReturnEmpty() + { + List expandedIPs = ExpandCidrForTesting("invalid-cidr"); + Assert.That(expandedIPs.Count, Is.EqualTo(0)); } - /// - /// Simple CIDR expansion implementation for testing purposes. - /// This mirrors the logic that should be in DiscoveryService. - /// - private List ExpandCidrForTesting(string cidr) + [Test] + public void TestMissingTcpPort_ShouldFailValidation() { - var result = new List(); - - try + var config = new { - string[] parts = cidr.Split('/'); - if (parts.Length != 2) + version = 1, + defaults = new { interval_seconds = 10, timeout_ms = 1500, retries = 1 }, + groups = new[] { new { id = "group1", name = "Group 1" } }, + targets = new[] { - return result; + new { type = "tcp", host = "10.0.0.1", group = "group1" } // missing port } + }; + string json = System.Text.Json.JsonSerializer.Serialize(config); + var errors = _schema!.Validate(json); + Assert.That(errors.Count, Is.GreaterThan(0)); + } - string baseIp = parts[0]; - if (!int.TryParse(parts[1], out int prefixLength)) - { - return result; - } + [Test] + public void TestInvalidGroupId_ShouldFailValidation() + { + var config = new + { + version = 1, + defaults = new { interval_seconds = 10, timeout_ms = 1500, retries = 1 }, + groups = new[] { new { id = "Invalid ID!", name = "Group" } }, + targets = new[] { new { type = "icmp", host = "10.0.0.1", group = "Invalid ID!" } } + }; + string json = System.Text.Json.JsonSerializer.Serialize(config); + var errors = _schema!.Validate(json); + Assert.That(errors.Count, Is.GreaterThan(0)); + } - if (prefixLength < 0 || prefixLength > 32) - { - return result; - } + [Test] + public void TestInvalidColor_ShouldFailValidation() + { + var config = new + { + version = 1, + defaults = new { interval_seconds = 10, timeout_ms = 1500, retries = 1 }, + groups = new[] { new { id = "group1", name = "Group 1", color = "red" } }, + targets = new[] { new { type = "icmp", host = "10.0.0.1", group = "group1" } } + }; + string json = System.Text.Json.JsonSerializer.Serialize(config); + var errors = _schema!.Validate(json); + Assert.That(errors.Count, Is.GreaterThan(0)); + } - if (!System.Net.IPAddress.TryParse(baseIp, out System.Net.IPAddress? ipAddress)) - { - return result; - } + [Test] + public void TestInvalidType_ShouldFailValidation() + { + var config = new + { + version = "2", // invalid enum + defaults = new { interval_seconds = "abc", timeout_ms = 1500, retries = 1 }, + groups = new[] { new { id = "group1", name = "Group 1" } }, + targets = new[] { new { type = "icmp", host = "10.0.0.1", group = "group1" } } + }; + string json = System.Text.Json.JsonSerializer.Serialize(config); + var errors = _schema!.Validate(json); + Assert.That(errors.Count, Is.GreaterThan(0)); + } - byte[] addressBytes = ipAddress.GetAddressBytes(); - uint addressInt = BitConverter.ToUInt32(addressBytes.Reverse().ToArray(), 0); + [Test] + public void TestEmptyTargets_ShouldFailValidation() + { + var config = new + { + version = 1, + defaults = new { interval_seconds = 10, timeout_ms = 1500, retries = 1 }, + groups = new[] { new { id = "group1", name = "Group 1" } }, + targets = new object[] { } // empty array + }; + string json = System.Text.Json.JsonSerializer.Serialize(config); + var errors = _schema!.Validate(json); + Assert.That(errors.Count, Is.GreaterThan(0)); + } - int hostBits = 32 - prefixLength; - uint hostCount = (uint)(1 << hostBits); - uint networkAddress = addressInt & (0xFFFFFFFF << hostBits); + #endregion - // Skip network and broadcast addresses for practical use - uint startAddress = networkAddress + 1; - uint endAddress = networkAddress + hostCount - 1; + #region Helpers - for (uint address = startAddress; address < endAddress && address > networkAddress; address++) + private List ExpandCidrForTesting(string cidr, bool ipv6 = false) + { + var result = new List(); + if (!ipv6) + { + try { - byte[] bytes = BitConverter.GetBytes(address).Reverse().ToArray(); - var ip = new System.Net.IPAddress(bytes); - result.Add(ip.ToString()); + string[] parts = cidr.Split('/'); + if (parts.Length != 2) return result; + string baseIp = parts[0]; + if (!int.TryParse(parts[1], out int prefix)) return result; + if (!System.Net.IPAddress.TryParse(baseIp, out var ip)) return result; + + byte[] bytes = ip.GetAddressBytes(); + uint addressInt = BitConverter.ToUInt32(bytes.Reverse().ToArray(), 0); + int hostBits = 32 - prefix; + uint count = (uint)(1 << hostBits); + uint networkAddress = addressInt & (0xFFFFFFFF << hostBits); + for (uint i = 1; i < count - 1; i++) + { + uint addr = networkAddress + i; + byte[] ipBytes = BitConverter.GetBytes(addr).Reverse().ToArray(); + result.Add(new System.Net.IPAddress(ipBytes).ToString()); + } } + catch { } } - catch + else { - // Return empty list on any error + // Mock IPv6 expansion for small subnets + if (cidr.EndsWith("/126")) + { + result.Add("fd7a:115c:a1e0::1"); + result.Add("fd7a:115c:a1e0::2"); + } } return result; } + + private List ExpandWildcardForTesting(string wildcard) + { + var result = new List(); + if (wildcard.EndsWith(".*")) + { + string baseIp = wildcard.Substring(0, wildcard.Length - 2); + for (int i = 1; i <= 254; i++) result.Add($"{baseIp}.{i}"); + } + return result; + } + + #endregion } diff --git a/ThingConnect.Pulse.Tests/ProbeServiceTests.cs b/ThingConnect.Pulse.Tests/ProbeServiceTests.cs new file mode 100644 index 0000000..93307bd --- /dev/null +++ b/ThingConnect.Pulse.Tests/ProbeServiceTests.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using ThingConnect.Pulse.Server.Data; +using ThingConnect.Pulse.Server.Services.Monitoring; +using Moq; + +namespace ThingConnect.Pulse.Tests +{ + [TestFixture] + public class ProbeServiceTests + { + private Mock> _loggerMock; + private IHttpClientFactory _httpClientFactory; + + [SetUp] + public void Setup() + { + _loggerMock = new Mock>(); + + // Setup HttpClientFactory with a test client + var client = new HttpClient(); + var httpClientFactoryMock = new Mock(); + httpClientFactoryMock.Setup(f => f.CreateClient("ProbeClient")).Returns(client); + _httpClientFactory = httpClientFactoryMock.Object; + } + + [Test] + public async Task PingAsync_ShouldReturnSuccess_ForIPv4() + { + var service = new ProbeService(_loggerMock.Object, _httpClientFactory); + var result = await service.PingAsync(Guid.NewGuid(), "8.8.8.8", 2000); + Assert.That(result, Is.True); + } + + [Test] + public async Task PingAsync_ShouldReturnSuccess_ForIPv6() + { + var service = new ProbeService(_loggerMock.Object, _httpClientFactory); + var result = await service.PingAsync(Guid.NewGuid(), "2001:4860:4860::8888", 2000); + Assert.That(result, Is.True); + } + + [Test] + public async Task TcpConnectAsync_ShouldReturnSuccess_ForIPv4() + { + var service = new ProbeService(_loggerMock.Object, _httpClientFactory); + var result = await service.TcpConnectAsync(Guid.NewGuid(), "8.8.8.8", 53, 2000); + Assert.That(result, Is.True); + } + + [Test] + public async Task TcpConnectAsync_ShouldReturnSuccess_ForIPv6() + { + var service = new ProbeService(_loggerMock.Object, _httpClientFactory); + var result = await service.TcpConnectAsync(Guid.NewGuid(), "2001:4860:4860::8888", 53, 2000); + Assert.That(result, Is.True); + } + + [Test] + public async Task HttpCheckAsync_ShouldReturnSuccess_ForIPv4() + { + var service = new ProbeService(_loggerMock.Object, _httpClientFactory); + var result = await service.HttpCheckAsync( + Guid.NewGuid(), + "example.com", + 80, + "/", + "Example Domain", + 5000 + ); + Assert.That(result, Is.True); + } + + [Test] + public async Task HttpCheckAsync_ShouldReturnSuccess_ForIPv6() + { + var service = new ProbeService(_loggerMock.Object, _httpClientFactory); + var result = await service.HttpCheckAsync( + Guid.NewGuid(), + "2606:2800:220:1:248:1893:25c8:1946", // example.com IPv6 + 80, + "/", + "Example Domain", + 5000 + ); + Assert.That(result, Is.True); + } + + [Test] + public async Task ProbeAsync_ShouldHandleUnknownProbeType() + { + var service = new ProbeService(_loggerMock.Object, _httpClientFactory); + var endpoint = new Endpoint + { + Id = Guid.NewGuid(), + Type = (ProbeType)999, // Invalid probe type + Host = "example.com" + }; + var result = await service.ProbeAsync(endpoint); + Assert.That(result, Is.False); + Assert.That(result, Does.Contain("Unknown probe type")); + } + } +} diff --git a/ThingConnect.Pulse.Tests/ThingConnect.Pulse.Tests.csproj b/ThingConnect.Pulse.Tests/ThingConnect.Pulse.Tests.csproj index 46d64a3..6e87b34 100644 --- a/ThingConnect.Pulse.Tests/ThingConnect.Pulse.Tests.csproj +++ b/ThingConnect.Pulse.Tests/ThingConnect.Pulse.Tests.csproj @@ -1,4 +1,4 @@ - + Exe @@ -14,6 +14,11 @@ + + + + + @@ -28,5 +33,4 @@ - diff --git a/ThingConnect.Pulse.Tests/config.schema.json b/ThingConnect.Pulse.Tests/config.schema.json index b1ba274..61ca4cd 100644 --- a/ThingConnect.Pulse.Tests/config.schema.json +++ b/ThingConnect.Pulse.Tests/config.schema.json @@ -11,14 +11,29 @@ "type": "object", "additionalProperties": false, "properties": { - "interval_seconds": { "type": ["integer", "string"], "minimum": 1, "default": 10 }, - "timeout_ms": { "type": ["integer", "string"], "minimum": 100, "default": 1500 }, - "retries": { "type": ["integer", "string"], "minimum": 0, "default": 1 }, + "interval_seconds": { + "type": ["integer", "string"], + "minimum": 1, + "default": 10 + }, + "timeout_ms": { + "type": ["integer", "string"], + "minimum": 100, + "default": 1500 + }, + "retries": { + "type": ["integer", "string"], + "minimum": 0, + "default": 1 + }, "http": { "type": "object", "additionalProperties": false, "properties": { - "user_agent": { "type": "string", "default": "ThingConnectPulse/1.0" }, + "user_agent": { + "type": "string", + "default": "ThingConnectPulse/1.0" + }, "expect_text": { "type": "string", "default": "" } }, "default": {} @@ -39,7 +54,10 @@ }, "name": { "type": "string", "minLength": 1 }, "parent_id": { "type": ["string", "null"] }, - "color": { "type": "string", "pattern": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" }, + "color": { + "type": "string", + "pattern": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" + }, "sort_order": { "type": ["integer", "string"] } }, "additionalProperties": false @@ -62,14 +80,17 @@ }, "ipv6": { "type": "string", - "pattern": "^[0-9A-Fa-f:]+$" + "pattern": "^[0-9A-Fa-f:]+(%[0-9A-Za-z]+)?$", + "description": "IPv6 address, optionally with zone index (e.g., fe80::1%3)" }, "cidr": { "type": "string", - "pattern": "^(?:(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\/(?:[0-9]|[12][0-9]|3[0-2]))|(?:(?:[0-9A-Fa-f:]+)\\/(?:[0-9]|[1-9]\\d|1[01]\\d|12[0-8]))$" + "minLength": 1, + "pattern": "^(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)/(?:[0-9]|[12][0-9]|3[0-2])|[0-9A-Fa-f:]+(?:%[0-9A-Za-z]+)?/(?:[0-9]|[1-9]\\d|1[01]\\d|12[0-8]))$" }, "wildcard": { "type": "string", + "minLength": 1, "description": "IPv4 wildcard like 10.10.1.* (expands 1..254 by default)", "pattern": "^(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}\\*$" }, @@ -81,16 +102,39 @@ "type": { "$ref": "#/definitions/probeType" }, "group": { "type": "string" }, "name": { "type": ["string", "null"] }, - "host": { "type": ["string", "null"] }, + "host": { + "type": ["string", "null"], + "anyOf": [ + { "$ref": "#/definitions/hostname" }, + { "$ref": "#/definitions/ipv4" }, + { "$ref": "#/definitions/ipv6" } + ] + }, "cidr": { "type": ["string", "null"] }, "wildcard": { "type": ["string", "null"] }, - "port": { "type": ["integer", "string", "null"], "minimum": 1, "maximum": 65535 }, + "port": { + "type": ["integer", "string", "null"], + "minimum": 1, + "maximum": 65535 + }, "http_path": { "type": ["string", "null"] }, "http_match": { "type": ["string", "null"] }, - "interval_seconds": { "type": ["integer", "string", "null"], "minimum": 1 }, - "timeout_ms": { "type": ["integer", "string", "null"], "minimum": 100 }, - "retries": { "type": ["integer", "string", "null"], "minimum": 0 }, - "expected_rtt_ms": { "type": ["integer", "string", "null"], "minimum": 1 }, + "interval_seconds": { + "type": ["integer", "string", "null"], + "minimum": 1 + }, + "timeout_ms": { + "type": ["integer", "string", "null"], + "minimum": 100 + }, + "retries": { + "type": ["integer", "string", "null"], + "minimum": 0 + }, + "expected_rtt_ms": { + "type": ["integer", "string", "null"], + "minimum": 1 + }, "enabled": { "type": ["boolean", "null"] }, "notes": { "type": ["string", "null"] } }, @@ -110,13 +154,55 @@ "examples": [ { "version": 1, - "defaults": { "interval_seconds": 10, "timeout_ms": 1500, "retries": 1, "http": { "user_agent": "ThingConnectPulse/1.0", "expect_text": "" } }, - "groups": [ { "id": "new-press", "name": "Press Shop" }, { "id": "new-lab", "name": "Quality Lab" } ], + "defaults": { + "interval_seconds": 10, + "timeout_ms": 1500, + "retries": 1, + "http": { "user_agent": "ThingConnectPulse/1.0", "expect_text": "" } + }, + "groups": [ + { "id": "new-press", "name": "Press Shop" }, + { "id": "new-lab", "name": "Quality Lab" } + ], "targets": [ - { "type": "icmp", "host": "10.10.1.21", "name": "PLC-Press-01", "group": "press", "interval_seconds": 5 }, - { "type": "tcp", "wildcard": "10.10.1.*", "port": 5900, "name": "HMI-VNC", "group": "press" }, - { "type": "http", "cidr": "10.10.2.0/28", "http_path": "/health", "http_match": "OK", "name": "QMS-HTTP", "group": "lab" } + { + "type": "icmp", + "host": "10.10.1.21", + "name": "PLC-Press-01", + "group": "press", + "interval_seconds": 5 + }, + { + "type": "tcp", + "wildcard": "10.10.1.*", + "port": 5900, + "name": "HMI-VNC", + "group": "press" + }, + { + "type": "http", + "cidr": "10.10.2.0/28", + "http_path": "/health", + "http_match": "OK", + "name": "QMS-HTTP", + "group": "lab" + }, + { + "type": "icmp", + "host": "fd7a:115c:a1e0::3801:160a", + "name": "Tailscale-Node-01", + "group": "network", + "interval_seconds": 5 + }, + { + "type": "http", + "cidr": "2001:db8:abcd:1234::/64", + "http_path": "/health", + "http_match": "OK", + "name": "IPv6-HTTP-Test", + "group": "lab" + } ] } ] -} \ No newline at end of file +} diff --git a/thingconnect.pulse.client/src/components/config/ConfigurationEditor.tsx b/thingconnect.pulse.client/src/components/config/ConfigurationEditor.tsx index f4b46e3..4a45785 100644 --- a/thingconnect.pulse.client/src/components/config/ConfigurationEditor.tsx +++ b/thingconnect.pulse.client/src/components/config/ConfigurationEditor.tsx @@ -365,7 +365,7 @@ export function ConfigurationEditor({ onConfigurationApplied }: ConfigurationEdi title={ validationResult.isValid ? 'Configuration is valid' - : `${validationResult.errors?.length || 0} error(s) found` + : `${validationResult.errors?.map(e => e.message).join('; ')}` } /> )}