diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionRulesTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionRulesTests.cs index 2b4c48c..7fa6e8b 100644 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionRulesTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionRulesTests.cs @@ -16,8 +16,7 @@ public class CrossOptionRulesTests var cfg = CrossOptionTestHelpers.BaselineConfig() with { NoResolv = true }; var rule = new NoResolvWithoutUpstreamsRule(); var issues = rule.Evaluate(Ctx(cfg)); - var key = EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server); + var key = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server); Assert.Single(issues); Assert.Equal(key, issues[0].FieldKey); Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); @@ -70,6 +69,38 @@ public class CrossOptionRulesTests Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); } + [Theory] + [InlineData("/10.in-addr.arpa/8.8.8.8")] + [InlineData("/31.172.in-addr.arpa/8.8.8.8")] + public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_rfc1918_zone_roots(string serverValue) + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + BogusPriv = true, + ServerValues = [serverValue] + }; + var rule = new BogusPrivBlocksPrivateReverseServerRule(); + Assert.Single(rule.Evaluate(Ctx(cfg))); + } + + [Theory] + [InlineData("/210.in-addr.arpa/8.8.8.8")] + [InlineData("/110.in-addr.arpa/8.8.8.8")] + [InlineData("/1.0.0.192.in-addr.arpa/8.8.8.8")] + [InlineData("/15.172.in-addr.arpa/8.8.8.8")] + [InlineData("/32.172.in-addr.arpa/8.8.8.8")] + [InlineData("/10.0.0.192.in-addr.arpa/8.8.8.8")] + public void BogusPrivBlocksPrivateReverseServerRule_No_issue_for_non_private_in_addr_substrings(string serverValue) + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + BogusPriv = true, + ServerValues = [serverValue] + }; + var rule = new BogusPrivBlocksPrivateReverseServerRule(); + Assert.Empty(rule.Evaluate(Ctx(cfg))); + } + [Fact] public void DnssecPrerequisitesRule_Error_when_build_lacks_dnssec() { @@ -78,7 +109,9 @@ public class CrossOptionRulesTests var ctx = new EffectiveConfigCrossOptionContext(status, []); var rule = new DnssecPrerequisitesRule(); var issues = rule.Evaluate(ctx); - Assert.Contains(issues, i => i.Severity == FieldIssueSeverity.Error); + var dnssecKey = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Dnssec); + Assert.Contains(issues, i => + i.Severity == FieldIssueSeverity.Error && i.FieldKey == dnssecKey); } [Fact] @@ -87,8 +120,10 @@ public class CrossOptionRulesTests var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true }; var rule = new DnssecPrerequisitesRule(); var issues = rule.Evaluate(Ctx(cfg)); + var trustAnchorKey = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.TrustAnchor); Assert.Contains(issues, i => i.Severity == FieldIssueSeverity.Warning && + i.FieldKey == trustAnchorKey && i.Message.Contains("trust-anchor", StringComparison.OrdinalIgnoreCase)); } @@ -104,6 +139,9 @@ public class CrossOptionRulesTests var issues = rule.Evaluate(Ctx(cfg)); Assert.Single(issues); Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); + Assert.Equal( + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.CacheSize), + issues[0].FieldKey); } [Fact] @@ -149,6 +187,9 @@ public class CrossOptionRulesTests var rule = new AddSubnetCacheBehaviorRule(); var issues = rule.Evaluate(Ctx(cfg)); Assert.Single(issues); + Assert.Equal( + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.AddSubnet), + issues[0].FieldKey); } [Fact] @@ -174,6 +215,52 @@ public class CrossOptionRulesTests Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); } + [Fact] + public void AddressLocalDnsmasq286CompatibilityRule_No_issue_when_matching_local_exists() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + AddressValues = ["/example.com/192.0.2.1"], + LocalValues = ["/example.com/"] + }; + var rule = new AddressLocalDnsmasq286CompatibilityRule(); + Assert.Empty(rule.Evaluate(Ctx(cfg))); + } + + [Fact] + public void DnssecCheckUnsignedRequiresDnssecRule_Warns_when_dnssec_check_unsigned_set_without_dnssec() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + Dnssec = false, + DnssecCheckUnsigned = "" + }; + var rule = new DnssecCheckUnsignedRequiresDnssecRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); + Assert.Equal( + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.DnssecCheckUnsigned), + issues[0].FieldKey); + } + + [Fact] + public void LocalServiceIgnoredByBindSettingsRule_Warns_when_local_service_with_interface_filters() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + LocalService = "net", + Interfaces = ["eth0"] + }; + var rule = new LocalServiceIgnoredByBindSettingsRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); + Assert.Equal( + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.LocalService), + issues[0].FieldKey); + } + [Fact] public void Pending_overlay_overrides_config_for_cross_option_rules() { diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/EffectiveConfigCrossOptionContextTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/EffectiveConfigCrossOptionContextTests.cs new file mode 100644 index 0000000..94015a1 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/EffectiveConfigCrossOptionContextTests.cs @@ -0,0 +1,46 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption; + +public class EffectiveConfigCrossOptionContextTests +{ + [Fact] + public void GetMulti_WhenPendingNewValueIsNull_ReturnsEmpty_NotDiskValues() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["1.1.1.1", "8.8.8.8"] }; + var pending = new[] + { + new PendingOptionChange("resolver", DnsmasqConfKeys.Server, new List { "1.1.1.1" }, null, null) + }; + var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending); + Assert.Empty(ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues)); + } + + [Fact] + public void GetMulti_WhenPendingNewValueIsList_ReturnsPending_NotDisk() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["9.9.9.9"] }; + var pending = new[] + { + new PendingOptionChange("resolver", DnsmasqConfKeys.Server, null, new List { "1.1.1.1" }, null) + }; + var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending); + var v = ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues); + Assert.Single(v); + Assert.Equal("1.1.1.1", v[0]); + } + + [Fact] + public void GetMulti_WhenPendingNewValueIsWrongType_ReturnsEmpty_NotDiskValues() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["1.1.1.1"] }; + var pending = new[] + { + new PendingOptionChange("resolver", DnsmasqConfKeys.Server, null, "not-a-list", null) + }; + var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending); + Assert.Empty(ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues)); + } +} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor index 110e0b1..70ece60 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor @@ -155,6 +155,7 @@ private void EnterEditMode() { Session.EnterEditMode(); + RefreshCrossOptionAfterEnteringEditMode(); StateHasChanged(); } @@ -206,6 +207,15 @@ Session.SetCrossOptionIssues(issues); } + /// + /// clears cross-option issues; repopulate from disk + pending. + /// + private void RefreshCrossOptionAfterEnteringEditMode() + { + if (Status != null) + RunCrossOptionEvaluator(); + } + private void OnRevertedFromSaveModal() { RunCrossOptionEvaluator(); @@ -214,7 +224,10 @@ private void OpenSaveModal() { - if (Session.PendingChanges.Count == 0) return; + var hasPending = Session.PendingChanges.Count > 0; + var hasValidation = Session.GetValidationSummary().Count > 0; + if (!hasPending && !hasValidation) + return; _showSaveModal = true; StateHasChanged(); } @@ -231,6 +244,7 @@ Session.ExitEditModeDiscard(); await OnSaveCompleted.InvokeAsync(); Session.EnterEditMode(); + RefreshCrossOptionAfterEnteringEditMode(); StateHasChanged(); } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigToolbar.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigToolbar.razor index fd88180..dc17333 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigToolbar.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigToolbar.razor @@ -37,7 +37,7 @@ { @ValidationErrorCount error(s) @@ -48,7 +48,7 @@ { @ValidationWarningCount warning(s) diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionContext.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionContext.cs index dcdc0ef..1406ff1 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionContext.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionContext.cs @@ -58,10 +58,13 @@ public sealed class EffectiveConfigCrossOptionContext public IReadOnlyList GetMulti(string optionName, Func> fromConfig) { - if (_pendingByOption.TryGetValue(optionName, out var change) && - change.NewValue is IReadOnlyList list) + if (_pendingByOption.TryGetValue(optionName, out var change)) { - return list; + if (change.NewValue is IReadOnlyList list) + return list; + // Pending row wins over disk: null or non-list (e.g. bad payload) => treat as empty multi-value, + // same spirit as GetInt returning null / GetBool returning false when the pending shape does not match. + return []; } return Status?.EffectiveConfig is { } cfg ? fromConfig(cfg) : []; @@ -84,6 +87,14 @@ public sealed class EffectiveConfigCrossOptionContext return Status?.EffectiveConfig?.ConnmarkAllowlistEnable is not null; } + /// Explicit section (e.g. attach an issue to a different field than the option that triggered the rule). public static string FieldKey(string sectionId, string optionName) => $"{sectionId}:{optionName}"; + + /// + /// Field key for using the same option → section mapping as the effective-config UI + /// (). Prefer this for cross-option rules so badges merge correctly. + /// + public static string FieldKeyForOption(string optionName) => + FieldKey(EffectiveConfigSections.GetSectionId(optionName), optionName); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Rules/CrossOptionRules.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Rules/CrossOptionRules.cs index 6e95da9..15d38d5 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Rules/CrossOptionRules.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Rules/CrossOptionRules.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Text.RegularExpressions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; @@ -23,9 +24,7 @@ public sealed class NoResolvWithoutUpstreamsRule : IEffectiveConfigCrossOptionRu return [ new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.Server), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server), "With no-resolv, dnsmasq does not read /etc/resolv.conf. Add at least one server= or resolv-file= so upstream resolvers are defined; otherwise DNS forwarding may not work.", FieldIssueSeverity.Warning) ]; @@ -46,9 +45,7 @@ public sealed class ConntrackWithQueryPortRule : IEffectiveConfigCrossOptionRule return [ new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.QueryPort), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.QueryPort), "dnsmasq does not allow query-port together with conntrack: conntrack copies Linux connection marks onto upstream DNS traffic, which needs the usual ephemeral source ports, not a fixed query port. Clear query-port (or set conntrack off) so the daemon can start.", FieldIssueSeverity.Error) ]; @@ -71,9 +68,7 @@ public sealed class RebindExceptionsRequireStopDnsRebindRule : IEffectiveConfigC if (localhostOk) { issues.Add(new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.RebindLocalhostOk), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RebindLocalhostOk), "rebind-localhost-ok only applies when stop-dns-rebind is on. Enable stop-dns-rebind or remove this flag to avoid a misleading configuration.", FieldIssueSeverity.Warning)); } @@ -82,9 +77,7 @@ public sealed class RebindExceptionsRequireStopDnsRebindRule : IEffectiveConfigC if (domainOk.Count > 0) { issues.Add(new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.RebindDomainOk), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RebindDomainOk), "rebind-domain-ok only applies when stop-dns-rebind is on. Enable stop-dns-rebind or clear these exceptions so they are not silently ignored.", FieldIssueSeverity.Warning)); } @@ -104,11 +97,7 @@ public sealed class BogusPrivBlocksPrivateReverseServerRule : IEffectiveConfigCr return []; var servers = context.GetMulti(DnsmasqConfKeys.Server, cfg => cfg.ServerValues); - var hasPrivateReverseServer = servers.Any(v => - v.Contains("in-addr.arpa", StringComparison.OrdinalIgnoreCase) && - (v.Contains("192", StringComparison.OrdinalIgnoreCase) || - v.Contains("172", StringComparison.OrdinalIgnoreCase) || - v.Contains("10", StringComparison.OrdinalIgnoreCase))); + var hasPrivateReverseServer = servers.Any(LooksLikeRfc1918InAddrArpaServer); if (!hasPrivateReverseServer) return []; @@ -116,13 +105,34 @@ public sealed class BogusPrivBlocksPrivateReverseServerRule : IEffectiveConfigCr return [ new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.Server), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server), "bogus-priv takes priority over private reverse lookup forwarding, so those PTR queries may never reach the configured upstream server.", FieldIssueSeverity.Warning) ]; } + + // RFC1918 reverse zone labels only; avoids substring false positives (e.g. 210, 192.0.0.x, 172.15). + private static bool LooksLikeRfc1918InAddrArpaServer(string v) + { + if (string.IsNullOrEmpty(v) || !v.Contains("in-addr.arpa", StringComparison.OrdinalIgnoreCase)) + return false; + + const RegexOptions re = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + + // 192.168.0.0/16 → *.168.192.in-addr.arpa + if (Regex.IsMatch(v, @"(^|[/.])168\.192\.in-addr\.arpa", re)) + return true; + + // 10.0.0.0/8 → /10.in-addr.arpa/ or PTR ... .10.in-addr.arpa + if (Regex.IsMatch(v, @"(^|[/.])10\.in-addr\.arpa", re)) + return true; + + // 172.16.0.0–172.31.255.255 → [16–31].172.in-addr.arpa + if (Regex.IsMatch(v, @"(^|[/.])(1[6-9]|2[0-9]|3[01])\.172\.in-addr\.arpa", re)) + return true; + + return false; + } } public sealed class DnssecPrerequisitesRule : IEffectiveConfigCrossOptionRule @@ -140,9 +150,7 @@ public sealed class DnssecPrerequisitesRule : IEffectiveConfigCrossOptionRule if (context.Status is { DnsmasqSupportsDnssec: false }) { issues.Add(new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.Dnssec), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Dnssec), "This dnsmasq binary does not report DNSSEC in its compile capabilities. Enabling dnssec will probably fail at startup; install a build with DNSSEC or turn dnssec off.", FieldIssueSeverity.Error)); } @@ -151,9 +159,7 @@ public sealed class DnssecPrerequisitesRule : IEffectiveConfigCrossOptionRule if (trustAnchors.Count == 0) { issues.Add(new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.TrustAnchor), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.TrustAnchor), "dnssec is on but no trust-anchor entries are set, so validation cannot anchor the chain of trust. Add trust-anchor lines (or disable dnssec) for DNSSEC to be meaningful.", FieldIssueSeverity.Warning)); } @@ -179,9 +185,7 @@ public sealed class ProxyDnssecCacheWarningRule : IEffectiveConfigCrossOptionRul return [ new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.CacheSize), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.CacheSize), "With proxy-dnssec, the dnsmasq docs recommend cache-size=0 if clients rely on the AD bit.", FieldIssueSeverity.Warning) ]; @@ -206,9 +210,7 @@ public sealed class ConnmarkAllowlistEnableRequiresAllowlistRule : IEffectiveCon return [ new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.ConnmarkAllowlist), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.ConnmarkAllowlist), "connmark-allowlist-enable is on but there are no connmark-allowlist= rules, so dnsmasq may refuse DNS for marked connections. Add at least one allowlist rule or disable connmark-allowlist-enable.", FieldIssueSeverity.Error) ]; @@ -231,9 +233,7 @@ public sealed class QueryPortIgnoredForSourceBoundServerRule : IEffectiveConfigC return [ new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.QueryPort), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.QueryPort), "query-port does not apply to server= lines that bind a source address or interface (the part after @). Those queries still use ephemeral ports; remove query-port or adjust server bindings if you expected a fixed source port everywhere.", FieldIssueSeverity.Warning) ]; @@ -253,9 +253,7 @@ public sealed class AddSubnetCacheBehaviorRule : IEffectiveConfigCrossOptionRule return [ new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.AddSubnet), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.AddSubnet), "add-subnet can disable caching for replies that vary by client subnet unless the forwarded subnet is constant.", FieldIssueSeverity.Warning) ]; @@ -275,9 +273,7 @@ public sealed class Filterwin2kSrvWarningRule : IEffectiveConfigCrossOptionRule return [ new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.Filterwin2k), + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Filterwin2k), "filterwin2k blocks SRV queries and can break Kerberos, SIP, XMPP, or similar service discovery.", FieldIssueSeverity.Warning) ]; @@ -294,31 +290,108 @@ public sealed class AddressLocalDnsmasq286CompatibilityRule : IEffectiveConfigCr if (addresses.Count == 0) return []; - var hasDomainLiteral = addresses.Any(LooksLikeDomainAddressDirective); - if (!hasDomainLiteral) + var addressDomains = addresses + .SelectMany(GetDomainsFromAddressDirective) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (addressDomains.Count == 0) + return []; + + var localDomains = context.GetMulti(DnsmasqConfKeys.Local, cfg => cfg.LocalValues) + .SelectMany(GetDomainsFromLocalDirective) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var uncovered = addressDomains.Where(d => !localDomains.Contains(d)).ToList(); + if (uncovered.Count == 0) + return []; + + var preview = string.Join(", ", uncovered.Take(3)); + var extra = uncovered.Count > 3 ? ", ..." : ""; + return + [ + new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Address), + $"From dnsmasq 2.86, address= with a domain and IP may forward non-A/AAAA queries upstream unless the domain is also covered by local=. Add matching local= entries for: {preview}{extra}.", + FieldIssueSeverity.Warning) + ]; + } + + private static IReadOnlyList GetDomainsFromAddressDirective(string value) + { + var t = value.Trim(); + if (!t.StartsWith('/')) + return []; + var end = t.LastIndexOf('/'); + if (end <= 1) + return []; + + var domainsPart = t[1..end]; + return domainsPart + .Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Where(d => !string.Equals(d, "#", StringComparison.Ordinal)) + .ToList(); + } + + private static IReadOnlyList GetDomainsFromLocalDirective(string value) + { + var t = value.Trim(); + if (!t.StartsWith('/') || !t.EndsWith('/') || t.Length < 3) + return []; + + var domainsPart = t[1..^1]; + return domainsPart + .Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Where(d => !string.Equals(d, "#", StringComparison.Ordinal)) + .ToList(); + } +} + +public sealed class DnssecCheckUnsignedRequiresDnssecRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.dnssec-check-unsigned-requires-dnssec"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + var dnssecCheckUnsigned = context.GetString(DnsmasqConfKeys.DnssecCheckUnsigned, cfg => cfg.DnssecCheckUnsigned); + if (dnssecCheckUnsigned is null) + return []; + + if (context.GetBool(DnsmasqConfKeys.Dnssec, cfg => cfg.Dnssec)) return []; return [ new FieldIssue( - EffectiveConfigCrossOptionContext.FieldKey( - EffectiveConfigSections.SectionResolver, - DnsmasqConfKeys.Address), - "From dnsmasq 2.86, address= with a domain and IP may forward non-A/AAAA queries upstream; add local= for that domain if you need the old NoData behavior.", + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.DnssecCheckUnsigned), + "dnssec-check-unsigned only has effect when dnssec is enabled. Enable dnssec or remove dnssec-check-unsigned to avoid a no-op setting.", FieldIssueSeverity.Warning) ]; } +} - private static bool LooksLikeDomainAddressDirective(string value) +public sealed class LocalServiceIgnoredByBindSettingsRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.local-service-ignored-by-bind-settings"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) { - var t = value.Trim(); - if (!t.StartsWith('/')) - return false; - var rest = t[1..]; - var idx = rest.IndexOf('/'); - if (idx <= 0) - return false; - var domain = rest[..idx]; - return domain.Length > 0 && !string.Equals(domain, "#", StringComparison.Ordinal); + if (context.GetString(DnsmasqConfKeys.LocalService, cfg => cfg.LocalService) is null) + return []; + + var hasInterface = context.GetMulti(DnsmasqConfKeys.Interface, cfg => cfg.Interfaces).Count > 0; + var hasExceptInterface = context.GetMulti(DnsmasqConfKeys.ExceptInterface, cfg => cfg.ExceptInterfaces).Count > 0; + var hasListenAddress = context.GetMulti(DnsmasqConfKeys.ListenAddress, cfg => cfg.ListenAddresses).Count > 0; + var hasAuthServer = context.GetMulti(DnsmasqConfKeys.AuthServer, cfg => cfg.AuthServerValues).Count > 0; + + if (!hasInterface && !hasExceptInterface && !hasListenAddress && !hasAuthServer) + return []; + + return + [ + new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.LocalService), + "local-service is ignored when interface, except-interface, listen-address, or auth-server is configured. Remove local-service or rely on explicit bind/listen settings.", + FieldIssueSeverity.Warning) + ]; } }