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