Option semantics: handlers, shared syntax, RaParam interface validation

- Add IOptionSemanticHandler implementations for DNS/DHCP/auth/interface options
- Add shared syntax helpers (DnsmasqRrTypeSyntax, DnsmasqScopedDomainSyntax, etc.)
- Wire semantics and registry for multi/single options; metadata-only semantics
- RaParam: use DnsmasqRelaySyntax.IsInterfaceNameWithOptionalTrailingWildcard so
  interface names allow only trailing *, not asterisks in the middle (eth*0, *eth0)
- Tests: RaParam trailing-wildcard and invalid-asterisk cases
This commit is contained in:
2026-03-11 09:38:36 +10:00
parent 36a160dfc7
commit 181cbbed48
33 changed files with 1287 additions and 18 deletions
@@ -43,6 +43,30 @@ public class OptionSemanticValidatorTests
new DhcpBootSemanticHandler(), new DhcpBootSemanticHandler(),
new SlaacSemanticHandler(), new SlaacSemanticHandler(),
new PxeServiceSemanticHandler(), new PxeServiceSemanticHandler(),
new DhcpCircuitidSemanticHandler(),
new DhcpRemoteidSemanticHandler(),
new DhcpSubscridSemanticHandler(),
new FilterRrSemanticHandler(),
new CacheRrSemanticHandler(),
new InterfaceNameSemanticHandler(),
new AuthServerSemanticHandler(),
new CnameSemanticHandler(),
new MxHostSemanticHandler(),
new PtrRecordSemanticHandler(),
new InterfaceNameRecordSemanticHandler(),
new CaaRecordSemanticHandler(),
new SrvHostSemanticHandler(),
new NaptrRecordSemanticHandler(),
new DnsRrSemanticHandler(),
new DynamicHostSemanticHandler(),
new AuthSoaSemanticHandler(),
new AuthSecServersSemanticHandler(),
new AuthPeerSemanticHandler(),
new HostRecordSemanticHandler(),
new TxtRecordSemanticHandler(),
new DomainSemanticHandler(),
new SynthDomainSemanticHandler(),
new AuthZoneSemanticHandler(),
]); ]);
[Fact] [Fact]
@@ -457,6 +481,9 @@ public class OptionSemanticValidatorTests
[InlineData("eth0,60", true)] [InlineData("eth0,60", true)]
[InlineData("eth0,mtu:1280,low,60,1200", true)] [InlineData("eth0,mtu:1280,low,60,1200", true)]
[InlineData("eth0,high", true)] [InlineData("eth0,high", true)]
[InlineData("eth*,60", true)]
[InlineData("eth*0,60", false)]
[InlineData("*eth0,60", false)]
[InlineData(",60", false)] [InlineData(",60", false)]
[InlineData("eth0,mtu:", false)] [InlineData("eth0,mtu:", false)]
public void ValidateMultiItem_RaParam_UsesHandler(string value, bool valid) public void ValidateMultiItem_RaParam_UsesHandler(string value, bool valid)
@@ -655,4 +682,257 @@ public class OptionSemanticValidatorTests
dir.Delete(recursive: true); dir.Delete(recursive: true);
} }
} }
[Fact]
public void ValidateMultiItem_DhcpCircuitid_RequiresSetTagValue()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "set:uplink,aa:bb:cc", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "set:mytag,simple-string", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "tag:uplink,id", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "set:only", semantics));
}
[Fact]
public void ValidateMultiItem_DhcpRemoteid_RequiresSetTagValue()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRemoteid, "set:uplink,remote-id", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRemoteid, "set:tag,", semantics));
}
[Fact]
public void ValidateMultiItem_DhcpSubscrid_RequiresSetTagValue()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpSubscrid, "set:sub,subscriber-id", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpSubscrid, "bad", semantics));
}
[Fact]
public void ValidateMultiItem_FilterRr_AcceptsRrTypes()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "A", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "A,AAAA,TXT,MX", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "ANY", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "255", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "A,,B", semantics));
}
[Fact]
public void ValidateMultiItem_CacheRr_AcceptsRrTypes()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "TXT", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "A,AAAA,CNAME,SRV", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "ANY", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "", semantics));
}
[Fact]
public void ValidateMultiItem_InterfaceNameOptions_AcceptInterfaceNameWithOptionalTrailingWildcard()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
foreach (var key in new[] { DnsmasqConfKeys.Interface, DnsmasqConfKeys.ExceptInterface, DnsmasqConfKeys.NoDhcpInterface, DnsmasqConfKeys.NoDhcpv4Interface, DnsmasqConfKeys.NoDhcpv6Interface })
{
Assert.Null(_validator.ValidateMultiItem(key, "eth0", semantics));
Assert.Null(_validator.ValidateMultiItem(key, "eth0*", semantics));
Assert.Null(_validator.ValidateMultiItem(key, "br-lan", semantics));
Assert.NotNull(_validator.ValidateMultiItem(key, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(key, "eth*0", semantics));
}
}
[Fact]
public void ValidateMultiItem_AuthServer_RequiresDomain_AcceptsInterfaceOrIp()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com,eth0", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com,192.168.1.1,eth1/4", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "ns.example.com,::1,eth0/6", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, ",eth0", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com,invalid@", semantics));
}
[Fact]
public void ValidateMultiItem_Cname_RequiresCnameAndTarget_AcceptsOptionalTtl()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "router.example.local,router", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "cname1,cname2,target", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "alias,host.example.com,60", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "only", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "bad..name,target", semantics));
}
[Fact]
public void ValidateMultiItem_MxHost_AcceptsMxNameWithOptionalHostnameAndPreference()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "example.local", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "example.local,mail.example.local,10", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "mx.local,5", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "bad..name", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "mx.local,mail,99999", semantics));
}
[Fact]
public void ValidateMultiItem_PtrRecord_NameAndOptionalTarget()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "1.0.168.192.in-addr.arpa,host.example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "name.only", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "bad..name,target", semantics));
}
[Fact]
public void ValidateMultiItem_InterfaceNameRecord_NameAndInterface()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "router.example.local,eth0", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "host.local,eth1/4", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "name", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "name,", semantics));
}
[Fact]
public void ValidateMultiItem_CaaRecord_FourParts()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "example.com,0,issue,letsencrypt.org", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "a,b", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "x,y,z,w", semantics)); // 4 but invalid name possible - "x" is valid DNS name
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "x,256,tag,val", semantics));
}
[Fact]
public void ValidateMultiItem_SrvHost_ServiceNameAndOptionalTargetPortPriorityWeight()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "_sip._udp.example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "_sip._udp,sip.example.com,5060,0,0", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "_sip._udp,host,70000", semantics));
}
[Fact]
public void ValidateMultiItem_NaptrRecord_SixOrSevenParts()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "name.example.com,0,0,s,a,x,replacement", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "n.zone,1,10,flags,svc,regex", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "a,b,c", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "bad..name,0,0,f,s,r", semantics));
}
[Fact]
public void ValidateMultiItem_DnsRr_NameAndRrNumber_OptionalHex()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "example.com,255", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "name.example,1,01:02:03", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "x", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "x,99999", semantics));
}
[Fact]
public void ValidateMultiItem_DynamicHost_FourFields_NameAndInterface()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "example.com,0.0.0.8,eth0", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "example.com,,,eth0", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "host.local,0.0.0.8,,eth0", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "x,y", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "x,1.2.3.4,::1,bad if", semantics));
}
[Fact]
public void ValidateMultiItem_AuthSoa_SerialAndOptionalFields()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "1", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "1,hostmaster.example.com,3600,600,86400", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "x", semantics));
}
[Fact]
public void ValidateMultiItem_AuthSecServers_OneOrMoreDomains()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "ns1.example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "a.com,b.com", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "bad..name", semantics));
}
[Fact]
public void ValidateMultiItem_AuthPeer_OneOrMoreIps()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "192.168.1.1", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "::1,10.0.0.1", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "not-an-ip", semantics));
}
[Fact]
public void ValidateMultiItem_HostRecord_AtLeastOneName_OptionalIpv4Ipv6Ttl()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "laptop,192.168.0.1", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "host.example.com,192.168.0.1,::1,60", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "bad..name,1.2.3.4", semantics));
}
[Fact]
public void ValidateMultiItem_TxtRecord_NameRequired()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "name.example.com,some text", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "bad..name,text", semantics));
}
[Fact]
public void ValidateMultiItem_Domain_DomainOrHash_OptionalRangeOrInterface()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "thekelleys.org.uk", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "#", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "lan,192.168.0.0/24,local", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "local.zone,eth0", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "bad..name", semantics));
}
[Fact]
public void ValidateMultiItem_SynthDomain_DomainAndRange_OptionalPrefix()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "zone.example.com,192.168.0.50,192.168.0.70", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "zone,192.168.0.0/24,internal-*", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "only", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "bad..name,1.2.3.4", semantics));
}
[Fact]
public void ValidateMultiItem_AuthZone_DomainRequired_OptionalSubnets()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "zone,192.168.0.0/24", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "bad..name", semantics));
}
} }
@@ -79,9 +79,9 @@ public static class DnsmasqOptionPlaceholders
[DnsmasqConfKeys.RaParam] = "eth0,mtu:1500,high", [DnsmasqConfKeys.RaParam] = "eth0,mtu:1500,high",
[DnsmasqConfKeys.Slaac] = "eth0,::10", [DnsmasqConfKeys.Slaac] = "eth0,::10",
[DnsmasqConfKeys.DhcpRelay] = "192.168.1.0,192.168.2.1", [DnsmasqConfKeys.DhcpRelay] = "192.168.1.0,192.168.2.1",
[DnsmasqConfKeys.DhcpCircuitid] = "tag:uplink,example-circuit", [DnsmasqConfKeys.DhcpCircuitid] = "set:<tag>,<circuit-id>",
[DnsmasqConfKeys.DhcpRemoteid] = "tag:uplink,example-remote", [DnsmasqConfKeys.DhcpRemoteid] = "set:<tag>,<remote-id>",
[DnsmasqConfKeys.DhcpSubscrid] = "tag:uplink,example-subscriber", [DnsmasqConfKeys.DhcpSubscrid] = "set:<tag>,<subscriber-id>",
[DnsmasqConfKeys.DhcpProxy] = "192.168.1.0,192.168.1.1", [DnsmasqConfKeys.DhcpProxy] = "192.168.1.0,192.168.1.1",
[DnsmasqConfKeys.TagIf] = "tag:guest,tag:wifi", [DnsmasqConfKeys.TagIf] = "tag:guest,tag:wifi",
[DnsmasqConfKeys.BridgeInterface] = "br0,eth0", [DnsmasqConfKeys.BridgeInterface] = "br0,eth0",
@@ -346,6 +346,146 @@ public static class EffectiveConfigSpecialOptionSemantics
EffectiveConfigParserBehavior.Multi, EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue, EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti), ComplexMulti),
[DnsmasqConfKeys.DhcpCircuitid] = new OptionSemantics(
DnsmasqConfKeys.DhcpCircuitid,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.DhcpRemoteid] = new OptionSemantics(
DnsmasqConfKeys.DhcpRemoteid,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.DhcpSubscrid] = new OptionSemantics(
DnsmasqConfKeys.DhcpSubscrid,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.FilterRr] = new OptionSemantics(
DnsmasqConfKeys.FilterRr,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.CacheRr] = new OptionSemantics(
DnsmasqConfKeys.CacheRr,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.Interface] = new OptionSemantics(
DnsmasqConfKeys.Interface,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.ExceptInterface] = new OptionSemantics(
DnsmasqConfKeys.ExceptInterface,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.NoDhcpInterface] = new OptionSemantics(
DnsmasqConfKeys.NoDhcpInterface,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.NoDhcpv4Interface] = new OptionSemantics(
DnsmasqConfKeys.NoDhcpv4Interface,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.NoDhcpv6Interface] = new OptionSemantics(
DnsmasqConfKeys.NoDhcpv6Interface,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.AuthServer] = new OptionSemantics(
DnsmasqConfKeys.AuthServer,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.Cname] = new OptionSemantics(
DnsmasqConfKeys.Cname,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.MxHost] = new OptionSemantics(
DnsmasqConfKeys.MxHost,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.PtrRecord] = new OptionSemantics(
DnsmasqConfKeys.PtrRecord,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.InterfaceName] = new OptionSemantics(
DnsmasqConfKeys.InterfaceName,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.CaaRecord] = new OptionSemantics(
DnsmasqConfKeys.CaaRecord,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.Srv] = new OptionSemantics(
DnsmasqConfKeys.Srv,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.NaptrRecord] = new OptionSemantics(
DnsmasqConfKeys.NaptrRecord,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.Domain] = new OptionSemantics(
DnsmasqConfKeys.Domain,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.TxtRecord] = new OptionSemantics(
DnsmasqConfKeys.TxtRecord,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.HostRecord] = new OptionSemantics(
DnsmasqConfKeys.HostRecord,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.DynamicHost] = new OptionSemantics(
DnsmasqConfKeys.DynamicHost,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.DnsRr] = new OptionSemantics(
DnsmasqConfKeys.DnsRr,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.SynthDomain] = new OptionSemantics(
DnsmasqConfKeys.SynthDomain,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.AuthZone] = new OptionSemantics(
DnsmasqConfKeys.AuthZone,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.AuthSoa] = new OptionSemantics(
DnsmasqConfKeys.AuthSoa,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.AuthSecServers] = new OptionSemantics(
DnsmasqConfKeys.AuthSecServers,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiValue,
ComplexMulti),
[DnsmasqConfKeys.AuthPeer] = new OptionSemantics(
DnsmasqConfKeys.AuthPeer,
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>
@@ -230,29 +230,29 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
DnsmasqConfKeys.DhcpUserclass, DnsmasqConfKeys.DhcpUserclass,
DnsmasqConfKeys.Slaac); DnsmasqConfKeys.Slaac);
RegisterMultis( RegisterSemanticMultis(
EffectiveConfigFieldBuilder.SectionResolver, EffectiveConfigFieldBuilder.SectionResolver,
DnsmasqConfKeys.FilterRr); DnsmasqConfKeys.FilterRr);
RegisterMultis( RegisterSemanticMultis(
EffectiveConfigFieldBuilder.SectionDnsRecords, EffectiveConfigFieldBuilder.SectionDnsRecords,
DnsmasqConfKeys.Domain, DnsmasqConfKeys.Domain,
DnsmasqConfKeys.Cname, DnsmasqConfKeys.Cname,
DnsmasqConfKeys.MxHost, DnsmasqConfKeys.MxHost,
DnsmasqConfKeys.Srv,
DnsmasqConfKeys.PtrRecord, DnsmasqConfKeys.PtrRecord,
DnsmasqConfKeys.TxtRecord,
DnsmasqConfKeys.NaptrRecord,
DnsmasqConfKeys.HostRecord,
DnsmasqConfKeys.DynamicHost,
DnsmasqConfKeys.InterfaceName, DnsmasqConfKeys.InterfaceName,
DnsmasqConfKeys.CaaRecord, DnsmasqConfKeys.CaaRecord,
DnsmasqConfKeys.Srv,
DnsmasqConfKeys.NaptrRecord,
DnsmasqConfKeys.TxtRecord,
DnsmasqConfKeys.HostRecord,
DnsmasqConfKeys.DynamicHost,
DnsmasqConfKeys.DnsRr, DnsmasqConfKeys.DnsRr,
DnsmasqConfKeys.SynthDomain, DnsmasqConfKeys.SynthDomain,
DnsmasqConfKeys.AuthZone, DnsmasqConfKeys.AuthZone,
DnsmasqConfKeys.AuthSoa, DnsmasqConfKeys.AuthSoa,
DnsmasqConfKeys.AuthSecServers, DnsmasqConfKeys.AuthSecServers,
DnsmasqConfKeys.AuthPeer); DnsmasqConfKeys.AuthPeer);
RegisterMultis( RegisterSemanticMultis(
EffectiveConfigFieldBuilder.SectionDhcp, EffectiveConfigFieldBuilder.SectionDhcp,
DnsmasqConfKeys.DhcpCircuitid, DnsmasqConfKeys.DhcpCircuitid,
DnsmasqConfKeys.DhcpRemoteid, DnsmasqConfKeys.DhcpRemoteid,
@@ -264,8 +264,8 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
EffectiveConfigFieldBuilder.SectionTftpPxe, EffectiveConfigFieldBuilder.SectionTftpPxe,
DnsmasqConfKeys.DhcpOptionPxe); DnsmasqConfKeys.DhcpOptionPxe);
RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor); RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor);
RegisterMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr); RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr);
RegisterMultis( RegisterSemanticMultis(
EffectiveConfigFieldBuilder.SectionProcess, EffectiveConfigFieldBuilder.SectionProcess,
DnsmasqConfKeys.Interface, DnsmasqConfKeys.Interface,
DnsmasqConfKeys.ExceptInterface, DnsmasqConfKeys.ExceptInterface,
@@ -0,0 +1,31 @@
using System.Net;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>auth-peer</c> values: &lt;ip-address&gt;[,&lt;ip-address&gt;...].
/// One or more IP addresses (secondary servers allowed to initiate AXFR).
/// </summary>
public sealed class AuthPeerSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.AuthPeer;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "auth-peer value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length == 0 || tokens.Any(t => t.Length == 0))
return "auth-peer must contain at least one IP address.";
foreach (var t in tokens)
{
if (!IPAddress.TryParse(t, out _))
return "auth-peer each value must be a valid IP address.";
}
return null;
}
}
@@ -0,0 +1,30 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>auth-sec-servers</c> values: &lt;domain&gt;[,&lt;domain&gt;...].
/// One or more domain names (secondary servers for authoritative zones).
/// </summary>
public sealed class AuthSecServersSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.AuthSecServers;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "auth-sec-servers value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length == 0 || tokens.Any(t => t.Length == 0))
return "auth-sec-servers must contain at least one domain name.";
foreach (var t in tokens)
{
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(t))
return "auth-sec-servers each domain must be a valid DNS name.";
}
return null;
}
}
@@ -0,0 +1,44 @@
using System.Net;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>auth-server</c> values: &lt;domain&gt;,[&lt;interface&gt;|&lt;ip-address&gt;...].
/// Domain (glue record) is required; optional comma-separated list of interfaces or IPs; interface may have /4 or /6.
/// </summary>
public sealed class AuthServerSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.AuthServer;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "auth-server value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length == 0 || tokens[0].Length == 0)
return "auth-server requires a domain (glue record).";
for (var i = 1; i < tokens.Length; i++)
{
var token = tokens[i];
if (token.Length == 0)
return "auth-server list cannot contain empty interface or address.";
if (IsIpOrInterface(token))
continue;
return "auth-server: each item after the domain must be an IP address or interface name (optional /4 or /6).";
}
return null;
}
private static bool IsIpOrInterface(string token)
{
if (IPAddress.TryParse(token, out _))
return true;
if (token.EndsWith("/4", StringComparison.Ordinal) || token.EndsWith("/6", StringComparison.Ordinal))
return DnsmasqRelaySyntax.IsInterfaceName(token[..^2]);
return DnsmasqRelaySyntax.IsInterfaceName(token);
}
}
@@ -0,0 +1,37 @@
using System.Globalization;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>auth-soa</c> values: &lt;serial&gt;[,&lt;hostmaster&gt;[,&lt;refresh&gt;[,&lt;retry&gt;[,&lt;expiry&gt;]]]].
/// Serial required; optional hostmaster (DNS name), refresh, retry, expiry (numbers).
/// </summary>
public sealed class AuthSoaSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.AuthSoa;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "auth-soa value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length is 0 or > 5)
return "auth-soa must be <serial>[,<hostmaster>[,<refresh>[,<retry>[,<expiry>]]]].";
if (tokens.Any(t => t.Length == 0))
return "auth-soa fields cannot be empty.";
if (!uint.TryParse(tokens[0], NumberStyles.None, CultureInfo.InvariantCulture, out _))
return "auth-soa serial must be a number.";
if (tokens.Length >= 2 && !DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[1]))
return "auth-soa hostmaster must be a valid DNS name.";
for (var i = 2; i < tokens.Length; i++)
{
if (!uint.TryParse(tokens[i], NumberStyles.None, CultureInfo.InvariantCulture, out _))
return "auth-soa refresh, retry and expiry must be numbers.";
}
return null;
}
}
@@ -0,0 +1,32 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>auth-zone</c> values: &lt;domain&gt;[,&lt;subnet&gt;[/prefix]...][,exclude:&lt;subnet&gt;...].
/// Domain required; optional subnets or exclude: clauses (interface names or CIDR).
/// </summary>
public sealed class AuthZoneSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.AuthZone;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "auth-zone value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length == 0 || tokens[0].Length == 0)
return "auth-zone requires a domain name.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
return "auth-zone domain must be a valid DNS name.";
for (var i = 1; i < tokens.Length; i++)
{
if (tokens[i].Length == 0)
return "auth-zone cannot have empty subnet or exclude fields.";
}
return null;
}
}
@@ -0,0 +1,31 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>caa-record</c> values: &lt;name&gt;,&lt;flags&gt;,&lt;tag&gt;,&lt;value&gt; (RFC6844).
/// All four parts required.
/// </summary>
public sealed class CaaRecordSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.CaaRecord;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "caa-record value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length != 4)
return "caa-record must be <name>,<flags>,<tag>,<value>.";
if (tokens.Any(t => t.Length == 0))
return "caa-record name, flags, tag and value cannot be empty.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
return "caa-record name must be a valid DNS name.";
if (!byte.TryParse(tokens[1], out _))
return "caa-record flags must be 0-255.";
return null;
}
}
@@ -0,0 +1,28 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>cache-rr</c> values: RR-type or comma-separated list (e.g. A,AAAA,TXT or ANY).
/// </summary>
public sealed class CacheRrSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.CacheRr;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "cache-rr value cannot be empty.";
foreach (var token in s.Split(',', StringSplitOptions.TrimEntries))
{
if (token.Length == 0)
return "cache-rr list cannot contain empty RR-type.";
if (!DnsmasqRrTypeSyntax.IsValidRrType(token))
return "cache-rr RR-type must be a name (e.g. A, TXT, ANY) or decimal number.";
}
return null;
}
}
@@ -0,0 +1,36 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>cname</c> values: &lt;cname&gt;[,&lt;cname&gt;,]&lt;target&gt;[,&lt;TTL&gt;].
/// At least one cname and target required; optional TTL as positive integer.
/// </summary>
public sealed class CnameSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.Cname;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "cname value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length < 2)
return "cname must be <cname>,<target> or <cname>[,<cname>...],<target>[,<TTL>].";
if (tokens.Any(t => t.Length == 0))
return "cname list cannot contain empty parts.";
var lastIsTtl = tokens.Length >= 3 && int.TryParse(tokens[^1], out var ttl) && ttl > 0;
var targetIndex = lastIsTtl ? tokens.Length - 2 : tokens.Length - 1;
if (targetIndex < 1)
return "cname requires at least one cname and a target.";
for (var i = 0; i <= targetIndex; i++)
{
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[i]))
return "cname and target must be valid DNS names (e.g. host.example.com).";
}
return null;
}
}
@@ -0,0 +1,16 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dhcp-circuitid</c> values: set:&lt;tag&gt;,&lt;circuit-id&gt; (colon-hex or string).
/// </summary>
public sealed class DhcpCircuitidSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DhcpCircuitid;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value) =>
DnsmasqDhcpTagSyntax.ValidateSetTagValue((value ?? "").Trim(), "dhcp-circuitid");
}
@@ -0,0 +1,16 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dhcp-remoteid</c> values: set:&lt;tag&gt;,&lt;remote-id&gt; (colon-hex or string).
/// </summary>
public sealed class DhcpRemoteidSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DhcpRemoteid;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value) =>
DnsmasqDhcpTagSyntax.ValidateSetTagValue((value ?? "").Trim(), "dhcp-remoteid");
}
@@ -0,0 +1,16 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dhcp-subscrid</c> values: set:&lt;tag&gt;,&lt;subscriber-id&gt; (RFC3993).
/// </summary>
public sealed class DhcpSubscridSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DhcpSubscrid;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value) =>
DnsmasqDhcpTagSyntax.ValidateSetTagValue((value ?? "").Trim(), "dhcp-subscrid");
}
@@ -0,0 +1,37 @@
using System.Globalization;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dns-rr</c> values: &lt;name&gt;,&lt;RR-number&gt;,[&lt;hex data&gt;].
/// Name and RR type number required; optional hex data (digits, colons, spaces).
/// </summary>
public sealed class DnsRrSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DnsRr;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "dns-rr value cannot be empty.";
var tokens = s.Split(',', 3, StringSplitOptions.TrimEntries);
if (tokens.Length < 2)
return "dns-rr must be <name>,<RR-number>[,<hex data>].";
if (tokens[0].Length == 0)
return "dns-rr name cannot be empty.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
return "dns-rr name must be a valid DNS name.";
if (!ushort.TryParse(tokens[1], NumberStyles.None, CultureInfo.InvariantCulture, out _))
return "dns-rr RR-number must be 0-65535.";
if (tokens.Length == 3 && tokens[2].Length > 0 && !IsValidHexData(tokens[2]))
return "dns-rr hex data must be hex digits, colons or spaces (e.g. 01:23:45 or 012345).";
return null;
}
private static bool IsValidHexData(string value) =>
value.All(c => char.IsAsciiHexDigit(c) || c is ':' or ' ' or '\t');
}
@@ -36,4 +36,25 @@ internal static class DnsmasqDhcpTagSyntax
token.Length > 0 && token.Length > 0 &&
token.Length <= 64 && token.Length <= 64 &&
token.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' || (allowWildcard && c == '*')); token.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' || (allowWildcard && c == '*'));
/// <summary>
/// Validates <c>set:&lt;tag&gt;,&lt;value&gt;</c> form used by dhcp-circuitid, dhcp-remoteid, dhcp-subscrid.
/// Tag and value must be non-empty; value may be colon-separated hex or a simple string.
/// </summary>
/// <param name="value">Trimmed option value.</param>
/// <param name="optionLabel">Option name for error messages (e.g. "dhcp-circuitid").</param>
/// <returns>Error message or null if valid.</returns>
public static string? ValidateSetTagValue(string value, string optionLabel)
{
if (string.IsNullOrWhiteSpace(value))
return $"{optionLabel} value cannot be empty.";
var tokens = value.Split(',', 2, StringSplitOptions.TrimEntries);
if (tokens.Length != 2)
return $"{optionLabel} must be set:<tag>,<value>.";
if (tokens[0].Length == 0 || tokens[1].Length == 0)
return $"{optionLabel} tag and value cannot be empty.";
if (!IsSetToken(tokens[0]))
return $"{optionLabel} must start with set:<tag>.";
return null;
}
} }
@@ -31,4 +31,20 @@ internal static class DnsmasqRelaySyntax
value.Length <= 64 && value.Length <= 64 &&
value.Count(c => c == '.') <= 1 && value.Count(c => c == '.') <= 1 &&
value.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.'); value.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.');
/// <summary>
/// Interface name with optional trailing <c>*</c> wildcard (for --interface, --except-interface, etc.).
/// Only a single trailing asterisk is allowed, not <c>*</c> in the middle.
/// </summary>
public static bool IsInterfaceNameWithOptionalTrailingWildcard(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
var v = value.Trim();
if (v.Length == 0)
return false;
if (v.EndsWith('*'))
return v.Length > 1 && IsInterfaceName(v[..^1]);
return IsInterfaceName(v);
}
} }
@@ -0,0 +1,19 @@
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Shared helpers for DNS RR-type values used by <c>filter-rr</c> and <c>cache-rr</c>.
/// RR-type can be a name (e.g. TXT, MX, A, AAAA, ANY) or a decimal number.
/// </summary>
internal static class DnsmasqRrTypeSyntax
{
/// <summary>Returns true if the token is a valid RR-type: non-empty, letters/digits/hyphen or decimal number.</summary>
public static bool IsValidRrType(string token)
{
if (string.IsNullOrWhiteSpace(token))
return false;
var t = token.Trim();
if (t.Length == 0)
return false;
return t.All(c => char.IsLetterOrDigit(c) || c == '-');
}
}
@@ -11,6 +11,27 @@ 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)] [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(); private static partial Regex DomainPattern();
/// <summary>Returns true if the value is a valid DNS name (hostname/domain) for record options like cname, mx-host.</summary>
public static bool IsValidDnsName(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
var v = value.Trim();
return v.Length >= 1 && v.Length <= 253 && DomainPattern().IsMatch(v);
}
/// <summary>Returns true if the value looks like an SRV service name (e.g. _sip._udp or _sip._udp.example.com). Allows leading underscore in labels.</summary>
public static bool IsValidSrvServiceName(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
var v = value.Trim();
if (v.Length > 253)
return false;
return v.Split('.').All(label => label.Length > 0 && label.Length <= 63 &&
label.All(c => char.IsLetterOrDigit(c) || c is '-' or '_'));
}
public static bool TrySplitScopedValue(string value, out string[] domains, out string tail, out string? error) public static bool TrySplitScopedValue(string value, out string[] domains, out string tail, out string? error)
{ {
domains = Array.Empty<string>(); domains = Array.Empty<string>();
@@ -0,0 +1,44 @@
using System.Net;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>domain</c> (DHCP/DNS) values: &lt;domain&gt;[[,&lt;address range&gt;[,local]]|&lt;interface&gt;].
/// Domain required (# or DNS name); optional address range (IP or CIDR), interface, or "local".
/// </summary>
public sealed class DomainSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.Domain;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "domain value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length == 0 || tokens[0].Length == 0)
return "domain requires a domain name or #.";
var domain = tokens[0];
if (domain != "#" && !DnsmasqScopedDomainSyntax.IsValidDnsName(domain))
return "domain must be a valid DNS name or #.";
if (tokens.Length >= 2 && tokens[1].Length == 0)
return "domain cannot have empty fields.";
if (tokens.Length >= 2 && !IsValidSecondToken(tokens[1]))
return "domain second field must be interface name, IP, CIDR (e.g. 192.168.0.0/24), or 'local'.";
return null;
}
private static bool IsValidSecondToken(string token)
{
if (token == "local")
return true;
if (DnsmasqRelaySyntax.IsInterfaceName(token))
return true;
if (token.Contains('/'))
return DnsmasqIpPrefixSyntax.ValidateIpWithOptionalPrefix(token, "domain") is null;
return IPAddress.TryParse(token, out _);
}
}
@@ -0,0 +1,37 @@
using System.Net;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>dynamic-host</c> values: &lt;name&gt;,[IPv4],[IPv6],&lt;interface&gt; (dnsmasq man).
/// Name and interface required. Doc example uses 3 tokens (name,IPv4,interface); we allow 3 or 4.
/// </summary>
public sealed class DynamicHostSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.DynamicHost;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "dynamic-host value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length is not 3 and not 4)
return "dynamic-host must be <name>,[IPv4],[IPv6],<interface> (3 or 4 comma-separated fields).";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
return "dynamic-host name must be a valid DNS name.";
var interfaceIndex = tokens.Length - 1;
if (tokens[1].Length > 0 && !IPAddress.TryParse(tokens[1], out _))
return "dynamic-host IPv4 must be a valid address or empty.";
if (tokens.Length == 4 && tokens[2].Length > 0 && !IPAddress.TryParse(tokens[2], out _))
return "dynamic-host IPv6 must be a valid address or empty.";
if (tokens[interfaceIndex].Length == 0)
return "dynamic-host interface cannot be empty.";
if (!DnsmasqRelaySyntax.IsInterfaceName(tokens[interfaceIndex]))
return "dynamic-host interface must be a valid interface name.";
return null;
}
}
@@ -0,0 +1,28 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>filter-rr</c> values: RR-type or comma-separated list (e.g. A,AAAA,TXT or ANY).
/// </summary>
public sealed class FilterRrSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.FilterRr;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "filter-rr value cannot be empty.";
foreach (var token in s.Split(',', StringSplitOptions.TrimEntries))
{
if (token.Length == 0)
return "filter-rr list cannot contain empty RR-type.";
if (!DnsmasqRrTypeSyntax.IsValidRrType(token))
return "filter-rr RR-type must be a name (e.g. A, TXT, ANY) or decimal number.";
}
return null;
}
}
@@ -0,0 +1,42 @@
using System.Net;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>host-record</c> values: &lt;name&gt;[,&lt;name&gt;...],[&lt;IPv4&gt;],[&lt;IPv6&gt;][,&lt;TTL&gt;].
/// At least one name required; optional IPv4, IPv6, TTL (positive integer).
/// </summary>
public sealed class HostRecordSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.HostRecord;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "host-record value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length == 0 || tokens[0].Length == 0)
return "host-record requires at least one name.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
return "host-record name(s) must be valid DNS names.";
var lastIsTtl = tokens.Length >= 2 && int.TryParse(tokens[^1], out var ttl) && ttl > 0;
var lastIndex = lastIsTtl ? tokens.Length - 2 : tokens.Length - 1;
for (var i = 1; i <= lastIndex; i++)
{
var t = tokens[i];
if (t.Length == 0)
return "host-record cannot have empty fields.";
if (IPAddress.TryParse(t, out _))
continue;
if (int.TryParse(t, out _))
return "host-record TTL must be last and positive; names and addresses cannot be numeric-only.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(t))
return "host-record name must be a valid DNS name.";
}
return null;
}
}
@@ -0,0 +1,34 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>interface-name</c> (DNS record) values: &lt;name&gt;,&lt;interface&gt;[/4|/6].
/// Name and interface required; interface may have /4 or /6 suffix.
/// </summary>
public sealed class InterfaceNameRecordSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.InterfaceName;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "interface-name value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length != 2)
return "interface-name must be <name>,<interface>[/4|/6].";
if (tokens[0].Length == 0 || tokens[1].Length == 0)
return "interface-name name and interface cannot be empty.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
return "interface-name name must be a valid DNS name.";
var iface = tokens[1];
if (iface.EndsWith("/4", StringComparison.Ordinal) || iface.EndsWith("/6", StringComparison.Ordinal))
iface = iface[..^2];
if (!DnsmasqRelaySyntax.IsInterfaceName(iface))
return "interface-name interface must be a valid interface name (optional /4 or /6).";
return null;
}
}
@@ -0,0 +1,34 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for interface-name options: <c>interface</c>, <c>except-interface</c>,
/// <c>no-dhcp-interface</c>, <c>no-dhcpv4-interface</c>, <c>no-dhcpv6-interface</c>.
/// Each value is an interface name with optional trailing <c>*</c> wildcard.
/// </summary>
public sealed class InterfaceNameSemanticHandler : IOptionSemanticHandler
{
private static readonly HashSet<string> HandledOptions = new(StringComparer.Ordinal)
{
DnsmasqConfKeys.Interface,
DnsmasqConfKeys.ExceptInterface,
DnsmasqConfKeys.NoDhcpInterface,
DnsmasqConfKeys.NoDhcpv4Interface,
DnsmasqConfKeys.NoDhcpv6Interface,
};
public bool CanHandle(string optionName) => HandledOptions.Contains(optionName);
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "Interface name cannot be empty.";
return DnsmasqRelaySyntax.IsInterfaceNameWithOptionalTrailingWildcard(s)
? null
: "Must be an interface name (e.g. eth0) with optional trailing * wildcard.";
}
}
@@ -0,0 +1,45 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>mx-host</c> values: &lt;mx name&gt;[[,&lt;hostname&gt;],&lt;preference&gt;].
/// MX name required; optional hostname and/or preference (integer).
/// </summary>
public sealed class MxHostSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.MxHost;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "mx-host value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length is 0 or > 3)
return "mx-host must be <mx name> or <mx name>,<hostname> or <mx name>[,<hostname>],<preference>.";
if (tokens.Any(t => t.Length == 0))
return "mx-host list cannot contain empty parts.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
return "mx-host MX name must be a valid DNS name.";
if (tokens.Length >= 2)
{
var lastIsPreference = int.TryParse(tokens[^1], out var pref) && pref >= 0 && pref <= 65535;
if (tokens.Length == 2)
{
if (!lastIsPreference && !DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[1]))
return "mx-host hostname or preference must be a valid DNS name or 0-65535.";
}
else
{
if (!lastIsPreference)
return "mx-host preference must be 0-65535.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[1]))
return "mx-host hostname must be a valid DNS name.";
}
}
return null;
}
}
@@ -0,0 +1,33 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>naptr-record</c> values: &lt;name&gt;,&lt;order&gt;,&lt;preference&gt;,&lt;flags&gt;,&lt;service&gt;,&lt;regexp&gt;[,&lt;replacement&gt;] (RFC3403).
/// Six required fields; optional replacement.
/// </summary>
public sealed class NaptrRecordSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.NaptrRecord;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "naptr-record value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length is < 6 or > 7)
return "naptr-record must be <name>,<order>,<preference>,<flags>,<service>,<regexp>[,<replacement>].";
if (tokens.Take(6).Any(t => t.Length == 0))
return "naptr-record name, order, preference, flags, service and regexp cannot be empty.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
return "naptr-record name must be a valid DNS name.";
if (!ushort.TryParse(tokens[1], out _))
return "naptr-record order must be 0-65535.";
if (!ushort.TryParse(tokens[2], out _))
return "naptr-record preference must be 0-65535.";
return null;
}
}
@@ -0,0 +1,31 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>ptr-record</c> values: &lt;name&gt;[,&lt;target&gt;].
/// Name required; optional target (both must be valid DNS names).
/// </summary>
public sealed class PtrRecordSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.PtrRecord;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "ptr-record value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length is 0 or > 2)
return "ptr-record must be <name> or <name>,<target>.";
if (tokens.Any(t => t.Length == 0))
return "ptr-record name and target cannot be empty.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
return "ptr-record name must be a valid DNS name.";
if (tokens.Length == 2 && !DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[1]))
return "ptr-record target must be a valid DNS name.";
return null;
}
}
@@ -23,8 +23,8 @@ public sealed class RaParamSemanticHandler : IOptionSemanticHandler
if (tokens.Length == 0 || tokens.Any(t => t.Length == 0)) if (tokens.Length == 0 || tokens.Any(t => t.Length == 0))
return "ra-param contains an empty comma-separated segment."; return "ra-param contains an empty comma-separated segment.";
if (!IsInterfaceName(tokens[0])) if (!DnsmasqRelaySyntax.IsInterfaceNameWithOptionalTrailingWildcard(tokens[0]))
return "ra-param must start with an interface name."; return "ra-param must start with an interface name (optionally ending with *).";
var seenPriority = false; var seenPriority = false;
var seenMtu = false; var seenMtu = false;
@@ -68,7 +68,4 @@ public sealed class RaParamSemanticHandler : IOptionSemanticHandler
return null; return null;
} }
private static bool IsInterfaceName(string value) =>
value.Length > 0 && value.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' or '*');
} }
@@ -0,0 +1,37 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>srv-host</c> values: &lt;_service&gt;.&lt;_prot&gt;[.&lt;domain&gt;],[&lt;target&gt;[,&lt;port&gt;[,&lt;priority&gt;[,&lt;weight&gt;]]]].
/// Service name required; optional target, port (0-65535), priority, weight.
/// </summary>
public sealed class SrvHostSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.Srv;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "srv-host value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length is 0 or > 5)
return "srv-host must be <_service>._prot[.domain][,target[,port[,priority[,weight]]]].";
if (tokens[0].Length == 0)
return "srv-host service name cannot be empty.";
if (!DnsmasqScopedDomainSyntax.IsValidSrvServiceName(tokens[0]))
return "srv-host service name must be like _service._prot or _service._prot.domain.";
if (tokens.Length >= 2 && tokens[1].Length > 0 && !DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[1]))
return "srv-host target must be a valid DNS name or empty.";
if (tokens.Length >= 3 && (!int.TryParse(tokens[2], out var port) || port < 0 || port > 65535))
return "srv-host port must be 0-65535.";
if (tokens.Length >= 4 && (!int.TryParse(tokens[3], out var pri) || pri < 0 || pri > 65535))
return "srv-host priority must be 0-65535.";
if (tokens.Length >= 5 && (!int.TryParse(tokens[4], out var w) || w < 0 || w > 65535))
return "srv-host weight must be 0-65535.";
return null;
}
}
@@ -0,0 +1,29 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>synth-domain</c> values: &lt;domain&gt;,&lt;address range&gt;[,&lt;prefix&gt;[*]].
/// Domain and address range required; optional prefix (may end with *).
/// </summary>
public sealed class SynthDomainSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.SynthDomain;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "synth-domain value cannot be empty.";
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
if (tokens.Length is < 2 or > 3)
return "synth-domain must be <domain>,<address range>[,<prefix>[*]].";
if (tokens.Any(t => t.Length == 0))
return "synth-domain domain and address range cannot be empty.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
return "synth-domain domain must be a valid DNS name.";
return null;
}
}
@@ -0,0 +1,27 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>txt-record</c> values: &lt;name&gt;[[,&lt;text&gt;],&lt;text&gt;].
/// Name required (first comma-separated field); optional text strings (commas allowed in quoted strings).
/// </summary>
public sealed class TxtRecordSemanticHandler : IOptionSemanticHandler
{
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.TxtRecord;
public string? ValidateSingle(object? value) => null;
public string? ValidateMultiItem(string? value)
{
var s = (value ?? "").Trim();
if (s.Length == 0)
return "txt-record value cannot be empty.";
var name = s.Contains(',') ? s.Split(',', 2, StringSplitOptions.TrimEntries)[0].Trim() : s;
if (name.Length == 0)
return "txt-record name cannot be empty.";
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(name))
return "txt-record name must be a valid DNS name.";
return null;
}
}