Refactor: option semantics to metadata + handlers, shared syntax, registry/save single path

- OptionSemantics: metadata only (no validator delegates); OptionValidationKind + PathPolicy
- IOptionSemanticHandler: handler-first validation; handlers registered via IApplicationMultiSingleton
- OptionSemanticValidator: single engine, handler dispatch then generic kind-based validation
- Shared syntax: DnsmasqScopedDomainSyntax, DnsmasqDhcpRangeValueParser, DnsmasqDhcpOptionSyntax,
  DnsmasqRelaySyntax, DnsmasqDhcpTagSyntax, DnsmasqIpPrefixSyntax for reuse across handlers
- New handlers: Server, Local, RevServer, Address, Alias, Ipset, Nftset, DhcpRange, DhcpHost,
  DhcpOption, DhcpRelay, DhcpProxy, IgnoreAddress, ConnmarkAllowlist, DhcpMatch, DhcpMac,
  PxeService, TrustAnchor, RebindDomainOk, BogusNxdomain, DhcpIgnoreNames, DhcpBoot, Slaac,
  BridgeInterface, SharedNetwork, DhcpOptionPxe, DhcpNameMatch, DhcpIgnore, DhcpVendorclass, DhcpUserclass, TagIf
- Registry and pre-save validation use engine only; placeholders via DnsmasqOptionPlaceholders
- DnsmasqConfigSetService uses DnsmasqDhcpRangeValueParser; DhcpRangeValueParserTests added
This commit is contained in:
2026-03-10 23:44:51 +10:00
parent 1bf33f1a9b
commit 36a160dfc7
42 changed files with 2217 additions and 126 deletions
@@ -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);
}
}
@@ -12,8 +12,37 @@ public class OptionSemanticValidatorTests
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([ private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
new LeasequerySemanticHandler(), new LeasequerySemanticHandler(),
new ServerSemanticHandler(), new ServerSemanticHandler(),
new LocalSemanticHandler(),
new RevServerSemanticHandler(), new RevServerSemanticHandler(),
new AddressSemanticHandler(), 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] [Fact]
@@ -98,7 +127,7 @@ public class OptionSemanticValidatorTests
[InlineData("/example.local/#", true)] [InlineData("/example.local/#", true)]
[InlineData("/example.local/", true)] [InlineData("/example.local/", true)]
[InlineData("example.local/192.168.1.10", false)] [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)] [InlineData("/example.local/not-an-ip", false)]
public void ValidateMultiItem_Address_UsesHandler(string value, bool valid) public void ValidateMultiItem_Address_UsesHandler(string value, bool valid)
{ {
@@ -107,6 +136,360 @@ public class OptionSemanticValidatorTests
Assert.Equal(valid, err is null); 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] [Fact]
public void ValidateSingle_UseStaleCache_UsesEngineRule() public void ValidateSingle_UseStaleCache_UsesEngineRule()
{ {
@@ -0,0 +1,124 @@
using System.Net;
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// <summary>
/// Shared structural parser for <c>dhcp-range</c> option values.
/// This is intentionally conservative and captures the core structure needed by
/// validation and simple range extraction without re-implementing all dnsmasq semantics.
/// </summary>
public sealed record ParsedDhcpRange(
IReadOnlyList<string> Tags,
string StartToken,
string SecondToken,
IReadOnlyList<string> RemainingTokens);
/// <summary>
/// Parser for a single <c>dhcp-range</c> value.
/// </summary>
public static class DnsmasqDhcpRangeValueParser
{
private static readonly HashSet<string> 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<string>();
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:<interface> 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 _);
}
@@ -96,6 +96,11 @@ public static class EffectiveConfigSpecialOptionSemantics
EffectiveConfigParserBehavior.Multi, EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue, EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti), ComplexMulti),
[DnsmasqConfKeys.Local] = new OptionSemantics(
DnsmasqConfKeys.Local,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.RevServer] = new OptionSemantics( [DnsmasqConfKeys.RevServer] = new OptionSemantics(
DnsmasqConfKeys.RevServer, DnsmasqConfKeys.RevServer,
EffectiveConfigParserBehavior.Multi, EffectiveConfigParserBehavior.Multi,
@@ -106,6 +111,36 @@ public static class EffectiveConfigSpecialOptionSemantics
EffectiveConfigParserBehavior.Multi, EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue, EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti), 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] = new OptionSemantics(
DnsmasqConfKeys.ListenAddress, DnsmasqConfKeys.ListenAddress,
EffectiveConfigParserBehavior.Multi, EffectiveConfigParserBehavior.Multi,
@@ -176,6 +211,61 @@ public static class EffectiveConfigSpecialOptionSemantics
EffectiveConfigParserBehavior.LastWins, EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.SingleValue, EffectiveConfigWriteBehavior.SingleValue,
PathFileSingleMustExist), 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] = new OptionSemantics(
DnsmasqConfKeys.DhcpHostsfile, DnsmasqConfKeys.DhcpHostsfile,
EffectiveConfigParserBehavior.Multi, EffectiveConfigParserBehavior.Multi,
@@ -196,6 +286,66 @@ public static class EffectiveConfigSpecialOptionSemantics
EffectiveConfigParserBehavior.Multi, EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue, EffectiveConfigWriteBehavior.MultiValue,
PathDirectoryMulti), 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),
}; };
/// <summary>Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints.</summary> /// <summary>Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints.</summary>
@@ -1,6 +1,7 @@
using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Contracts; using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config; namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
@@ -56,22 +57,7 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
/// <summary>Parses dhcp-range value to (startIp, endIp). Format is typically start,end,mask,lease or tag:...,start,end,...; finds first two IPv4-looking tokens.</summary> /// <summary>Parses dhcp-range value to (startIp, endIp). Format is typically start,end,mask,lease or tag:...,start,end,...; finds first two IPv4-looking tokens.</summary>
internal static (string? Start, string? End) ParseDhcpRangeStartEnd(string? raw) internal static (string? Start, string? End) ParseDhcpRangeStartEnd(string? raw)
{ => DnsmasqDhcpRangeValueParser.GetIPv4StartEnd(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);
}
private ConfigSetSnapshot GetSnapshot() => private ConfigSetSnapshot GetSnapshot() =>
_cache.GetSnapshotAsync(CancellationToken.None).GetAwaiter().GetResult(); _cache.GetSnapshotAsync(CancellationToken.None).GetAwaiter().GetResult();
@@ -192,28 +192,47 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts); RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts);
RegisterSemanticMultis( RegisterSemanticMultis(
EffectiveConfigFieldBuilder.SectionResolver, EffectiveConfigFieldBuilder.SectionResolver,
DnsmasqConfKeys.Local,
DnsmasqConfKeys.ResolvFile, DnsmasqConfKeys.ResolvFile,
DnsmasqConfKeys.RevServer, DnsmasqConfKeys.RevServer,
DnsmasqConfKeys.Address); DnsmasqConfKeys.Address,
RegisterSemanticMultis(
EffectiveConfigFieldBuilder.SectionDhcp,
DnsmasqConfKeys.DhcpHostsfile,
DnsmasqConfKeys.DhcpOptsfile,
DnsmasqConfKeys.DhcpHostsdir,
DnsmasqConfKeys.DhcpOptsdir,
DnsmasqConfKeys.Leasequery);
RegisterMultis(
EffectiveConfigFieldBuilder.SectionResolver,
DnsmasqConfKeys.Local,
DnsmasqConfKeys.RebindDomainOk, DnsmasqConfKeys.RebindDomainOk,
DnsmasqConfKeys.BogusNxdomain, DnsmasqConfKeys.BogusNxdomain,
DnsmasqConfKeys.IgnoreAddress, DnsmasqConfKeys.IgnoreAddress,
DnsmasqConfKeys.Alias, DnsmasqConfKeys.Alias,
DnsmasqConfKeys.FilterRr,
DnsmasqConfKeys.Ipset, DnsmasqConfKeys.Ipset,
DnsmasqConfKeys.Nftset, DnsmasqConfKeys.Nftset,
DnsmasqConfKeys.ConnmarkAllowlist); 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( RegisterMultis(
EffectiveConfigFieldBuilder.SectionDnsRecords, EffectiveConfigFieldBuilder.SectionDnsRecords,
DnsmasqConfKeys.Domain, DnsmasqConfKeys.Domain,
@@ -235,33 +254,16 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
DnsmasqConfKeys.AuthPeer); DnsmasqConfKeys.AuthPeer);
RegisterMultis( RegisterMultis(
EffectiveConfigFieldBuilder.SectionDhcp, EffectiveConfigFieldBuilder.SectionDhcp,
DnsmasqConfKeys.DhcpRange,
DnsmasqConfKeys.DhcpHost,
DnsmasqConfKeys.DhcpOption,
DnsmasqConfKeys.DhcpOptionForce,
DnsmasqConfKeys.DhcpMatch,
DnsmasqConfKeys.DhcpMac,
DnsmasqConfKeys.DhcpNameMatch,
DnsmasqConfKeys.DhcpIgnoreNames,
DnsmasqConfKeys.DhcpRelay,
DnsmasqConfKeys.DhcpCircuitid, DnsmasqConfKeys.DhcpCircuitid,
DnsmasqConfKeys.DhcpRemoteid, DnsmasqConfKeys.DhcpRemoteid,
DnsmasqConfKeys.DhcpSubscrid, DnsmasqConfKeys.DhcpSubscrid);
DnsmasqConfKeys.DhcpProxy, RegisterSemanticMultis(
DnsmasqConfKeys.TagIf, EffectiveConfigFieldBuilder.SectionTftpPxe,
DnsmasqConfKeys.BridgeInterface, DnsmasqConfKeys.PxeService);
DnsmasqConfKeys.SharedNetwork, RegisterSemanticMultis(
DnsmasqConfKeys.DhcpBoot,
DnsmasqConfKeys.DhcpIgnore,
DnsmasqConfKeys.DhcpVendorclass,
DnsmasqConfKeys.DhcpUserclass,
DnsmasqConfKeys.RaParam,
DnsmasqConfKeys.Slaac);
RegisterMultis(
EffectiveConfigFieldBuilder.SectionTftpPxe, EffectiveConfigFieldBuilder.SectionTftpPxe,
DnsmasqConfKeys.PxeService,
DnsmasqConfKeys.DhcpOptionPxe); DnsmasqConfKeys.DhcpOptionPxe);
RegisterMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor); RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor);
RegisterMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr); RegisterMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr);
RegisterMultis( RegisterMultis(
EffectiveConfigFieldBuilder.SectionProcess, EffectiveConfigFieldBuilder.SectionProcess,
@@ -1,6 +1,4 @@
using System.Net; using System.Net;
using System.Linq;
using System.Text.RegularExpressions;
using DnsmasqWebUI.Infrastructure.Helpers.Config; using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
@@ -9,11 +7,8 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// Specialized semantic behavior for <c>address</c> values. /// Specialized semantic behavior for <c>address</c> values.
/// Validates the <c>/domain[/domain...]/ip</c> structure and accepts empty or <c>#</c> address forms. /// Validates the <c>/domain[/domain...]/ip</c> structure and accepts empty or <c>#</c> address forms.
/// </summary> /// </summary>
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) => public bool CanHandle(string optionName) =>
optionName == DnsmasqConfKeys.Address; optionName == DnsmasqConfKeys.Address;
@@ -25,24 +20,16 @@ public sealed partial class AddressSemanticHandler : IOptionSemanticHandler
if (s.Length == 0) if (s.Length == 0)
return "Value cannot be empty."; return "Value cannot be empty.";
if (!s.StartsWith("/", StringComparison.Ordinal)) if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domainParts, out var addressPart, out var error))
return "Address must start with '/'."; return error == "Value must start with '/'."
? "Address must start with '/'."
: "Address must use /domain[/domain...]/ip syntax.";
var parts = s.Split('/'); error = DnsmasqScopedDomainSyntax.ValidateDomainPatterns(domainParts);
if (parts.Length < 3) if (error is not null)
return "Address must use /domain[/domain...]/ip syntax."; return error.Replace("Value", "Address", StringComparison.Ordinal);
var domainParts = parts.Skip(1).Take(parts.Length - 2).ToArray(); addressPart = addressPart.Trim();
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();
if (addressPart.Length == 0 || addressPart == "#") if (addressPart.Length == 0 || addressPart == "#")
return null; return null;
@@ -50,20 +37,4 @@ public sealed partial class AddressSemanticHandler : IOptionSemanticHandler
? null ? null
: "Address target must be empty, '#', or a valid IP address."; : "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);
}
} }
@@ -0,0 +1,49 @@
using System.Net;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>alias</c> values.
/// Supports IPv4 single-address or IPv4 range mapping forms.
/// </summary>
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;
}
@@ -0,0 +1,21 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>bogus-nxdomain</c> values.
/// </summary>
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");
}
}
@@ -0,0 +1,35 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>bridge-interface</c> values.
/// </summary>
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;
}
}
@@ -0,0 +1,72 @@
using System.Text.RegularExpressions;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>connmark-allowlist</c> values.
/// Validates the connmark[/mask] prefix and one or more domain-style patterns or '*' disable form.
/// </summary>
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);
}
}
@@ -0,0 +1,45 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Conservative semantic validation for <c>dhcp-boot</c> values.
/// Validates optional leading tag and the required filename field.
/// </summary>
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;
}
}
@@ -0,0 +1,101 @@
using System.Net;
using System.Text.RegularExpressions;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Conservative semantic validation for <c>dhcp-host</c> values.
/// Validates obvious token shapes without attempting to fully model every legal dnsmasq variant.
/// </summary>
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:<client-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';
}
}
@@ -0,0 +1,29 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dhcp-ignore-names</c> values.
/// Accepts no tags (global) or one or more tag clauses.
/// </summary>
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:<tag> clauses.";
}
}
@@ -0,0 +1,28 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dhcp-ignore</c> values.
/// </summary>
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:<tag> clauses.";
return tokens.All(t => DnsmasqDhcpTagSyntax.IsTagToken(t, allowNegation: true))
? null
: "dhcp-ignore only supports tag:<tag> and tag:!<tag> clauses.";
}
}
@@ -0,0 +1,37 @@
using System.Text.RegularExpressions;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>dhcp-mac</c> values.
/// Requires a leading set:&lt;tag&gt; and a MAC pattern with wildcard support.
/// </summary>
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:<tag>,<MAC pattern>.";
if (!tokens[0].StartsWith("set:", StringComparison.OrdinalIgnoreCase) || tokens[0].Length <= 4)
return "dhcp-mac must start with set:<tag>.";
return MacPattern().IsMatch(tokens[1])
? null
: "dhcp-mac must end with a valid MAC pattern.";
}
}
@@ -0,0 +1,33 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Conservative semantic behavior for <c>dhcp-match</c> values.
/// Requires a leading set:&lt;tag&gt; and a non-empty option selector, with optional match value.
/// </summary>
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:<tag>,option-spec[,value].";
if (!tokens[0].StartsWith("set:", StringComparison.OrdinalIgnoreCase) || tokens[0].Length <= 4)
return "dhcp-match must start with set:<tag>.";
return DnsmasqDhcpOptionSyntax.IsOptionSelector(tokens[1])
? null
: $"Invalid dhcp-match option spec '{tokens[1]}'.";
}
}
@@ -0,0 +1,36 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dhcp-name-match</c> values.
/// </summary>
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:<tag>,<name>[*].";
if (!DnsmasqDhcpTagSyntax.IsSetToken(tokens[0]))
return "dhcp-name-match must start with set:<tag>.";
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.";
}
}
@@ -0,0 +1,40 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dhcp-option-pxe</c> values.
/// This is a narrower PXE-specific form of dhcp-option with a required numeric option selector.
/// </summary>
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]}'.";
}
}
@@ -0,0 +1,41 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Conservative semantic validation for <c>dhcp-option</c> and <c>dhcp-option-force</c> values.
/// Validates the presence and basic shape of known prefix tokens and the required option selector.
/// </summary>
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]}'.";
}
}
@@ -0,0 +1,30 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>dhcp-proxy</c> values.
/// The UI currently only supports explicit values, so validation focuses on IP-literal lists.
/// </summary>
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.";
}
}
@@ -0,0 +1,23 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>dhcp-range</c> 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.
/// </summary>
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;
}
}
@@ -0,0 +1,44 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>dhcp-relay</c> values.
/// </summary>
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;
}
}
@@ -0,0 +1,29 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dhcp-userclass</c> values.
/// </summary>
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:<tag>,<user-class>.";
if (!DnsmasqDhcpTagSyntax.IsSetToken(tokens[0], prefixOptional: true))
return "dhcp-userclass must start with a tag (optionally prefixed by set:).";
return null;
}
}
@@ -0,0 +1,41 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dhcp-vendorclass</c> values.
/// </summary>
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:<tag>,[enterprise:<num>,]<vendor-class>.";
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;
}
}
@@ -0,0 +1,48 @@
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Shared token parsing helpers for DHCP option-style values such as <c>dhcp-option</c>,
/// <c>dhcp-option-force</c>, and <c>dhcp-match</c>.
/// </summary>
internal static class DnsmasqDhcpOptionSyntax
{
public static string[] SplitTokens(string raw) =>
raw.Split(',').Select(t => t.Trim()).ToArray();
public static bool HasEmptyToken(IEnumerable<string> 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;
}
}
@@ -0,0 +1,39 @@
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Shared helpers for DHCP option families that use <c>set:</c>, <c>tag:</c>,
/// and interface/tag-like tokens.
/// </summary>
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 == '*'));
}
@@ -0,0 +1,27 @@
using System.Net;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Shared helpers for option values that are an IP literal with an optional prefix length.
/// </summary>
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}.";
}
}
@@ -0,0 +1,34 @@
using System.Net;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Shared parsing helpers for relay/proxy style values that work primarily with
/// IP literal addresses, optional <c>#port</c> suffixes, and interface names.
/// </summary>
internal static class DnsmasqRelaySyntax
{
public static string[] SplitTokens(string raw) =>
raw.Split(',').Select(t => t.Trim()).ToArray();
public static bool HasEmptyToken(IEnumerable<string> 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 '.');
}
@@ -0,0 +1,67 @@
using System.Text.RegularExpressions;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Shared parsing/validation helpers for dnsmasq option values that use the
/// <c>/domain[/domain...]/tail</c> syntax shared by server/local/address/ipset/nftset.
/// </summary>
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<string>();
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<string> 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;
}
}
@@ -0,0 +1,24 @@
using System.Net;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>ignore-address</c> values.
/// Accepts an IP literal with an optional numeric prefix length.
/// </summary>
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");
}
}
@@ -0,0 +1,37 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>ipset</c> values.
/// Validates scoped domain syntax followed by one or more ipset names.
/// </summary>
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;
}
}
@@ -0,0 +1,34 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>local</c> values.
/// This is the local-only form of server domain matching, so values must use scoped
/// <c>/domain[/domain...]/</c> syntax or <c>//</c> for unqualified names.
/// </summary>
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);
}
}
@@ -0,0 +1,55 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>nftset</c> values.
/// Validates scoped domain syntax followed by one or more nftables set specifications.
/// </summary>
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;
}
}
@@ -0,0 +1,62 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Conservative semantic behavior for <c>pxe-service</c> values.
/// Validates optional leading tag and the required CSA + menu text fields.
/// </summary>
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";
}
@@ -0,0 +1,74 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>ra-param</c> values.
/// Uses conservative validation for interface, mtu/priority, interval, and optional lifetime fields.
/// </summary>
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 '*');
}
@@ -0,0 +1,34 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>rebind-domain-ok</c> values.
/// Accepts either a single domain or the scoped /domain/domain/ syntax.
/// </summary>
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);
}
}
@@ -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)] [GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$", RegexOptions.CultureInvariant)]
private static partial Regex HostnamePattern(); 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) => public bool CanHandle(string optionName) =>
optionName == DnsmasqConfKeys.Server; optionName == DnsmasqConfKeys.Server;
@@ -36,27 +33,13 @@ public sealed partial class ServerSemanticHandler : IOptionSemanticHandler
private static string? ValidateScopedServer(string value) private static string? ValidateScopedServer(string value)
{ {
var lastSlash = value.LastIndexOf('/'); if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(value, out var domains, out var targetPart, out var error))
if (lastSlash <= 0)
return "Scoped server must use /domain/.../server syntax, for example /example.local/192.168.1.1."; return "Scoped server must use /domain/.../server syntax, for example /example.local/192.168.1.1.";
var domainPart = value[1..lastSlash]; error = DnsmasqScopedDomainSyntax.ValidateDomainPatterns(domains);
var targetPart = value[(lastSlash + 1)..]; if (error is not null)
return error.Replace("Value", "Scoped server", StringComparison.Ordinal) +
var domains = domainPart.Split('/'); " Use server=/domain/server and keep domain labels to letters, digits, '-', '.', or a leading '*'.";
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 '*'.";
}
}
if (targetPart.Length == 0) if (targetPart.Length == 0)
return null; // local-only form return null; // local-only form
@@ -118,19 +101,6 @@ public sealed partial class ServerSemanticHandler : IOptionSemanticHandler
return value.Length <= 253 && HostnamePattern().IsMatch(value); 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) => private static bool IsValidInterfaceName(string value) =>
value.Length > 0 && value.Length > 0 &&
value.Length <= 64 && value.Length <= 64 &&
@@ -0,0 +1,28 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Conservative semantic validation for <c>shared-network</c> values.
/// </summary>
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.";
}
}
@@ -0,0 +1,52 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using System.Net;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Conservative semantic validation for <c>slaac</c> values.
/// Accepts interface-like tokens, IPv6 literals, and documented SLAAC mode keywords.
/// </summary>
public sealed class SlaacSemanticHandler : IOptionSemanticHandler
{
private static readonly HashSet<string> 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;
}
}
@@ -0,0 +1,41 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>tag-if</c> values.
/// </summary>
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:<tag> and optional tag:<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:<tag> clause.";
}
}
@@ -0,0 +1,73 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Specialized semantic behavior for <c>trust-anchor</c> values.
/// Supports negative trust anchors (domain[,class]) and DS-record forms.
/// </summary>
public sealed class TrustAnchorSemanticHandler : IOptionSemanticHandler
{
private static readonly HashSet<string> 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;
}
}