diff --git a/src/DnsmasqWebUI.Tests/DnsmasqDhcpRangeValueParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqDhcpRangeValueParserTests.cs new file mode 100644 index 0000000..006cdc1 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/DnsmasqDhcpRangeValueParserTests.cs @@ -0,0 +1,42 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config; + +namespace DnsmasqWebUI.Tests; + +public class DnsmasqDhcpRangeValueParserTests +{ + [Fact] + public void TryParse_ValidRange_ReturnsStructuredTokens() + { + var ok = DnsmasqDhcpRangeValueParser.TryParse( + "tag:guest,set:known,192.168.1.50,192.168.1.150,12h", + out var parsed, + out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.NotNull(parsed); + Assert.Equal(["tag:guest", "set:known"], parsed!.Tags); + Assert.Equal("192.168.1.50", parsed.StartToken); + Assert.Equal("192.168.1.150", parsed.SecondToken); + Assert.Equal(["12h"], parsed.RemainingTokens); + } + + [Fact] + public void TryParse_MalformedTrailingComma_ReturnsError() + { + var ok = DnsmasqDhcpRangeValueParser.TryParse("172.28.0.10,", out var parsed, out var error); + + Assert.False(ok); + Assert.Null(parsed); + Assert.Equal("dhcp-range contains an empty comma-separated segment.", error); + } + + [Fact] + public void GetIPv4StartEnd_ReturnsParsedIpv4Range() + { + var result = DnsmasqDhcpRangeValueParser.GetIPv4StartEnd("tag:guest,192.168.1.50,192.168.1.150,12h"); + + Assert.Equal(("192.168.1.50", "192.168.1.150"), result); + } +} diff --git a/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs b/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs index 76f0d09..ec47765 100644 --- a/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs +++ b/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs @@ -12,8 +12,37 @@ public class OptionSemanticValidatorTests private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([ new LeasequerySemanticHandler(), new ServerSemanticHandler(), + new LocalSemanticHandler(), new RevServerSemanticHandler(), new AddressSemanticHandler(), + new TrustAnchorSemanticHandler(), + new AliasSemanticHandler(), + new IpsetSemanticHandler(), + new NftsetSemanticHandler(), + new IgnoreAddressSemanticHandler(), + new ConnmarkAllowlistSemanticHandler(), + new DhcpRangeSemanticHandler(), + new DhcpHostSemanticHandler(), + new DhcpOptionSemanticHandler(), + new DhcpMatchSemanticHandler(), + new DhcpMacSemanticHandler(), + new DhcpRelaySemanticHandler(), + new DhcpProxySemanticHandler(), + new RaParamSemanticHandler(), + new DhcpNameMatchSemanticHandler(), + new DhcpIgnoreSemanticHandler(), + new DhcpVendorclassSemanticHandler(), + new DhcpUserclassSemanticHandler(), + new TagIfSemanticHandler(), + new BridgeInterfaceSemanticHandler(), + new SharedNetworkSemanticHandler(), + new DhcpOptionPxeSemanticHandler(), + new RebindDomainOkSemanticHandler(), + new BogusNxdomainSemanticHandler(), + new DhcpIgnoreNamesSemanticHandler(), + new DhcpBootSemanticHandler(), + new SlaacSemanticHandler(), + new PxeServiceSemanticHandler(), ]); [Fact] @@ -98,7 +127,7 @@ public class OptionSemanticValidatorTests [InlineData("/example.local/#", true)] [InlineData("/example.local/", true)] [InlineData("example.local/192.168.1.10", false)] - [InlineData("//192.168.1.10", false)] + [InlineData("//192.168.1.10", true)] [InlineData("/example.local/not-an-ip", false)] public void ValidateMultiItem_Address_UsesHandler(string value, bool valid) { @@ -107,6 +136,360 @@ public class OptionSemanticValidatorTests Assert.Equal(valid, err is null); } + [Theory] + [InlineData("/example.local/", true)] + [InlineData("//", true)] + [InlineData("/*.example.local/", true)] + [InlineData("/internal$lan/", false)] + [InlineData("/example.local/192.168.1.1", false)] + public void ValidateMultiItem_Local_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Local, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData(".,20326,8,2,abcdef", true)] + [InlineData("example.com", true)] + [InlineData("example.com,IN", true)] + [InlineData("example.com,IN,20326,8,2,abcdef", true)] + [InlineData("", false)] + [InlineData("example.com,BOGUS", false)] + [InlineData("example.com,IN,tag,8,2,abcdef", false)] + public void ValidateMultiItem_TrustAnchor_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.TrustAnchor, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("1.2.3.0,6.7.8.0,255.255.255.0", true)] + [InlineData("192.168.0.10-192.168.0.40,10.0.0.0,255.255.255.0", true)] + [InlineData("192.168.0.10-,10.0.0.1", false)] + [InlineData("not-an-ip,10.0.0.1", false)] + public void ValidateMultiItem_Alias_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Alias, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("/example.local/ipset1", true)] + [InlineData("/example.local/example.org/ipset1,ipset2", true)] + [InlineData("/internal$lan/ipset1", false)] + [InlineData("/example.local/", false)] + public void ValidateMultiItem_Ipset_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Ipset, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("/example.local/inet#filter#set1", true)] + [InlineData("/example.local/4#inet#filter#set1", true)] + [InlineData("/example.local/6#inet#filter#set1", true)] + [InlineData("/example.local/not#enough", false)] + [InlineData("/internal$lan/inet#filter#set1", false)] + public void ValidateMultiItem_Nftset_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Nftset, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("64.94.110.11", true)] + [InlineData("10.0.0.0/24", true)] + [InlineData("2001:db8::/64", true)] + [InlineData("not-an-ip", false)] + public void ValidateMultiItem_IgnoreAddress_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.IgnoreAddress, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("example.com", true)] + [InlineData("/domain1/domain2/", true)] + [InlineData("/domain1//", false)] + [InlineData("internal$lan", false)] + public void ValidateMultiItem_RebindDomainOk_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.RebindDomainOk, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("64.94.110.11", true)] + [InlineData("64.94.110.11/24", true)] + [InlineData("not-an-ip", false)] + public void ValidateMultiItem_BogusNxdomain_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.BogusNxdomain, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("0xff,example.com", true)] + [InlineData("0xff/0xff,*", true)] + [InlineData("0xff,*.example.com/api.example.com", true)] + [InlineData("not-a-mark,example.com", false)] + [InlineData("0xff,local", false)] + public void ValidateMultiItem_ConnmarkAllowlist_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.ConnmarkAllowlist, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("192.168.1.50,192.168.1.150", true)] + [InlineData("tag:guest,set:known,192.168.1.50,192.168.1.150,12h", true)] + [InlineData("constructor:eth0,::,static", true)] + [InlineData("172.28.0.10,", false)] + [InlineData("172.28.0.10", false)] + [InlineData("tag:guest,", false)] + [InlineData("not-an-ip,192.168.1.150", false)] + public void ValidateMultiItem_DhcpRange_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRange, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("00:20:e0:3b:13:af,wap,infinite", true)] + [InlineData("lap,192.168.0.199", true)] + [InlineData("id:clientid,set:known,192.168.1.10,host1,12h", true)] + [InlineData("ignore", false)] + [InlineData("00:20:e0:3b:13:af,,host1", false)] + public void ValidateMultiItem_DhcpHost_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHost, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("3,192.168.4.4", true)] + [InlineData("option:router,192.168.4.4", true)] + [InlineData("vendor:PXEClient,1,0.0.0.0", true)] + [InlineData("encap:175,190,iscsi-client0", true)] + [InlineData("option:", false)] + [InlineData("vendor:,", false)] + public void ValidateMultiItem_DhcpOption_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpOption, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("set:efi-ia32,option:client-arch,6", true)] + [InlineData("set:known,93", true)] + [InlineData("option:client-arch,6", false)] + [InlineData("set:,option:client-arch,6", false)] + public void ValidateMultiItem_DhcpMatch_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpMatch, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("set:3com,01:34:23:*:*:*", true)] + [InlineData("set:vendor,aa:bb:cc:dd:ee:ff", true)] + [InlineData("01:34:23:*:*:*", false)] + [InlineData("set:,01:34:23:*:*:*", false)] + public void ValidateMultiItem_DhcpMac_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpMac, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("set:tag,hostname*", true)] + [InlineData("set:tag,hostname", true)] + [InlineData("set:tag,*host*", false)] + [InlineData("hostname*", false)] + public void ValidateMultiItem_DhcpNameMatch_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpNameMatch, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("", true)] + [InlineData("tag:guest", true)] + [InlineData("tag:guest,tag:lab", true)] + [InlineData("guest", false)] + public void ValidateMultiItem_DhcpIgnoreNames_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpIgnoreNames, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("tag:blocked", true)] + [InlineData("tag:blocked,tag:!known", true)] + [InlineData("blocked", false)] + public void ValidateMultiItem_DhcpIgnore_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpIgnore, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("set:printers,Hewlett-Packard JetDirect", true)] + [InlineData("printers,enterprise:32473,VendorClass", true)] + [InlineData("set:printers,enterprise:notnum,VendorClass", false)] + public void ValidateMultiItem_DhcpVendorclass_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpVendorclass, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("set:userclass,ExampleClient", true)] + [InlineData("userclass,ExampleClient", true)] + [InlineData("set:,ExampleClient", false)] + public void ValidateMultiItem_DhcpUserclass_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpUserclass, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("set:ppp,tag:ppp*", true)] + [InlineData("set:guest,tag:!known", true)] + [InlineData("tag:known", false)] + public void ValidateMultiItem_TagIf_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.TagIf, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("192.168.1.1,192.168.2.1", true)] + [InlineData("192.168.1.1,192.168.2.1#1067,eth1", true)] + [InlineData("192.168.1.1,eth1", true)] + [InlineData("not-an-ip,192.168.2.1", false)] + [InlineData("192.168.1.1,server.example.com", false)] + public void ValidateMultiItem_DhcpRelay_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRelay, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("192.168.1.1", true)] + [InlineData("192.168.1.1,192.168.1.2", true)] + [InlineData("not-an-ip", false)] + public void ValidateMultiItem_DhcpProxy_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpProxy, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("br0,eth0", true)] + [InlineData("br0,tap*", true)] + [InlineData("br0", false)] + public void ValidateMultiItem_BridgeInterface_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.BridgeInterface, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("pxelinux.0", true)] + [InlineData("tag:pxe,pxelinux.0,,192.168.1.2", true)] + [InlineData("tag:,pxelinux.0", false)] + [InlineData("", false)] + public void ValidateMultiItem_DhcpBoot_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpBoot, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("sharednet,192.168.10.0,255.255.255.0", true)] + [InlineData("eth0,192.168.10.1", true)] + [InlineData("sharednet", false)] + public void ValidateMultiItem_SharedNetwork_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.SharedNetwork, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("eth0,::10", true)] + [InlineData("slaac", true)] + [InlineData("ra-names,eth0", true)] + [InlineData("bad value", false)] + public void ValidateMultiItem_Slaac_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Slaac, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("eth0,60", true)] + [InlineData("eth0,mtu:1280,low,60,1200", true)] + [InlineData("eth0,high", true)] + [InlineData(",60", false)] + [InlineData("eth0,mtu:", false)] + public void ValidateMultiItem_RaParam_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.RaParam, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("x86PC,\"PXE Boot\",pxelinux", true)] + [InlineData("tag:pxe,x86PC,\"PXE Boot\",pxelinux,192.168.1.2", true)] + [InlineData("x86PC", false)] + [InlineData("bad-csa,\"PXE Boot\",pxelinux", false)] + public void ValidateMultiItem_PxeService_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.PxeService, value, semantics); + Assert.Equal(valid, err is null); + } + + [Theory] + [InlineData("vendor:PXEClient,1,0.0.0.0", true)] + [InlineData("encap:175,190,iscsi-client0", true)] + [InlineData("option:router,192.168.1.1", false)] + [InlineData("vendor:,1,0.0.0.0", false)] + public void ValidateMultiItem_DhcpOptionPxe_UsesHandler(string value, bool valid) + { + var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true); + var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpOptionPxe, value, semantics); + Assert.Equal(valid, err is null); + } + [Fact] public void ValidateSingle_UseStaleCache_UsesEngineRule() { diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqDhcpRangeValueParser.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqDhcpRangeValueParser.cs new file mode 100644 index 0000000..cd703ff --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqDhcpRangeValueParser.cs @@ -0,0 +1,124 @@ +using System.Net; + +namespace DnsmasqWebUI.Infrastructure.Helpers.Config; + +/// +/// Shared structural parser for dhcp-range option values. +/// This is intentionally conservative and captures the core structure needed by +/// validation and simple range extraction without re-implementing all dnsmasq semantics. +/// +public sealed record ParsedDhcpRange( + IReadOnlyList Tags, + string StartToken, + string SecondToken, + IReadOnlyList RemainingTokens); + +/// +/// Parser for a single dhcp-range value. +/// +public static class DnsmasqDhcpRangeValueParser +{ + private static readonly HashSet ModeKeywords = new(StringComparer.OrdinalIgnoreCase) + { + "static", + "proxy", + "ra-only", + "ra-stateless", + "ra-names", + "slaac", + "off-link", + }; + + public static bool TryParse(string raw, out ParsedDhcpRange? parsed, out string? error) + { + parsed = null; + error = null; + + if (string.IsNullOrWhiteSpace(raw)) + { + error = "Value cannot be empty."; + return false; + } + + var tokens = raw.Split(',').Select(t => t.Trim()).ToArray(); + if (tokens.Any(t => t.Length == 0)) + { + error = "dhcp-range contains an empty comma-separated segment."; + return false; + } + + var index = 0; + var tags = new List(); + while (index < tokens.Length && IsTagToken(tokens[index])) + { + tags.Add(tokens[index]); + index++; + } + + if (index >= tokens.Length || !IsRangeStartToken(tokens[index])) + { + error = "dhcp-range must include a valid start address or constructor: after any tag/set prefixes."; + return false; + } + + var startToken = tokens[index]; + index++; + + if (index >= tokens.Length) + { + error = "dhcp-range must include an end address or mode after the start address."; + return false; + } + + var secondToken = tokens[index]; + if (!IsIpAddress(secondToken) && !IsModeToken(secondToken)) + { + error = "dhcp-range second value must be an end address or a valid mode."; + return false; + } + + parsed = new ParsedDhcpRange( + tags, + startToken, + secondToken, + tokens.Skip(index + 1).ToArray()); + return true; + } + + public static (string? Start, string? End) GetIPv4StartEnd(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return (null, null); + + if (!TryParse(raw, out var parsed, out _)) + return (null, null); + + if (!IPAddress.TryParse(parsed!.StartToken, out var startIp) || + startIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + { + return (null, null); + } + + if (!IPAddress.TryParse(parsed.SecondToken, out var endIp) || + endIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + { + return (parsed.StartToken, null); + } + + return (parsed.StartToken, parsed.SecondToken); + } + + private static bool IsTagToken(string value) => + value.StartsWith("tag:", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("set:", StringComparison.OrdinalIgnoreCase); + + private static bool IsModeToken(string value) => + ModeKeywords.Contains(value) || + value.StartsWith("constructor:", StringComparison.OrdinalIgnoreCase); + + private static bool IsRangeStartToken(string value) => + IsIpAddress(value) || value.StartsWith("constructor:", StringComparison.OrdinalIgnoreCase); + + private static bool IsIpAddress(string value) => + IPAddress.TryParse(value, out _); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs index d551b0f..59c6826 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs @@ -96,6 +96,11 @@ public static class EffectiveConfigSpecialOptionSemantics EffectiveConfigParserBehavior.Multi, EffectiveConfigWriteBehavior.MultiValue, ComplexMulti), + [DnsmasqConfKeys.Local] = new OptionSemantics( + DnsmasqConfKeys.Local, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), [DnsmasqConfKeys.RevServer] = new OptionSemantics( DnsmasqConfKeys.RevServer, EffectiveConfigParserBehavior.Multi, @@ -106,6 +111,36 @@ public static class EffectiveConfigSpecialOptionSemantics EffectiveConfigParserBehavior.Multi, EffectiveConfigWriteBehavior.MultiValue, ComplexMulti), + [DnsmasqConfKeys.RebindDomainOk] = new OptionSemantics( + DnsmasqConfKeys.RebindDomainOk, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.BogusNxdomain] = new OptionSemantics( + DnsmasqConfKeys.BogusNxdomain, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.IgnoreAddress] = new OptionSemantics( + DnsmasqConfKeys.IgnoreAddress, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.Alias] = new OptionSemantics( + DnsmasqConfKeys.Alias, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.Ipset] = new OptionSemantics( + DnsmasqConfKeys.Ipset, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.Nftset] = new OptionSemantics( + DnsmasqConfKeys.Nftset, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), [DnsmasqConfKeys.ListenAddress] = new OptionSemantics( DnsmasqConfKeys.ListenAddress, EffectiveConfigParserBehavior.Multi, @@ -176,6 +211,61 @@ public static class EffectiveConfigSpecialOptionSemantics EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.SingleValue, PathFileSingleMustExist), + [DnsmasqConfKeys.DhcpRange] = new OptionSemantics( + DnsmasqConfKeys.DhcpRange, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpHost] = new OptionSemantics( + DnsmasqConfKeys.DhcpHost, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpOption] = new OptionSemantics( + DnsmasqConfKeys.DhcpOption, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpOptionForce] = new OptionSemantics( + DnsmasqConfKeys.DhcpOptionForce, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpMatch] = new OptionSemantics( + DnsmasqConfKeys.DhcpMatch, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpMac] = new OptionSemantics( + DnsmasqConfKeys.DhcpMac, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpIgnoreNames] = new OptionSemantics( + DnsmasqConfKeys.DhcpIgnoreNames, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpNameMatch] = new OptionSemantics( + DnsmasqConfKeys.DhcpNameMatch, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpIgnore] = new OptionSemantics( + DnsmasqConfKeys.DhcpIgnore, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpVendorclass] = new OptionSemantics( + DnsmasqConfKeys.DhcpVendorclass, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpUserclass] = new OptionSemantics( + DnsmasqConfKeys.DhcpUserclass, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), [DnsmasqConfKeys.DhcpHostsfile] = new OptionSemantics( DnsmasqConfKeys.DhcpHostsfile, EffectiveConfigParserBehavior.Multi, @@ -196,6 +286,66 @@ public static class EffectiveConfigSpecialOptionSemantics EffectiveConfigParserBehavior.Multi, EffectiveConfigWriteBehavior.MultiValue, PathDirectoryMulti), + [DnsmasqConfKeys.DhcpRelay] = new OptionSemantics( + DnsmasqConfKeys.DhcpRelay, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpProxy] = new OptionSemantics( + DnsmasqConfKeys.DhcpProxy, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.RaParam] = new OptionSemantics( + DnsmasqConfKeys.RaParam, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.TagIf] = new OptionSemantics( + DnsmasqConfKeys.TagIf, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.BridgeInterface] = new OptionSemantics( + DnsmasqConfKeys.BridgeInterface, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.SharedNetwork] = new OptionSemantics( + DnsmasqConfKeys.SharedNetwork, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpBoot] = new OptionSemantics( + DnsmasqConfKeys.DhcpBoot, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.DhcpOptionPxe] = new OptionSemantics( + DnsmasqConfKeys.DhcpOptionPxe, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.PxeService] = new OptionSemantics( + DnsmasqConfKeys.PxeService, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.Slaac] = new OptionSemantics( + DnsmasqConfKeys.Slaac, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.TrustAnchor] = new OptionSemantics( + DnsmasqConfKeys.TrustAnchor, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.ConnmarkAllowlist] = new OptionSemantics( + DnsmasqConfKeys.ConnmarkAllowlist, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), }; /// Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints. diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigSetService.cs index 2c34a15..7c52d8c 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigSetService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigSetService.cs @@ -1,6 +1,7 @@ using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Contracts; +using DnsmasqWebUI.Infrastructure.Helpers.Config; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config; @@ -56,22 +57,7 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService /// Parses dhcp-range value to (startIp, endIp). Format is typically start,end,mask,lease or tag:...,start,end,...; finds first two IPv4-looking tokens. internal static (string? Start, string? End) ParseDhcpRangeStartEnd(string? raw) - { - if (string.IsNullOrWhiteSpace(raw)) return (null, null); - var parts = raw.Split(','); - string? start = null; - string? end = null; - foreach (var p in parts) - { - var t = p.Trim(); - if (string.IsNullOrEmpty(t)) continue; - if (!System.Net.IPAddress.TryParse(t, out var ip) || ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) - continue; - if (start == null) { start = t; continue; } - if (end == null) { end = t; break; } - } - return (start, end); - } + => DnsmasqDhcpRangeValueParser.GetIPv4StartEnd(raw); private ConfigSetSnapshot GetSnapshot() => _cache.GetSnapshotAsync(CancellationToken.None).GetAwaiter().GetResult(); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs index 1b2361a..9582f4e 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs @@ -192,28 +192,47 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts); RegisterSemanticMultis( EffectiveConfigFieldBuilder.SectionResolver, + DnsmasqConfKeys.Local, DnsmasqConfKeys.ResolvFile, DnsmasqConfKeys.RevServer, - DnsmasqConfKeys.Address); - RegisterSemanticMultis( - EffectiveConfigFieldBuilder.SectionDhcp, - DnsmasqConfKeys.DhcpHostsfile, - DnsmasqConfKeys.DhcpOptsfile, - DnsmasqConfKeys.DhcpHostsdir, - DnsmasqConfKeys.DhcpOptsdir, - DnsmasqConfKeys.Leasequery); - - RegisterMultis( - EffectiveConfigFieldBuilder.SectionResolver, - DnsmasqConfKeys.Local, + DnsmasqConfKeys.Address, DnsmasqConfKeys.RebindDomainOk, DnsmasqConfKeys.BogusNxdomain, DnsmasqConfKeys.IgnoreAddress, DnsmasqConfKeys.Alias, - DnsmasqConfKeys.FilterRr, DnsmasqConfKeys.Ipset, DnsmasqConfKeys.Nftset, DnsmasqConfKeys.ConnmarkAllowlist); + RegisterSemanticMultis( + EffectiveConfigFieldBuilder.SectionDhcp, + DnsmasqConfKeys.DhcpRange, + DnsmasqConfKeys.DhcpHost, + DnsmasqConfKeys.DhcpOption, + DnsmasqConfKeys.DhcpOptionForce, + DnsmasqConfKeys.DhcpMatch, + DnsmasqConfKeys.DhcpMac, + DnsmasqConfKeys.DhcpIgnoreNames, + DnsmasqConfKeys.DhcpNameMatch, + DnsmasqConfKeys.DhcpHostsfile, + DnsmasqConfKeys.DhcpOptsfile, + DnsmasqConfKeys.DhcpHostsdir, + DnsmasqConfKeys.DhcpOptsdir, + DnsmasqConfKeys.Leasequery, + DnsmasqConfKeys.DhcpRelay, + DnsmasqConfKeys.DhcpProxy, + DnsmasqConfKeys.RaParam, + DnsmasqConfKeys.TagIf, + DnsmasqConfKeys.BridgeInterface, + DnsmasqConfKeys.SharedNetwork, + DnsmasqConfKeys.DhcpBoot, + DnsmasqConfKeys.DhcpIgnore, + DnsmasqConfKeys.DhcpVendorclass, + DnsmasqConfKeys.DhcpUserclass, + DnsmasqConfKeys.Slaac); + + RegisterMultis( + EffectiveConfigFieldBuilder.SectionResolver, + DnsmasqConfKeys.FilterRr); RegisterMultis( EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Domain, @@ -235,33 +254,16 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm DnsmasqConfKeys.AuthPeer); RegisterMultis( EffectiveConfigFieldBuilder.SectionDhcp, - DnsmasqConfKeys.DhcpRange, - DnsmasqConfKeys.DhcpHost, - DnsmasqConfKeys.DhcpOption, - DnsmasqConfKeys.DhcpOptionForce, - DnsmasqConfKeys.DhcpMatch, - DnsmasqConfKeys.DhcpMac, - DnsmasqConfKeys.DhcpNameMatch, - DnsmasqConfKeys.DhcpIgnoreNames, - DnsmasqConfKeys.DhcpRelay, DnsmasqConfKeys.DhcpCircuitid, DnsmasqConfKeys.DhcpRemoteid, - DnsmasqConfKeys.DhcpSubscrid, - DnsmasqConfKeys.DhcpProxy, - DnsmasqConfKeys.TagIf, - DnsmasqConfKeys.BridgeInterface, - DnsmasqConfKeys.SharedNetwork, - DnsmasqConfKeys.DhcpBoot, - DnsmasqConfKeys.DhcpIgnore, - DnsmasqConfKeys.DhcpVendorclass, - DnsmasqConfKeys.DhcpUserclass, - DnsmasqConfKeys.RaParam, - DnsmasqConfKeys.Slaac); - RegisterMultis( + DnsmasqConfKeys.DhcpSubscrid); + RegisterSemanticMultis( + EffectiveConfigFieldBuilder.SectionTftpPxe, + DnsmasqConfKeys.PxeService); + RegisterSemanticMultis( EffectiveConfigFieldBuilder.SectionTftpPxe, - DnsmasqConfKeys.PxeService, DnsmasqConfKeys.DhcpOptionPxe); - RegisterMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor); + RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor); RegisterMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr); RegisterMultis( EffectiveConfigFieldBuilder.SectionProcess, diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AddressSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AddressSemanticHandler.cs index ea18145..30285f4 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AddressSemanticHandler.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AddressSemanticHandler.cs @@ -1,6 +1,4 @@ using System.Net; -using System.Linq; -using System.Text.RegularExpressions; using DnsmasqWebUI.Infrastructure.Helpers.Config; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; @@ -9,11 +7,8 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; /// Specialized semantic behavior for address values. /// Validates the /domain[/domain...]/ip structure and accepts empty or # address forms. /// -public sealed partial class AddressSemanticHandler : IOptionSemanticHandler +public sealed class AddressSemanticHandler : IOptionSemanticHandler { - [GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$", RegexOptions.CultureInvariant)] - private static partial Regex DomainPattern(); - public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.Address; @@ -25,24 +20,16 @@ public sealed partial class AddressSemanticHandler : IOptionSemanticHandler if (s.Length == 0) return "Value cannot be empty."; - if (!s.StartsWith("/", StringComparison.Ordinal)) - return "Address must start with '/'."; + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domainParts, out var addressPart, out var error)) + return error == "Value must start with '/'." + ? "Address must start with '/'." + : "Address must use /domain[/domain...]/ip syntax."; - var parts = s.Split('/'); - if (parts.Length < 3) - return "Address must use /domain[/domain...]/ip syntax."; + error = DnsmasqScopedDomainSyntax.ValidateDomainPatterns(domainParts); + if (error is not null) + return error.Replace("Value", "Address", StringComparison.Ordinal); - var domainParts = parts.Skip(1).Take(parts.Length - 2).ToArray(); - if (domainParts.Length == 0 || domainParts.Any(string.IsNullOrWhiteSpace)) - return "Address must include at least one domain pattern."; - - foreach (var domain in domainParts) - { - if (!IsValidDomainPattern(domain)) - return $"Invalid domain pattern '{domain}'."; - } - - var addressPart = parts[^1].Trim(); + addressPart = addressPart.Trim(); if (addressPart.Length == 0 || addressPart == "#") return null; @@ -50,20 +37,4 @@ public sealed partial class AddressSemanticHandler : IOptionSemanticHandler ? null : "Address target must be empty, '#', or a valid IP address."; } - - private static bool IsValidDomainPattern(string domain) - { - if (domain == "#") - return true; - - var normalized = domain; - if (normalized.StartsWith('*')) - normalized = normalized[1..]; - if (normalized.StartsWith('.')) - normalized = normalized[1..]; - - return normalized.Length > 0 && - normalized.Length <= 253 && - DomainPattern().IsMatch(normalized); - } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AliasSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AliasSemanticHandler.cs new file mode 100644 index 0000000..28a0b12 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AliasSemanticHandler.cs @@ -0,0 +1,49 @@ +using System.Net; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for alias values. +/// Supports IPv4 single-address or IPv4 range mapping forms. +/// +public sealed class AliasSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.Alias; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',').Select(t => t.Trim()).ToArray(); + if (tokens.Length is < 2 or > 3 || tokens.Any(t => t.Length == 0)) + return "alias must be old-ip,new-ip[,mask] or start-ip-end-ip,new-ip[,mask]."; + + if (!IsValidOldIpOrRange(tokens[0])) + return "alias first value must be an IPv4 address or IPv4 range."; + if (!IsIPv4(tokens[1])) + return "alias replacement address must be a valid IPv4 address."; + if (tokens.Length == 3 && !IsIPv4(tokens[2])) + return "alias mask must be a valid IPv4 address."; + + return null; + } + + private static bool IsValidOldIpOrRange(string value) + { + if (IsIPv4(value)) + return true; + + var parts = value.Split('-', 2); + return parts.Length == 2 && IsIPv4(parts[0]) && IsIPv4(parts[1]); + } + + private static bool IsIPv4(string value) => + IPAddress.TryParse(value, out var ip) && + ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork; +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/BogusNxdomainSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/BogusNxdomainSemanticHandler.cs new file mode 100644 index 0000000..f42bd19 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/BogusNxdomainSemanticHandler.cs @@ -0,0 +1,21 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for bogus-nxdomain values. +/// +public sealed class BogusNxdomainSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.BogusNxdomain; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + return DnsmasqIpPrefixSyntax.ValidateIpWithOptionalPrefix(s, "bogus-nxdomain"); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/BridgeInterfaceSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/BridgeInterfaceSemanticHandler.cs new file mode 100644 index 0000000..ca2922e --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/BridgeInterfaceSemanticHandler.cs @@ -0,0 +1,35 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for bridge-interface values. +/// +public sealed class BridgeInterfaceSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.BridgeInterface; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Length < 2 || tokens.Any(t => t.Length == 0)) + return "bridge-interface must be interface,alias[,alias]."; + + if (!DnsmasqDhcpTagSyntax.IsInterfaceLike(tokens[0])) + return "bridge-interface must start with a valid interface name."; + + for (var i = 1; i < tokens.Length; i++) + { + if (!DnsmasqDhcpTagSyntax.IsInterfaceLike(tokens[i], allowWildcard: true)) + return $"Invalid bridge-interface alias '{tokens[i]}'."; + } + + return null; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ConnmarkAllowlistSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ConnmarkAllowlistSemanticHandler.cs new file mode 100644 index 0000000..328894d --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ConnmarkAllowlistSemanticHandler.cs @@ -0,0 +1,72 @@ +using System.Text.RegularExpressions; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for connmark-allowlist values. +/// Validates the connmark[/mask] prefix and one or more domain-style patterns or '*' disable form. +/// +public sealed partial class ConnmarkAllowlistSemanticHandler : IOptionSemanticHandler +{ + [GeneratedRegex(@"^[A-Za-z0-9*]([A-Za-z0-9*-]*[A-Za-z0-9*])?(\.[A-Za-z0-9*]([A-Za-z0-9*-]*[A-Za-z0-9*])?)+$", RegexOptions.CultureInvariant)] + private static partial Regex PatternRegex(); + + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.ConnmarkAllowlist; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Length < 2 || tokens.Any(t => t.Length == 0)) + return "connmark-allowlist must be connmark[/mask],pattern[/pattern...]."; + + if (!IsValidConnmark(tokens[0])) + return "connmark-allowlist mark must be decimal or hex, with optional /mask."; + + if (tokens.Length == 2 && tokens[1] == "*") + return null; + + foreach (var token in tokens.Skip(1)) + { + var patterns = token.Split('/'); + foreach (var pattern in patterns) + { + if (string.IsNullOrWhiteSpace(pattern)) + return "connmark-allowlist contains an empty pattern."; + if (!IsValidPattern(pattern)) + return $"Invalid allowlist pattern '{pattern}'."; + } + } + + return null; + } + + private static bool IsValidConnmark(string value) + { + var parts = value.Split('/', 2); + return IsUInt(parts[0]) && (parts.Length == 1 || IsUInt(parts[1])); + } + + private static bool IsUInt(string value) + { + if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return uint.TryParse(value[2..], System.Globalization.NumberStyles.HexNumber, null, out _); + return uint.TryParse(value, out _); + } + + private static bool IsValidPattern(string value) + { + if (value.Equals("local", StringComparison.OrdinalIgnoreCase)) + return false; + + return PatternRegex().IsMatch(value) && + !value.Split('.').Last().All(char.IsDigit); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpBootSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpBootSemanticHandler.cs new file mode 100644 index 0000000..7a65c7e --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpBootSemanticHandler.cs @@ -0,0 +1,45 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Conservative semantic validation for dhcp-boot values. +/// Validates optional leading tag and the required filename field. +/// +public sealed class DhcpBootSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DhcpBoot; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',').Select(t => t.Trim()).ToArray(); + + var index = 0; + if (tokens[0].StartsWith("tag:", StringComparison.OrdinalIgnoreCase)) + { + if (!DnsmasqDhcpTagSyntax.IsTagToken(tokens[0])) + return "dhcp-boot tag: value cannot be empty."; + index++; + } + + if (index >= tokens.Length) + return "dhcp-boot must include a boot filename."; + + if (tokens[index].Length == 0) + return "dhcp-boot filename cannot be empty."; + + if (tokens.Length > index + 3) + return "dhcp-boot supports filename[,servername[,server address]]."; + + if (tokens.Length == index + 3 && tokens[index + 2].Length == 0) + return "dhcp-boot server address cannot be empty when the third field is present."; + + return null; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpHostSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpHostSemanticHandler.cs new file mode 100644 index 0000000..b1a6c95 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpHostSemanticHandler.cs @@ -0,0 +1,101 @@ +using System.Net; +using System.Text.RegularExpressions; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Conservative semantic validation for dhcp-host values. +/// Validates obvious token shapes without attempting to fully model every legal dnsmasq variant. +/// +public sealed partial class DhcpHostSemanticHandler : IOptionSemanticHandler +{ + [GeneratedRegex(@"^([0-9A-Fa-f*]{2}:){5}[0-9A-Fa-f*]{2}$", RegexOptions.CultureInvariant)] + private static partial Regex MacPattern(); + + [GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$", RegexOptions.CultureInvariant)] + private static partial Regex HostPattern(); + + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.DhcpHost; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',').Select(t => t.Trim()).ToArray(); + if (tokens.Any(t => t.Length == 0)) + return "dhcp-host contains an empty comma-separated segment."; + + var hasIdentity = false; + foreach (var token in tokens) + { + if (token.Equals("ignore", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("set:", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("tag:", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (token.StartsWith("id:", StringComparison.OrdinalIgnoreCase)) + { + hasIdentity = true; + if (token.Length <= 3) + return "dhcp-host id: segment cannot be empty."; + continue; + } + + if (MacPattern().IsMatch(token)) + { + hasIdentity = true; + continue; + } + + if (IsDhcpHostAddress(token) || IsLeaseToken(token)) + continue; + + if (HostPattern().IsMatch(token)) + { + hasIdentity = true; + continue; + } + + return $"Unrecognized dhcp-host segment '{token}'."; + } + + return hasIdentity + ? null + : "dhcp-host must include a MAC address, id:, or hostname."; + } + + private static bool IsDhcpHostAddress(string value) + { + if (IPAddress.TryParse(value, out _)) + return true; + + if (value.StartsWith("[", StringComparison.Ordinal) && value.EndsWith("]", StringComparison.Ordinal)) + return true; + + return false; + } + + private static bool IsLeaseToken(string value) + { + if (value.Equals("infinite", StringComparison.OrdinalIgnoreCase)) + return true; + + if (value.Length == 0) + return false; + + var suffix = value[^1]; + var number = char.IsLetter(suffix) ? value[..^1] : value; + if (!int.TryParse(number, out _)) + return false; + + return !char.IsLetter(suffix) || suffix is 's' or 'm' or 'h' or 'd' or 'w'; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpIgnoreNamesSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpIgnoreNamesSemanticHandler.cs new file mode 100644 index 0000000..5b70e06 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpIgnoreNamesSemanticHandler.cs @@ -0,0 +1,29 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dhcp-ignore-names values. +/// Accepts no tags (global) or one or more tag clauses. +/// +public sealed class DhcpIgnoreNamesSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DhcpIgnoreNames; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return null; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Any(t => t.Length == 0)) + return "dhcp-ignore-names contains an empty comma-separated segment."; + + return tokens.All(t => DnsmasqDhcpTagSyntax.IsTagToken(t)) + ? null + : "dhcp-ignore-names only supports tag: clauses."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpIgnoreSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpIgnoreSemanticHandler.cs new file mode 100644 index 0000000..9f2da99 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpIgnoreSemanticHandler.cs @@ -0,0 +1,28 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dhcp-ignore values. +/// +public sealed class DhcpIgnoreSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DhcpIgnore; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Length == 0 || tokens.Any(t => t.Length == 0)) + return "dhcp-ignore must contain one or more tag: clauses."; + + return tokens.All(t => DnsmasqDhcpTagSyntax.IsTagToken(t, allowNegation: true)) + ? null + : "dhcp-ignore only supports tag: and tag:! clauses."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpMacSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpMacSemanticHandler.cs new file mode 100644 index 0000000..86aee93 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpMacSemanticHandler.cs @@ -0,0 +1,37 @@ +using System.Text.RegularExpressions; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for dhcp-mac values. +/// Requires a leading set:<tag> and a MAC pattern with wildcard support. +/// +public sealed partial class DhcpMacSemanticHandler : IOptionSemanticHandler +{ + [GeneratedRegex(@"^([0-9A-Fa-f*]{1,2}:){5}[0-9A-Fa-f*]{1,2}$", RegexOptions.CultureInvariant)] + private static partial Regex MacPattern(); + + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.DhcpMac; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Length != 2 || tokens.Any(t => t.Length == 0)) + return "dhcp-mac must be set:,."; + + if (!tokens[0].StartsWith("set:", StringComparison.OrdinalIgnoreCase) || tokens[0].Length <= 4) + return "dhcp-mac must start with set:."; + + return MacPattern().IsMatch(tokens[1]) + ? null + : "dhcp-mac must end with a valid MAC pattern."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpMatchSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpMatchSemanticHandler.cs new file mode 100644 index 0000000..c3db8c9 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpMatchSemanticHandler.cs @@ -0,0 +1,33 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Conservative semantic behavior for dhcp-match values. +/// Requires a leading set:<tag> and a non-empty option selector, with optional match value. +/// +public sealed class DhcpMatchSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.DhcpMatch; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = DnsmasqDhcpOptionSyntax.SplitTokens(s); + if (tokens.Length < 2 || DnsmasqDhcpOptionSyntax.HasEmptyToken(tokens)) + return "dhcp-match must be set:,option-spec[,value]."; + + if (!tokens[0].StartsWith("set:", StringComparison.OrdinalIgnoreCase) || tokens[0].Length <= 4) + return "dhcp-match must start with set:."; + + return DnsmasqDhcpOptionSyntax.IsOptionSelector(tokens[1]) + ? null + : $"Invalid dhcp-match option spec '{tokens[1]}'."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpNameMatchSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpNameMatchSemanticHandler.cs new file mode 100644 index 0000000..d27232f --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpNameMatchSemanticHandler.cs @@ -0,0 +1,36 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dhcp-name-match values. +/// +public sealed class DhcpNameMatchSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DhcpNameMatch; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Length != 2 || tokens.Any(t => t.Length == 0)) + return "dhcp-name-match must be set:,[*]."; + + if (!DnsmasqDhcpTagSyntax.IsSetToken(tokens[0])) + return "dhcp-name-match must start with set:."; + + var pattern = tokens[1]; + var starCount = pattern.Count(c => c == '*'); + if (starCount > 1 || (starCount == 1 && !pattern.EndsWith('*'))) + return "dhcp-name-match allows at most one trailing '*' wildcard."; + + return pattern.TrimEnd('*').Length > 0 + ? null + : "dhcp-name-match name pattern cannot be empty."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpOptionPxeSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpOptionPxeSemanticHandler.cs new file mode 100644 index 0000000..9b88f43 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpOptionPxeSemanticHandler.cs @@ -0,0 +1,40 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dhcp-option-pxe values. +/// This is a narrower PXE-specific form of dhcp-option with a required numeric option selector. +/// +public sealed class DhcpOptionPxeSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DhcpOptionPxe; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = DnsmasqDhcpOptionSyntax.SplitTokens(s); + if (DnsmasqDhcpOptionSyntax.HasEmptyToken(tokens)) + return "dhcp-option-pxe contains an empty comma-separated segment."; + + var index = 0; + while (index < tokens.Length && DnsmasqDhcpOptionSyntax.IsPrefixToken(tokens[index], out var error)) + { + if (error is not null) + return error; + index++; + } + + if (index >= tokens.Length) + return "dhcp-option-pxe must include a numeric option selector after any prefixes."; + + return int.TryParse(tokens[index], out _) + ? null + : $"Invalid dhcp-option-pxe selector '{tokens[index]}'."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpOptionSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpOptionSemanticHandler.cs new file mode 100644 index 0000000..7f09769 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpOptionSemanticHandler.cs @@ -0,0 +1,41 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Conservative semantic validation for dhcp-option and dhcp-option-force values. +/// Validates the presence and basic shape of known prefix tokens and the required option selector. +/// +public sealed class DhcpOptionSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName is DnsmasqConfKeys.DhcpOption or DnsmasqConfKeys.DhcpOptionForce; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = DnsmasqDhcpOptionSyntax.SplitTokens(s); + if (DnsmasqDhcpOptionSyntax.HasEmptyToken(tokens)) + return "dhcp-option contains an empty comma-separated segment."; + + var index = 0; + while (index < tokens.Length && DnsmasqDhcpOptionSyntax.IsPrefixToken(tokens[index], out var error)) + { + if (error is not null) + return error; + index++; + } + + if (index >= tokens.Length) + return "dhcp-option must include an option selector after any prefixes."; + + return DnsmasqDhcpOptionSyntax.IsOptionSelector(tokens[index]) + ? null + : $"Invalid dhcp-option selector '{tokens[index]}'."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpProxySemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpProxySemanticHandler.cs new file mode 100644 index 0000000..14aa593 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpProxySemanticHandler.cs @@ -0,0 +1,30 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for dhcp-proxy values. +/// The UI currently only supports explicit values, so validation focuses on IP-literal lists. +/// +public sealed class DhcpProxySemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.DhcpProxy; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = DnsmasqRelaySyntax.SplitTokens(s); + if (DnsmasqRelaySyntax.HasEmptyToken(tokens)) + return "dhcp-proxy contains an empty comma-separated segment."; + + return tokens.All(DnsmasqRelaySyntax.IsIpLiteral) + ? null + : "dhcp-proxy must contain one or more IP literal addresses."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpRangeSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpRangeSemanticHandler.cs new file mode 100644 index 0000000..4cd835e --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpRangeSemanticHandler.cs @@ -0,0 +1,23 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for dhcp-range values. +/// Uses conservative validation: optional leading tag/set tokens, then a required start address, +/// followed by a required second token which may be an end address or a mode keyword. +/// +public sealed class DhcpRangeSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.DhcpRange; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + return DnsmasqDhcpRangeValueParser.TryParse(value ?? "", out _, out var error) + ? null + : error; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpRelaySemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpRelaySemanticHandler.cs new file mode 100644 index 0000000..803b4e8 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpRelaySemanticHandler.cs @@ -0,0 +1,44 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for dhcp-relay values. +/// +public sealed class DhcpRelaySemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.DhcpRelay; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = DnsmasqRelaySyntax.SplitTokens(s); + if (tokens.Length is < 1 or > 3 || DnsmasqRelaySyntax.HasEmptyToken(tokens)) + return "dhcp-relay must be local-address[,server-address[#port]][,interface]."; + + if (!DnsmasqRelaySyntax.IsIpLiteral(tokens[0])) + return "dhcp-relay must start with a local IP address."; + + if (tokens.Length == 1) + return null; + + if (tokens.Length == 2) + { + return DnsmasqRelaySyntax.IsServerAddress(tokens[1]) || DnsmasqRelaySyntax.IsInterfaceName(tokens[1]) + ? null + : "dhcp-relay second value must be a server IP[#port] or interface name."; + } + + if (!DnsmasqRelaySyntax.IsServerAddress(tokens[1])) + return "dhcp-relay server value must be an IP address with optional #port."; + if (!DnsmasqRelaySyntax.IsInterfaceName(tokens[2])) + return "dhcp-relay interface value must be a valid interface name."; + return null; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpUserclassSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpUserclassSemanticHandler.cs new file mode 100644 index 0000000..8774963 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpUserclassSemanticHandler.cs @@ -0,0 +1,29 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dhcp-userclass values. +/// +public sealed class DhcpUserclassSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DhcpUserclass; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Length < 2 || tokens.Any(t => t.Length == 0)) + return "dhcp-userclass must be set:,."; + + if (!DnsmasqDhcpTagSyntax.IsSetToken(tokens[0], prefixOptional: true)) + return "dhcp-userclass must start with a tag (optionally prefixed by set:)."; + + return null; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpVendorclassSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpVendorclassSemanticHandler.cs new file mode 100644 index 0000000..0ccc694 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpVendorclassSemanticHandler.cs @@ -0,0 +1,41 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dhcp-vendorclass values. +/// +public sealed class DhcpVendorclassSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DhcpVendorclass; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Length < 2 || tokens.Any(t => t.Length == 0)) + return "dhcp-vendorclass must be set:,[enterprise:,]."; + + if (!DnsmasqDhcpTagSyntax.IsSetToken(tokens[0], prefixOptional: true)) + return "dhcp-vendorclass must start with a tag (optionally prefixed by set:)."; + + var index = 1; + if (tokens[index].StartsWith("enterprise:", StringComparison.OrdinalIgnoreCase)) + { + var enterprise = tokens[index]["enterprise:".Length..]; + if (!uint.TryParse(enterprise, out _)) + return "dhcp-vendorclass enterprise: value must be numeric."; + index++; + } + + if (index >= tokens.Length) + return "dhcp-vendorclass must include a vendor-class string."; + + return null; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqDhcpOptionSyntax.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqDhcpOptionSyntax.cs new file mode 100644 index 0000000..9379f70 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqDhcpOptionSyntax.cs @@ -0,0 +1,48 @@ +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Shared token parsing helpers for DHCP option-style values such as dhcp-option, +/// dhcp-option-force, and dhcp-match. +/// +internal static class DnsmasqDhcpOptionSyntax +{ + public static string[] SplitTokens(string raw) => + raw.Split(',').Select(t => t.Trim()).ToArray(); + + public static bool HasEmptyToken(IEnumerable tokens) => + tokens.Any(t => t.Length == 0); + + public static bool IsPrefixToken(string token, out string? error) + { + error = null; + if (token.StartsWith("tag:", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("encap:", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("vi-encap:", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("vendor:", StringComparison.OrdinalIgnoreCase)) + { + var colon = token.IndexOf(':'); + if (colon < 0 || colon == token.Length - 1) + error = $"Prefix token '{token}' cannot be empty after ':'."; + return true; + } + + return false; + } + + public static bool IsOptionSelector(string token) + { + if (int.TryParse(token, out _)) + return true; + + if (token.StartsWith("option:", StringComparison.OrdinalIgnoreCase) && token.Length > "option:".Length) + return true; + + if (token.StartsWith("option6:", StringComparison.OrdinalIgnoreCase) && token.Length > "option6:".Length) + return true; + + if (token.StartsWith("vi-encap:", StringComparison.OrdinalIgnoreCase) && token.Length > "vi-encap:".Length) + return true; + + return false; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqDhcpTagSyntax.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqDhcpTagSyntax.cs new file mode 100644 index 0000000..eb4a24d --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqDhcpTagSyntax.cs @@ -0,0 +1,39 @@ +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Shared helpers for DHCP option families that use set:, tag:, +/// and interface/tag-like tokens. +/// +internal static class DnsmasqDhcpTagSyntax +{ + public static bool IsSetToken(string token, bool prefixOptional = false) + { + if (token.StartsWith("set:", StringComparison.OrdinalIgnoreCase)) + return token.Length > 4; + + return prefixOptional && token.Length > 0; + } + + public static bool IsTagToken(string token, bool allowNegation = false) + { + if (!token.StartsWith("tag:", StringComparison.OrdinalIgnoreCase)) + return false; + + var value = token["tag:".Length..]; + if (value.Length == 0) + return false; + + if (allowNegation && value.StartsWith("!", StringComparison.Ordinal)) + value = value[1..]; + + return value.Length > 0; + } + + public static bool IsTagOrSetToken(string token, bool allowNegation = false) => + IsSetToken(token) || IsTagToken(token, allowNegation); + + public static bool IsInterfaceLike(string token, bool allowWildcard = false) => + token.Length > 0 && + token.Length <= 64 && + token.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' || (allowWildcard && c == '*')); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqIpPrefixSyntax.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqIpPrefixSyntax.cs new file mode 100644 index 0000000..199f7f3 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqIpPrefixSyntax.cs @@ -0,0 +1,27 @@ +using System.Net; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Shared helpers for option values that are an IP literal with an optional prefix length. +/// +internal static class DnsmasqIpPrefixSyntax +{ + public static string? ValidateIpWithOptionalPrefix(string value, string optionName) + { + var parts = value.Split('/', 2); + if (!IPAddress.TryParse(parts[0], out var ip)) + return $"{optionName} must start with a valid IP address."; + + if (parts.Length == 1) + return null; + + if (!int.TryParse(parts[1], out var prefix)) + return $"{optionName} prefix length must be numeric."; + + var maxPrefix = ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128; + return prefix >= 0 && prefix <= maxPrefix + ? null + : $"{optionName} prefix length must be between 0 and {maxPrefix}."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqRelaySyntax.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqRelaySyntax.cs new file mode 100644 index 0000000..75aa8ec --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqRelaySyntax.cs @@ -0,0 +1,34 @@ +using System.Net; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Shared parsing helpers for relay/proxy style values that work primarily with +/// IP literal addresses, optional #port suffixes, and interface names. +/// +internal static class DnsmasqRelaySyntax +{ + public static string[] SplitTokens(string raw) => + raw.Split(',').Select(t => t.Trim()).ToArray(); + + public static bool HasEmptyToken(IEnumerable tokens) => + tokens.Any(t => t.Length == 0); + + public static bool IsIpLiteral(string value) => + IPAddress.TryParse(value, out _); + + public static bool IsServerAddress(string value) + { + var hash = value.LastIndexOf('#'); + var host = hash >= 0 ? value[..hash] : value; + if (!IsIpLiteral(host)) + return false; + return hash < 0 || (int.TryParse(value[(hash + 1)..], out var port) && port is >= 1 and <= 65535); + } + + public static bool IsInterfaceName(string value) => + value.Length > 0 && + value.Length <= 64 && + value.Count(c => c == '.') <= 1 && + value.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.'); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqScopedDomainSyntax.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqScopedDomainSyntax.cs new file mode 100644 index 0000000..215fadd --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqScopedDomainSyntax.cs @@ -0,0 +1,67 @@ +using System.Text.RegularExpressions; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Shared parsing/validation helpers for dnsmasq option values that use the +/// /domain[/domain...]/tail syntax shared by server/local/address/ipset/nftset. +/// +internal static partial class DnsmasqScopedDomainSyntax +{ + [GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$", RegexOptions.CultureInvariant)] + private static partial Regex DomainPattern(); + + public static bool TrySplitScopedValue(string value, out string[] domains, out string tail, out string? error) + { + domains = Array.Empty(); + tail = ""; + error = null; + + if (!value.StartsWith("/", StringComparison.Ordinal)) + { + error = "Value must start with '/'."; + return false; + } + + var lastSlash = value.LastIndexOf('/'); + if (lastSlash <= 0) + { + error = "Value must use /domain[/domain...]/... syntax."; + return false; + } + + domains = value[1..lastSlash].Split('/'); + tail = value[(lastSlash + 1)..]; + return true; + } + + public static string? ValidateDomainPatterns(IEnumerable domains, bool allowUnqualifiedMarker = true, bool allowHash = true) + { + var list = domains.ToArray(); + if (list.Length == 0) + return "Value must include at least one domain pattern."; + + if (allowUnqualifiedMarker && list.Length == 1 && list[0].Length == 0) + return null; // "//" means unqualified names only + + foreach (var domain in list) + { + if (string.IsNullOrWhiteSpace(domain)) + return "Value contains an empty domain pattern."; + + if (allowHash && domain == "#") + continue; + + var normalized = domain; + if (normalized.StartsWith("*", StringComparison.Ordinal)) + normalized = normalized[1..]; + if (normalized.StartsWith(".", StringComparison.Ordinal)) + normalized = normalized[1..]; + + if (normalized.Length == 0 || normalized.Length > 253 || !DomainPattern().IsMatch(normalized)) + return $"Invalid domain pattern '{domain}'."; + } + + return null; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IgnoreAddressSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IgnoreAddressSemanticHandler.cs new file mode 100644 index 0000000..1fdd8e7 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IgnoreAddressSemanticHandler.cs @@ -0,0 +1,24 @@ +using System.Net; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for ignore-address values. +/// Accepts an IP literal with an optional numeric prefix length. +/// +public sealed class IgnoreAddressSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.IgnoreAddress; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + return DnsmasqIpPrefixSyntax.ValidateIpWithOptionalPrefix(s, "ignore-address"); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IpsetSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IpsetSemanticHandler.cs new file mode 100644 index 0000000..fa4bf01 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IpsetSemanticHandler.cs @@ -0,0 +1,37 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for ipset values. +/// Validates scoped domain syntax followed by one or more ipset names. +/// +public sealed class IpsetSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.Ipset; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domains, out var setList, out var error)) + return error == "Value must start with '/'." + ? "ipset must start with '/'." + : "ipset must use /domain[/domain...]/set[,set...] syntax."; + + error = DnsmasqScopedDomainSyntax.ValidateDomainPatterns(domains); + if (error is not null) + return error.Replace("Value", "ipset", StringComparison.Ordinal); + + var sets = setList.Split(',').Select(t => t.Trim()).ToArray(); + if (sets.Length == 0 || sets.Any(string.IsNullOrWhiteSpace)) + return "ipset must include at least one non-empty set name."; + + return null; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/LocalSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/LocalSemanticHandler.cs new file mode 100644 index 0000000..206eacd --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/LocalSemanticHandler.cs @@ -0,0 +1,34 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for local values. +/// This is the local-only form of server domain matching, so values must use scoped +/// /domain[/domain...]/ syntax or // for unqualified names. +/// +public sealed class LocalSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.Local; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domains, out var tail, out var error)) + return error == "Value must start with '/'." + ? "Local must start with '/'." + : "Local must use /domain[/domain...]/ syntax."; + + if (tail.Length != 0) + return "Local must end with a trailing '/' and not include an upstream server."; + + error = DnsmasqScopedDomainSyntax.ValidateDomainPatterns(domains); + return error?.Replace("Value", "Local", StringComparison.Ordinal); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/NftsetSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/NftsetSemanticHandler.cs new file mode 100644 index 0000000..32ef74c --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/NftsetSemanticHandler.cs @@ -0,0 +1,55 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for nftset values. +/// Validates scoped domain syntax followed by one or more nftables set specifications. +/// +public sealed class NftsetSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.Nftset; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domains, out var specList, out var error)) + return error == "Value must start with '/'." + ? "nftset must start with '/'." + : "nftset must use /domain[/domain...]/set-spec[,set-spec...] syntax."; + + error = DnsmasqScopedDomainSyntax.ValidateDomainPatterns(domains); + if (error is not null) + return error.Replace("Value", "nftset", StringComparison.Ordinal); + + var specs = specList.Split(',').Select(t => t.Trim()).ToArray(); + if (specs.Length == 0 || specs.Any(string.IsNullOrWhiteSpace)) + return "nftset must include at least one non-empty set specification."; + + foreach (var spec in specs) + { + if (!IsValidSetSpec(spec)) + return $"Invalid nftset specification '{spec}'."; + } + + return null; + } + + private static bool IsValidSetSpec(string spec) + { + var parts = spec.Split('#'); + if (parts.Any(p => p.Length == 0)) + return false; + + if (parts[0] is "4" or "6") + return parts.Length is 3 or 4; + + return parts.Length == 3; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/PxeServiceSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/PxeServiceSemanticHandler.cs new file mode 100644 index 0000000..938aaea --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/PxeServiceSemanticHandler.cs @@ -0,0 +1,62 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Conservative semantic behavior for pxe-service values. +/// Validates optional leading tag and the required CSA + menu text fields. +/// +public sealed class PxeServiceSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.PxeService; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Length < 2 || tokens.Any(t => t.Length == 0)) + return "pxe-service must include a CSA and menu text."; + + var index = 0; + if (tokens[0].StartsWith("tag:", StringComparison.OrdinalIgnoreCase)) + { + if (tokens[0].Length <= 4) + return "pxe-service tag: value cannot be empty."; + index++; + } + + if (index >= tokens.Length) + return "pxe-service must include a client system architecture value."; + + if (!IsCsa(tokens[index])) + return $"Invalid pxe-service CSA '{tokens[index]}'."; + + index++; + if (index >= tokens.Length) + return "pxe-service must include menu text."; + + return null; + } + + private static bool IsCsa(string value) => + int.TryParse(value, out _) || + value is + "x86PC" or + "PC98" or + "IA64_EFI" or + "Alpha" or + "Arc_x86" or + "Intel_Lean_Client" or + "IA32_EFI" or + "x86-64_EFI" or + "Xscale_EFI" or + "BC_EFI" or + "ARM32_EFI" or + "ARM64_EFI"; +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RaParamSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RaParamSemanticHandler.cs new file mode 100644 index 0000000..c310911 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RaParamSemanticHandler.cs @@ -0,0 +1,74 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for ra-param values. +/// Uses conservative validation for interface, mtu/priority, interval, and optional lifetime fields. +/// +public sealed class RaParamSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.RaParam; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',').Select(t => t.Trim()).ToArray(); + if (tokens.Length == 0 || tokens.Any(t => t.Length == 0)) + return "ra-param contains an empty comma-separated segment."; + + if (!IsInterfaceName(tokens[0])) + return "ra-param must start with an interface name."; + + var seenPriority = false; + var seenMtu = false; + var numericCount = 0; + for (var i = 1; i < tokens.Length; i++) + { + var token = tokens[i]; + if (token is "high" or "low") + { + if (seenPriority) + return "ra-param can only include one priority token."; + seenPriority = true; + continue; + } + + if (token.Equals("off", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("mtu:", StringComparison.OrdinalIgnoreCase)) + { + if (seenMtu) + return "ra-param can only include one mtu token."; + seenMtu = true; + if (token.StartsWith("mtu:", StringComparison.OrdinalIgnoreCase)) + { + var mtuValue = token["mtu:".Length..]; + if (mtuValue.Length == 0) + return "ra-param mtu: value cannot be empty."; + } + continue; + } + + if (int.TryParse(token, out _)) + { + numericCount++; + if (numericCount > 2) + return "ra-param can include at most interval and lifetime numeric values."; + continue; + } + + return $"Invalid ra-param segment '{token}'."; + } + + return null; + } + + private static bool IsInterfaceName(string value) => + value.Length > 0 && value.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' or '*'); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RebindDomainOkSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RebindDomainOkSemanticHandler.cs new file mode 100644 index 0000000..31dcf00 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RebindDomainOkSemanticHandler.cs @@ -0,0 +1,34 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for rebind-domain-ok values. +/// Accepts either a single domain or the scoped /domain/domain/ syntax. +/// +public sealed class RebindDomainOkSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.RebindDomainOk; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + if (s.StartsWith("/", StringComparison.Ordinal)) + { + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domains, out var tail, out var error)) + return "rebind-domain-ok must use /domain[/domain...]/ syntax."; + if (tail.Length != 0) + return "rebind-domain-ok must not include a value after the final '/'."; + error = DnsmasqScopedDomainSyntax.ValidateDomainPatterns(domains, allowUnqualifiedMarker: false, allowHash: false); + return error?.Replace("Value", "rebind-domain-ok", StringComparison.Ordinal); + } + + return DnsmasqScopedDomainSyntax.ValidateDomainPatterns([s], allowUnqualifiedMarker: false, allowHash: false) + ?.Replace("Value", "rebind-domain-ok", StringComparison.Ordinal); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerSemanticHandler.cs index a02c84d..6904965 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerSemanticHandler.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerSemanticHandler.cs @@ -15,9 +15,6 @@ public sealed partial class ServerSemanticHandler : IOptionSemanticHandler [GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$", RegexOptions.CultureInvariant)] private static partial Regex HostnamePattern(); - [GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$", RegexOptions.CultureInvariant)] - private static partial Regex DomainPattern(); - public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.Server; @@ -36,27 +33,13 @@ public sealed partial class ServerSemanticHandler : IOptionSemanticHandler private static string? ValidateScopedServer(string value) { - var lastSlash = value.LastIndexOf('/'); - if (lastSlash <= 0) + if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(value, out var domains, out var targetPart, out var error)) return "Scoped server must use /domain/.../server syntax, for example /example.local/192.168.1.1."; - var domainPart = value[1..lastSlash]; - var targetPart = value[(lastSlash + 1)..]; - - var domains = domainPart.Split('/'); - if (domains.Length == 0) - return "Scoped server must include at least one domain pattern."; - - if (!(domains.Length == 1 && domains[0].Length == 0)) - { - foreach (var domain in domains) - { - if (string.IsNullOrWhiteSpace(domain)) - return "Scoped server contains an empty domain pattern."; - if (!IsValidDomainPattern(domain)) - return $"Invalid domain pattern '{domain}'. Use server=/domain/server and keep domain labels to letters, digits, '-', '.', or a leading '*'."; - } - } + error = DnsmasqScopedDomainSyntax.ValidateDomainPatterns(domains); + if (error is not null) + return error.Replace("Value", "Scoped server", StringComparison.Ordinal) + + " Use server=/domain/server and keep domain labels to letters, digits, '-', '.', or a leading '*'."; if (targetPart.Length == 0) return null; // local-only form @@ -118,19 +101,6 @@ public sealed partial class ServerSemanticHandler : IOptionSemanticHandler return value.Length <= 253 && HostnamePattern().IsMatch(value); } - private static bool IsValidDomainPattern(string value) - { - var normalized = value; - if (normalized.StartsWith("*", StringComparison.Ordinal)) - normalized = normalized[1..]; - if (normalized.StartsWith(".", StringComparison.Ordinal)) - normalized = normalized[1..]; - - return normalized.Length > 0 && - normalized.Length <= 253 && - DomainPattern().IsMatch(normalized); - } - private static bool IsValidInterfaceName(string value) => value.Length > 0 && value.Length <= 64 && diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SharedNetworkSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SharedNetworkSemanticHandler.cs new file mode 100644 index 0000000..a290e0e --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SharedNetworkSemanticHandler.cs @@ -0,0 +1,28 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Conservative semantic validation for shared-network values. +/// +public sealed class SharedNetworkSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.SharedNetwork; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Length < 2 || tokens.Any(t => t.Length == 0)) + return "shared-network must include an interface/name and at least one additional value."; + + return DnsmasqDhcpTagSyntax.IsInterfaceLike(tokens[0]) + ? null + : "shared-network must start with an interface or shared-network name."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SlaacSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SlaacSemanticHandler.cs new file mode 100644 index 0000000..d5c5cfa --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SlaacSemanticHandler.cs @@ -0,0 +1,52 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; +using System.Net; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Conservative semantic validation for slaac values. +/// Accepts interface-like tokens, IPv6 literals, and documented SLAAC mode keywords. +/// +public sealed class SlaacSemanticHandler : IOptionSemanticHandler +{ + private static readonly HashSet ModeKeywords = new(StringComparer.OrdinalIgnoreCase) + { + "ra-only", + "slaac", + "ra-names", + "ra-stateless", + "ra-advrouter", + "off-link", + }; + + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.Slaac; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Any(t => t.Length == 0)) + return "slaac contains an empty comma-separated segment."; + + foreach (var token in tokens) + { + if (ModeKeywords.Contains(token)) + continue; + if (DnsmasqDhcpTagSyntax.IsInterfaceLike(token, allowWildcard: true)) + continue; + if (IPAddress.TryParse(token, out _)) + continue; + if (token.StartsWith("[", StringComparison.Ordinal) && token.EndsWith("]", StringComparison.Ordinal)) + continue; + + return $"Invalid slaac segment '{token}'."; + } + + return null; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/TagIfSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/TagIfSemanticHandler.cs new file mode 100644 index 0000000..76ea251 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/TagIfSemanticHandler.cs @@ -0,0 +1,41 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for tag-if values. +/// +public sealed class TagIfSemanticHandler : IOptionSemanticHandler +{ + public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.TagIf; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',', StringSplitOptions.TrimEntries); + if (tokens.Length == 0 || tokens.Any(t => t.Length == 0)) + return "tag-if must contain set: and optional tag: clauses."; + + var hasSet = false; + foreach (var token in tokens) + { + if (DnsmasqDhcpTagSyntax.IsSetToken(token)) + { + hasSet = true; + continue; + } + + if (!DnsmasqDhcpTagSyntax.IsTagToken(token, allowNegation: true)) + return $"Invalid tag-if segment '{token}'."; + } + + return hasSet + ? null + : "tag-if must contain at least one set: clause."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/TrustAnchorSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/TrustAnchorSemanticHandler.cs new file mode 100644 index 0000000..859d0dc --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/TrustAnchorSemanticHandler.cs @@ -0,0 +1,73 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for trust-anchor values. +/// Supports negative trust anchors (domain[,class]) and DS-record forms. +/// +public sealed class TrustAnchorSemanticHandler : IOptionSemanticHandler +{ + private static readonly HashSet AllowedClasses = new(StringComparer.OrdinalIgnoreCase) + { + "IN", + "CH", + "HS", + }; + + public bool CanHandle(string optionName) => + optionName == DnsmasqConfKeys.TrustAnchor; + + public string? ValidateSingle(object? value) => null; + + public string? ValidateMultiItem(string? value) + { + var s = value?.Trim() ?? ""; + if (s.Length == 0) + return "Value cannot be empty."; + + var tokens = s.Split(',').Select(t => t.Trim()).ToArray(); + if (tokens.Any(t => t.Length == 0)) + return "trust-anchor contains an empty comma-separated segment."; + + if (!IsValidAnchorDomain(tokens[0])) + return "trust-anchor must start with a valid domain name or '.'."; + + return tokens.Length switch + { + 1 => null, + 2 => IsValidClass(tokens[1]) ? null : "trust-anchor class must be IN, CH, HS, or a numeric DNS class.", + 5 => ValidateDsTuple(tokens, hasClass: false), + 6 => IsValidClass(tokens[1]) + ? ValidateDsTuple(tokens, hasClass: true) + : "trust-anchor class must be IN, CH, HS, or a numeric DNS class.", + _ => "trust-anchor must be domain[,class] or domain[,class],key-tag,algorithm,digest-type,digest.", + }; + } + + private static string? ValidateDsTuple(string[] tokens, bool hasClass) + { + var offset = hasClass ? 2 : 1; + if (!ushort.TryParse(tokens[offset], out _)) + return "trust-anchor key-tag must be numeric."; + if (!byte.TryParse(tokens[offset + 1], out _)) + return "trust-anchor algorithm must be numeric."; + if (!byte.TryParse(tokens[offset + 2], out _)) + return "trust-anchor digest-type must be numeric."; + return tokens[offset + 3].Length > 0 + ? null + : "trust-anchor digest cannot be empty."; + } + + private static bool IsValidClass(string value) => + AllowedClasses.Contains(value) || ushort.TryParse(value, out _); + + private static bool IsValidAnchorDomain(string value) + { + if (value == ".") + return true; + + var normalized = value.EndsWith(".", StringComparison.Ordinal) ? value[..^1] : value; + return DnsmasqScopedDomainSyntax.ValidateDomainPatterns([normalized], allowUnqualifiedMarker: false, allowHash: false) is null; + } +}