diff --git a/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs b/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs index ec47765..925196b 100644 --- a/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs +++ b/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs @@ -43,6 +43,30 @@ public class OptionSemanticValidatorTests new DhcpBootSemanticHandler(), new SlaacSemanticHandler(), 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] @@ -457,6 +481,9 @@ public class OptionSemanticValidatorTests [InlineData("eth0,60", true)] [InlineData("eth0,mtu:1280,low,60,1200", true)] [InlineData("eth0,high", true)] + [InlineData("eth*,60", true)] + [InlineData("eth*0,60", false)] + [InlineData("*eth0,60", false)] [InlineData(",60", false)] [InlineData("eth0,mtu:", false)] public void ValidateMultiItem_RaParam_UsesHandler(string value, bool valid) @@ -655,4 +682,257 @@ public class OptionSemanticValidatorTests 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)); + } } diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionPlaceholders.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionPlaceholders.cs index 772c419..6a8ec06 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionPlaceholders.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionPlaceholders.cs @@ -79,9 +79,9 @@ public static class DnsmasqOptionPlaceholders [DnsmasqConfKeys.RaParam] = "eth0,mtu:1500,high", [DnsmasqConfKeys.Slaac] = "eth0,::10", [DnsmasqConfKeys.DhcpRelay] = "192.168.1.0,192.168.2.1", - [DnsmasqConfKeys.DhcpCircuitid] = "tag:uplink,example-circuit", - [DnsmasqConfKeys.DhcpRemoteid] = "tag:uplink,example-remote", - [DnsmasqConfKeys.DhcpSubscrid] = "tag:uplink,example-subscriber", + [DnsmasqConfKeys.DhcpCircuitid] = "set:,", + [DnsmasqConfKeys.DhcpRemoteid] = "set:,", + [DnsmasqConfKeys.DhcpSubscrid] = "set:,", [DnsmasqConfKeys.DhcpProxy] = "192.168.1.0,192.168.1.1", [DnsmasqConfKeys.TagIf] = "tag:guest,tag:wifi", [DnsmasqConfKeys.BridgeInterface] = "br0,eth0", diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs index 59c6826..e6c6094 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs @@ -346,6 +346,146 @@ public static class EffectiveConfigSpecialOptionSemantics EffectiveConfigParserBehavior.Multi, EffectiveConfigWriteBehavior.MultiValue, 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), }; /// Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints. diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs index 9582f4e..8d85332 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs @@ -230,29 +230,29 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm DnsmasqConfKeys.DhcpUserclass, DnsmasqConfKeys.Slaac); - RegisterMultis( + RegisterSemanticMultis( EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterRr); - RegisterMultis( + RegisterSemanticMultis( EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Domain, DnsmasqConfKeys.Cname, DnsmasqConfKeys.MxHost, - DnsmasqConfKeys.Srv, DnsmasqConfKeys.PtrRecord, - DnsmasqConfKeys.TxtRecord, - DnsmasqConfKeys.NaptrRecord, - DnsmasqConfKeys.HostRecord, - DnsmasqConfKeys.DynamicHost, DnsmasqConfKeys.InterfaceName, DnsmasqConfKeys.CaaRecord, + DnsmasqConfKeys.Srv, + DnsmasqConfKeys.NaptrRecord, + DnsmasqConfKeys.TxtRecord, + DnsmasqConfKeys.HostRecord, + DnsmasqConfKeys.DynamicHost, DnsmasqConfKeys.DnsRr, DnsmasqConfKeys.SynthDomain, DnsmasqConfKeys.AuthZone, DnsmasqConfKeys.AuthSoa, DnsmasqConfKeys.AuthSecServers, DnsmasqConfKeys.AuthPeer); - RegisterMultis( + RegisterSemanticMultis( EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpCircuitid, DnsmasqConfKeys.DhcpRemoteid, @@ -264,8 +264,8 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.DhcpOptionPxe); RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor); - RegisterMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr); - RegisterMultis( + RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr); + RegisterSemanticMultis( EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.Interface, DnsmasqConfKeys.ExceptInterface, diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthPeerSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthPeerSemanticHandler.cs new file mode 100644 index 0000000..a97efb1 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthPeerSemanticHandler.cs @@ -0,0 +1,31 @@ +using System.Net; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for auth-peer values: <ip-address>[,<ip-address>...]. +/// One or more IP addresses (secondary servers allowed to initiate AXFR). +/// +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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthSecServersSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthSecServersSemanticHandler.cs new file mode 100644 index 0000000..e1c16ef --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthSecServersSemanticHandler.cs @@ -0,0 +1,30 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for auth-sec-servers values: <domain>[,<domain>...]. +/// One or more domain names (secondary servers for authoritative zones). +/// +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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthServerSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthServerSemanticHandler.cs new file mode 100644 index 0000000..50b0638 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthServerSemanticHandler.cs @@ -0,0 +1,44 @@ +using System.Net; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for auth-server values: <domain>,[<interface>|<ip-address>...]. +/// Domain (glue record) is required; optional comma-separated list of interfaces or IPs; interface may have /4 or /6. +/// +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); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthSoaSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthSoaSemanticHandler.cs new file mode 100644 index 0000000..c2be876 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthSoaSemanticHandler.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for auth-soa values: <serial>[,<hostmaster>[,<refresh>[,<retry>[,<expiry>]]]]. +/// Serial required; optional hostmaster (DNS name), refresh, retry, expiry (numbers). +/// +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 [,[,[,[,]]]]."; + 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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthZoneSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthZoneSemanticHandler.cs new file mode 100644 index 0000000..e2ca354 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AuthZoneSemanticHandler.cs @@ -0,0 +1,32 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for auth-zone values: <domain>[,<subnet>[/prefix]...][,exclude:<subnet>...]. +/// Domain required; optional subnets or exclude: clauses (interface names or CIDR). +/// +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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/CaaRecordSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/CaaRecordSemanticHandler.cs new file mode 100644 index 0000000..8cb8067 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/CaaRecordSemanticHandler.cs @@ -0,0 +1,31 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for caa-record values: <name>,<flags>,<tag>,<value> (RFC6844). +/// All four parts required. +/// +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 ,,,."; + 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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/CacheRrSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/CacheRrSemanticHandler.cs new file mode 100644 index 0000000..cf8edf9 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/CacheRrSemanticHandler.cs @@ -0,0 +1,28 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for cache-rr values: RR-type or comma-separated list (e.g. A,AAAA,TXT or ANY). +/// +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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/CnameSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/CnameSemanticHandler.cs new file mode 100644 index 0000000..75cfbf7 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/CnameSemanticHandler.cs @@ -0,0 +1,36 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for cname values: <cname>[,<cname>,]<target>[,<TTL>]. +/// At least one cname and target required; optional TTL as positive integer. +/// +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 , or [,...],[,]."; + 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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpCircuitidSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpCircuitidSemanticHandler.cs new file mode 100644 index 0000000..7857779 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpCircuitidSemanticHandler.cs @@ -0,0 +1,16 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dhcp-circuitid values: set:<tag>,<circuit-id> (colon-hex or string). +/// +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"); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpRemoteidSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpRemoteidSemanticHandler.cs new file mode 100644 index 0000000..d8229bd --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpRemoteidSemanticHandler.cs @@ -0,0 +1,16 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dhcp-remoteid values: set:<tag>,<remote-id> (colon-hex or string). +/// +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"); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpSubscridSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpSubscridSemanticHandler.cs new file mode 100644 index 0000000..3d4f5ed --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DhcpSubscridSemanticHandler.cs @@ -0,0 +1,16 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dhcp-subscrid values: set:<tag>,<subscriber-id> (RFC3993). +/// +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"); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsRrSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsRrSemanticHandler.cs new file mode 100644 index 0000000..67bf863 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsRrSemanticHandler.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dns-rr values: <name>,<RR-number>,[<hex data>]. +/// Name and RR type number required; optional hex data (digits, colons, spaces). +/// +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 ,[,]."; + 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'); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqDhcpTagSyntax.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqDhcpTagSyntax.cs index eb4a24d..58cee8d 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqDhcpTagSyntax.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqDhcpTagSyntax.cs @@ -36,4 +36,25 @@ internal static class DnsmasqDhcpTagSyntax token.Length > 0 && token.Length <= 64 && token.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' || (allowWildcard && c == '*')); + + /// + /// Validates set:<tag>,<value> 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. + /// + /// Trimmed option value. + /// Option name for error messages (e.g. "dhcp-circuitid"). + /// Error message or null if valid. + 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:,."; + 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:."; + return null; + } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqRelaySyntax.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqRelaySyntax.cs index 75aa8ec..50e49c3 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqRelaySyntax.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqRelaySyntax.cs @@ -31,4 +31,20 @@ internal static class DnsmasqRelaySyntax value.Length <= 64 && value.Count(c => c == '.') <= 1 && value.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.'); + + /// + /// Interface name with optional trailing * wildcard (for --interface, --except-interface, etc.). + /// Only a single trailing asterisk is allowed, not * in the middle. + /// + 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); + } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqRrTypeSyntax.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqRrTypeSyntax.cs new file mode 100644 index 0000000..61954af --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqRrTypeSyntax.cs @@ -0,0 +1,19 @@ +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Shared helpers for DNS RR-type values used by filter-rr and cache-rr. +/// RR-type can be a name (e.g. TXT, MX, A, AAAA, ANY) or a decimal number. +/// +internal static class DnsmasqRrTypeSyntax +{ + /// Returns true if the token is a valid RR-type: non-empty, letters/digits/hyphen or decimal number. + 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 == '-'); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqScopedDomainSyntax.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqScopedDomainSyntax.cs index 215fadd..25488c2 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqScopedDomainSyntax.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DnsmasqScopedDomainSyntax.cs @@ -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)] private static partial Regex DomainPattern(); + /// Returns true if the value is a valid DNS name (hostname/domain) for record options like cname, mx-host. + 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); + } + + /// 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. + 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) { domains = Array.Empty(); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DomainSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DomainSemanticHandler.cs new file mode 100644 index 0000000..4735c3a --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DomainSemanticHandler.cs @@ -0,0 +1,44 @@ +using System.Net; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for domain (DHCP/DNS) values: <domain>[[,<address range>[,local]]|<interface>]. +/// Domain required (# or DNS name); optional address range (IP or CIDR), interface, or "local". +/// +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 _); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DynamicHostSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DynamicHostSemanticHandler.cs new file mode 100644 index 0000000..5a94f06 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DynamicHostSemanticHandler.cs @@ -0,0 +1,37 @@ +using System.Net; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for dynamic-host values: <name>,[IPv4],[IPv6],<interface> (dnsmasq man). +/// Name and interface required. Doc example uses 3 tokens (name,IPv4,interface); we allow 3 or 4. +/// +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 ,[IPv4],[IPv6], (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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/FilterRrSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/FilterRrSemanticHandler.cs new file mode 100644 index 0000000..40f701c --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/FilterRrSemanticHandler.cs @@ -0,0 +1,28 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for filter-rr values: RR-type or comma-separated list (e.g. A,AAAA,TXT or ANY). +/// +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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/HostRecordSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/HostRecordSemanticHandler.cs new file mode 100644 index 0000000..c3a45ee --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/HostRecordSemanticHandler.cs @@ -0,0 +1,42 @@ +using System.Net; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for host-record values: <name>[,<name>...],[<IPv4>],[<IPv6>][,<TTL>]. +/// At least one name required; optional IPv4, IPv6, TTL (positive integer). +/// +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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/InterfaceNameRecordSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/InterfaceNameRecordSemanticHandler.cs new file mode 100644 index 0000000..91bab35 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/InterfaceNameRecordSemanticHandler.cs @@ -0,0 +1,34 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for interface-name (DNS record) values: <name>,<interface>[/4|/6]. +/// Name and interface required; interface may have /4 or /6 suffix. +/// +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 ,[/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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/InterfaceNameSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/InterfaceNameSemanticHandler.cs new file mode 100644 index 0000000..445068f --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/InterfaceNameSemanticHandler.cs @@ -0,0 +1,34 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for interface-name options: interface, except-interface, +/// no-dhcp-interface, no-dhcpv4-interface, no-dhcpv6-interface. +/// Each value is an interface name with optional trailing * wildcard. +/// +public sealed class InterfaceNameSemanticHandler : IOptionSemanticHandler +{ + private static readonly HashSet 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."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/MxHostSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/MxHostSemanticHandler.cs new file mode 100644 index 0000000..0a7cacc --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/MxHostSemanticHandler.cs @@ -0,0 +1,45 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for mx-host values: <mx name>[[,<hostname>],<preference>]. +/// MX name required; optional hostname and/or preference (integer). +/// +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 or , or [,],."; + 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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/NaptrRecordSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/NaptrRecordSemanticHandler.cs new file mode 100644 index 0000000..af7b29a --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/NaptrRecordSemanticHandler.cs @@ -0,0 +1,33 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for naptr-record values: <name>,<order>,<preference>,<flags>,<service>,<regexp>[,<replacement>] (RFC3403). +/// Six required fields; optional replacement. +/// +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 ,,,,,[,]."; + 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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/PtrRecordSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/PtrRecordSemanticHandler.cs new file mode 100644 index 0000000..c950263 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/PtrRecordSemanticHandler.cs @@ -0,0 +1,31 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for ptr-record values: <name>[,<target>]. +/// Name required; optional target (both must be valid DNS names). +/// +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 or ,."; + 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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RaParamSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RaParamSemanticHandler.cs index c310911..f690ecd 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RaParamSemanticHandler.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RaParamSemanticHandler.cs @@ -23,8 +23,8 @@ public sealed class RaParamSemanticHandler : IOptionSemanticHandler 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."; + if (!DnsmasqRelaySyntax.IsInterfaceNameWithOptionalTrailingWildcard(tokens[0])) + return "ra-param must start with an interface name (optionally ending with *)."; var seenPriority = false; var seenMtu = false; @@ -68,7 +68,4 @@ public sealed class RaParamSemanticHandler : IOptionSemanticHandler return null; } - - private static bool IsInterfaceName(string value) => - value.Length > 0 && value.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' or '*'); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SrvHostSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SrvHostSemanticHandler.cs new file mode 100644 index 0000000..f586981 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SrvHostSemanticHandler.cs @@ -0,0 +1,37 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for srv-host values: <_service>.<_prot>[.<domain>],[<target>[,<port>[,<priority>[,<weight>]]]]. +/// Service name required; optional target, port (0-65535), priority, weight. +/// +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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SynthDomainSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SynthDomainSemanticHandler.cs new file mode 100644 index 0000000..9efb8df --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SynthDomainSemanticHandler.cs @@ -0,0 +1,29 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for synth-domain values: <domain>,<address range>[,<prefix>[*]]. +/// Domain and address range required; optional prefix (may end with *). +/// +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 ,
[,[*]]."; + 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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/TxtRecordSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/TxtRecordSemanticHandler.cs new file mode 100644 index 0000000..74ce254 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/TxtRecordSemanticHandler.cs @@ -0,0 +1,27 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Semantic validation for txt-record values: <name>[[,<text>],<text>]. +/// Name required (first comma-separated field); optional text strings (commas allowed in quoted strings). +/// +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; + } +}