mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
@@ -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 _);
|
||||||
|
}
|
||||||
+150
@@ -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();
|
||||||
|
|||||||
+38
-36
@@ -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,
|
||||||
|
|||||||
+9
-38
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+49
@@ -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;
|
||||||
|
}
|
||||||
+21
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -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:<tag> 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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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:<tag> 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]}'.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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]}'.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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]}'.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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 == '*'));
|
||||||
|
}
|
||||||
+27
@@ -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}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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 '.');
|
||||||
|
}
|
||||||
+67
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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";
|
||||||
|
}
|
||||||
+74
@@ -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 '*');
|
||||||
|
}
|
||||||
+34
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-35
@@ -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 &&
|
||||||
|
|||||||
+28
@@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user