From a3dc5ac45c629945394c88b6726b7bd4c7af8871 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sun, 29 Mar 2026 21:35:30 +1000 Subject: [PATCH] EffectiveConfig: replace cross-option evaluator with rule service Move cross-option checks to DI-backed rules and shared context so editor and quick-add flows use one validation pipeline. Improve warning copy and save-modal messaging to clarify that validation reflects both on-disk config and pending edits. --- .../CrossOption/CrossOptionRulesTests.cs | 190 ++++++++++ .../CrossOption/CrossOptionTestHelpers.cs | 226 ++++++++++++ ...ConfigCrossOptionValidationServiceTests.cs | 30 ++ .../Composition/EffectiveConfigSection.razor | 5 +- .../Modals/EffectiveConfigSaveModal.razor | 9 +- .../EffectiveConfigPageEditor.cs | 13 +- .../IEffectiveConfigCrossOptionRule.cs | 11 + ...ctiveConfigCrossOptionValidationService.cs | 12 + .../EffectiveConfigCrossOptionContext.cs | 89 +++++ .../EffectiveConfigCrossOptionEvaluator.cs | 92 ----- ...ctiveConfigCrossOptionValidationService.cs | 25 ++ .../Validation/Rules/CrossOptionRules.cs | 324 ++++++++++++++++++ testdata/dnsmasq-test.conf | 1 + 13 files changed, 929 insertions(+), 98 deletions(-) create mode 100644 src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionRulesTests.cs create mode 100644 src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionTestHelpers.cs create mode 100644 src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/EffectiveConfigCrossOptionValidationServiceTests.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Abstractions/IEffectiveConfigCrossOptionRule.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Abstractions/IEffectiveConfigCrossOptionValidationService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionContext.cs delete mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionValidationService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Rules/CrossOptionRules.cs diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionRulesTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionRulesTests.cs new file mode 100644 index 0000000..2b4c48c --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionRulesTests.cs @@ -0,0 +1,190 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Rules; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption; + +public class CrossOptionRulesTests +{ + private static EffectiveConfigCrossOptionContext Ctx(EffectiveDnsmasqConfig cfg) => + new(CrossOptionTestHelpers.Status(cfg), []); + + [Fact] + public void NoResolvWithoutUpstreamsRule_Warns_when_no_server_and_no_resolv_file() + { + 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); + Assert.Single(issues); + Assert.Equal(key, issues[0].FieldKey); + Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); + } + + [Fact] + public void NoResolvWithoutUpstreamsRule_No_issue_when_resolv_file_configured() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + NoResolv = true, + ResolvFiles = ["/run/resolv.conf"] + }; + var rule = new NoResolvWithoutUpstreamsRule(); + Assert.Empty(rule.Evaluate(Ctx(cfg))); + } + + [Fact] + public void ConntrackWithQueryPortRule_Error_when_both_set() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { Conntrack = true, QueryPort = 5353 }; + var rule = new ConntrackWithQueryPortRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + Assert.Equal(FieldIssueSeverity.Error, issues[0].Severity); + Assert.Contains("query-port", issues[0].Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RebindExceptionsRequireStopDnsRebindRule_Warns_on_localhost_ok_without_stop() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { RebindLocalhostOk = true }; + var rule = new RebindExceptionsRequireStopDnsRebindRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); + } + + [Fact] + public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_private_in_addr_server() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + BogusPriv = true, + ServerValues = ["/0.168.192.in-addr.arpa/10.0.0.1"] + }; + var rule = new BogusPrivBlocksPrivateReverseServerRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); + } + + [Fact] + public void DnssecPrerequisitesRule_Error_when_build_lacks_dnssec() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true }; + var status = CrossOptionTestHelpers.Status(cfg, dnsmasqSupportsDnssec: false); + var ctx = new EffectiveConfigCrossOptionContext(status, []); + var rule = new DnssecPrerequisitesRule(); + var issues = rule.Evaluate(ctx); + Assert.Contains(issues, i => i.Severity == FieldIssueSeverity.Error); + } + + [Fact] + public void DnssecPrerequisitesRule_Warns_when_no_trust_anchors() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true }; + var rule = new DnssecPrerequisitesRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Contains(issues, i => + i.Severity == FieldIssueSeverity.Warning && + i.Message.Contains("trust-anchor", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ProxyDnssecCacheWarningRule_Warns_when_cache_not_zero() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + ProxyDnssec = true, + CacheSize = 150 + }; + var rule = new ProxyDnssecCacheWarningRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); + } + + [Fact] + public void ConnmarkAllowlistEnableRequiresAllowlistRule_Error_when_enable_key_only() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { ConnmarkAllowlistEnable = "" }; + var rule = new ConnmarkAllowlistEnableRequiresAllowlistRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + Assert.Equal(FieldIssueSeverity.Error, issues[0].Severity); + } + + [Fact] + public void ConnmarkAllowlistEnableRequiresAllowlistRule_No_issue_when_allowlists_present() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + ConnmarkAllowlistEnable = "0xff", + ConnmarkAllowlistValues = ["0x1,*.example.com"] + }; + var rule = new ConnmarkAllowlistEnableRequiresAllowlistRule(); + Assert.Empty(rule.Evaluate(Ctx(cfg))); + } + + [Fact] + public void QueryPortIgnoredForSourceBoundServerRule_Warns_when_server_has_at() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + QueryPort = 12345, + ServerValues = ["10.0.0.1@192.168.1.1"] + }; + var rule = new QueryPortIgnoredForSourceBoundServerRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); + } + + [Fact] + public void AddSubnetCacheBehaviorRule_Warns_when_add_subnet_set() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { AddSubnet = "24,96" }; + var rule = new AddSubnetCacheBehaviorRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + } + + [Fact] + public void Filterwin2kSrvWarningRule_Warns_when_enabled() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { Filterwin2k = true }; + var rule = new Filterwin2kSrvWarningRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + Assert.Contains("SRV", issues[0].Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AddressLocalDnsmasq286CompatibilityRule_Warns_for_domain_address() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + AddressValues = ["/example.com/192.0.2.1"] + }; + var rule = new AddressLocalDnsmasq286CompatibilityRule(); + var issues = rule.Evaluate(Ctx(cfg)); + Assert.Single(issues); + Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); + } + + [Fact] + public void Pending_overlay_overrides_config_for_cross_option_rules() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with { NoResolv = false }; + var pending = new[] + { + new PendingOptionChange("resolver", DnsmasqConfKeys.NoResolv, false, true, null) + }; + var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending); + var rule = new NoResolvWithoutUpstreamsRule(); + var issues = rule.Evaluate(ctx); + Assert.Single(issues); + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionTestHelpers.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionTestHelpers.cs new file mode 100644 index 0000000..080a119 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/CrossOptionTestHelpers.cs @@ -0,0 +1,226 @@ +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption; + +internal static class CrossOptionTestHelpers +{ + /// Minimal baseline effective config; override with record with in tests. + public static EffectiveDnsmasqConfig BaselineConfig() => + new( + NoHosts: false, + AddnHostsPaths: Array.Empty(), + HostsdirPath: null, + ServerValues: Array.Empty(), + LocalValues: Array.Empty(), + RevServerValues: Array.Empty(), + AddressValues: Array.Empty(), + Interfaces: Array.Empty(), + ListenAddresses: Array.Empty(), + ExceptInterfaces: Array.Empty(), + DhcpRanges: Array.Empty(), + DhcpHostLines: Array.Empty(), + DhcpOptionLines: Array.Empty(), + DhcpMatchValues: Array.Empty(), + DhcpBootValues: Array.Empty(), + DhcpIgnoreValues: Array.Empty(), + DhcpVendorclassValues: Array.Empty(), + DhcpUserclassValues: Array.Empty(), + RaParamValues: Array.Empty(), + SlaacValues: Array.Empty(), + PxeServiceValues: Array.Empty(), + TrustAnchorValues: Array.Empty(), + ResolvFiles: Array.Empty(), + RebindDomainOkValues: Array.Empty(), + BogusNxdomainValues: Array.Empty(), + IgnoreAddressValues: Array.Empty(), + AliasValues: Array.Empty(), + FilterRrValues: Array.Empty(), + CacheRrValues: Array.Empty(), + AuthServerValues: Array.Empty(), + NoDhcpInterfaceValues: Array.Empty(), + NoDhcpv4InterfaceValues: Array.Empty(), + NoDhcpv6InterfaceValues: Array.Empty(), + DomainValues: Array.Empty(), + CnameValues: Array.Empty(), + MxHostValues: Array.Empty(), + SrvValues: Array.Empty(), + PtrRecordValues: Array.Empty(), + TxtRecordValues: Array.Empty(), + NaptrRecordValues: Array.Empty(), + HostRecordValues: Array.Empty(), + DynamicHostValues: Array.Empty(), + InterfaceNameValues: Array.Empty(), + DhcpOptionForceLines: Array.Empty(), + IpsetValues: Array.Empty(), + NftsetValues: Array.Empty(), + DhcpMacValues: Array.Empty(), + DhcpNameMatchValues: Array.Empty(), + DhcpIgnoreNamesValues: Array.Empty(), + DhcpHostsfilePaths: Array.Empty(), + DhcpOptsfilePaths: Array.Empty(), + DhcpHostsdirPaths: Array.Empty(), + DhcpOptsdirPaths: Array.Empty(), + ConnmarkAllowlistValues: Array.Empty(), + CaaRecordValues: Array.Empty(), + DnsRrValues: Array.Empty(), + SynthDomainValues: Array.Empty(), + AuthZoneValues: Array.Empty(), + AuthSoaValues: Array.Empty(), + AuthSecServersValues: Array.Empty(), + AuthPeerValues: Array.Empty(), + DhcpRelayValues: Array.Empty(), + DhcpCircuitidValues: Array.Empty(), + DhcpRemoteidValues: Array.Empty(), + DhcpSubscridValues: Array.Empty(), + DhcpProxyValues: Array.Empty(), + TagIfValues: Array.Empty(), + BridgeInterfaceValues: Array.Empty(), + SharedNetworkValues: Array.Empty(), + DhcpOptionPxeValues: Array.Empty(), + ExpandHosts: false, + BogusPriv: false, + StrictOrder: false, + AllServers: false, + NoResolv: false, + DomainNeeded: false, + NoPoll: false, + BindInterfaces: false, + BindDynamic: false, + NoNegcache: false, + DnsLoopDetect: false, + StopDnsRebind: false, + RebindLocalhostOk: false, + ClearOnReload: false, + Filterwin2k: false, + FilterA: false, + FilterAaaa: false, + LocaliseQueries: false, + LogDebug: false, + DhcpAuthoritative: false, + LeasefileRo: false, + EnableTftp: false, + TftpSecure: false, + TftpNoFail: false, + TftpNoBlocksize: false, + Dnssec: false, + DnssecCheckUnsigned: null, + ReadEthers: false, + DhcpRapidCommit: false, + Localmx: false, + Selfmx: false, + EnableRa: false, + LogDhcp: false, + KeepInForeground: false, + NoDaemon: false, + ProxyDnssec: false, + ConnmarkAllowlistEnable: null, + NoRoundRobin: false, + DnssecNoTimecheck: false, + DnssecDebug: false, + LeasequeryValues: Array.Empty(), + DhcpGenerateNames: null, + DhcpBroadcast: null, + DhcpSequentialIp: false, + DhcpIgnoreClid: false, + BootpDynamic: null, + NoPing: false, + ScriptArp: false, + ScriptOnRenewal: false, + DhcpNoOverride: false, + QuietDhcp: false, + QuietDhcp6: false, + QuietRa: false, + QuietTftp: false, + DhcpLeaseFilePath: null, + CacheSize: null, + Port: null, + LocalTtl: null, + PidFilePath: null, + User: null, + Group: null, + LogFacility: null, + LogQueries: null, + AuthTtl: null, + EdnsPacketMax: null, + QueryPort: null, + PortLimit: null, + MinPort: null, + MaxPort: null, + LogAsync: null, + LocalService: null, + DhcpLeaseMax: null, + NegTtl: null, + MaxTtl: null, + MaxCacheTtl: null, + MinCacheTtl: null, + DhcpTtl: null, + TftpRootPath: null, + PxePrompt: null, + EnableDbus: null, + EnableUbus: null, + FastDnsRetry: null, + DhcpScriptPath: null, + MxTarget: null, + DnsForwardMax: null, + DumpfilePath: null, + Dumpmask: null, + AddCpeId: null, + DnssecTimestamp: null, + DnssecLimits: null, + DhcpAlternatePort: null, + DhcpDuid: null, + DhcpLuascriptPath: null, + DhcpScriptuser: null, + DhcpPxeVendor: null, + UseStaleCache: null, + AddMac: null, + StripMac: false, + AddSubnet: null, + StripSubnet: false, + Umbrella: null, + Do0x20EncodeState: ExplicitToggleState.Default, + Conntrack: false); + + public static DnsmasqServiceStatus Status(EffectiveDnsmasqConfig config, bool dnsmasqSupportsDnssec = true) => + new( + SystemHostsPath: null, + SystemHostsPathExists: false, + ManagedHostsFilePath: null, + ManagedHostsPathExists: false, + NoHosts: false, + AddnHostsPaths: Array.Empty(), + EffectiveConfig: config, + EffectiveConfigSources: null, + MainConfigPath: null, + ManagedFilePath: null, + LeasesPath: null, + MainConfigPathExists: false, + ManagedFilePathExists: false, + LeasesPathConfigured: false, + LeasesPathExists: false, + ConfigFiles: null, + ReloadCommandConfigured: false, + StatusCommandConfigured: false, + StatusShowConfigured: false, + LogsConfigured: false, + LogsPath: null, + StatusShowCommand: null, + LogsCommand: null, + DnsmasqStatus: "active", + StatusCommandExitCode: null, + StatusCommandStdout: null, + StatusCommandStderr: null, + StatusShowOutput: null, + LogsOutput: null, + DhcpRangeStart: null, + DhcpRangeEnd: null, + DnsmasqVersion: "2.91", + MinimumSupportedDnsmasqVersion: "2.91", + DnsmasqVersionSupported: true, + DnsmasqVersionError: null, + DnsmasqSupportsDhcp: true, + DnsmasqSupportsTftp: true, + DnsmasqSupportsDnssec: dnsmasqSupportsDnssec, + DnsmasqSupportsDbus: false); +} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/EffectiveConfigCrossOptionValidationServiceTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/EffectiveConfigCrossOptionValidationServiceTests.cs new file mode 100644 index 0000000..9f0b619 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/CrossOption/EffectiveConfigCrossOptionValidationServiceTests.cs @@ -0,0 +1,30 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Rules; + +namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption; + +public class EffectiveConfigCrossOptionValidationServiceTests +{ + [Fact] + public void Validate_aggregates_issues_from_all_rules() + { + var cfg = CrossOptionTestHelpers.BaselineConfig() with + { + Conntrack = true, + QueryPort = 1, + Filterwin2k = true + }; + var status = CrossOptionTestHelpers.Status(cfg); + IReadOnlyList rules = + [ + new ConntrackWithQueryPortRule(), + new Filterwin2kSrvWarningRule() + ]; + var service = new EffectiveConfigCrossOptionValidationService(rules); + + var issues = service.Validate(status, []); + + Assert.Equal(2, issues.Count); + } +} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor index df81e64..110e0b1 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor @@ -2,8 +2,9 @@ @using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions -@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions @inject IEffectiveConfigEditSession Session +@inject IEffectiveConfigCrossOptionValidationService CrossOptionValidation @inject IJSRuntime JSRuntime @implements IDisposable @@ -201,7 +202,7 @@ private void RunCrossOptionEvaluator() { var optionChanges = Session.PendingChanges.OfType().ToList(); - var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, optionChanges); + var issues = CrossOptionValidation.Validate(Status, optionChanges); Session.SetCrossOptionIssues(issues); } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor index 8195930..2dc70ec 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor @@ -114,6 +114,10 @@ @if (ValidationErrors.Count > 0 || ValidationWarnings.Count > 0) {
+

+ Validation uses your effective configuration: values already on disk plus any pending edits listed below. + Reverting pending edits only clears issues that those edits introduced; messages can remain when they reflect saved settings. +

@if (ValidationErrors.Count > 0) {
@@ -140,7 +144,10 @@ }
} -

These changes will be written to your config. Remove any you don't want to keep.

+ @if (PendingChanges.Count > 0) + { +

These changes will be written to your config. Remove any you don't want to keep.

+ } @if (TargetFileNames.Count > 0) {

diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs index 4b83304..e2b05be 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs @@ -1,5 +1,5 @@ using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; -using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; @@ -13,11 +13,16 @@ public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor { private readonly IEffectiveConfigEditSession _session; private readonly IEffectiveConfigDescriptorProvider _descriptorProvider; + private readonly IEffectiveConfigCrossOptionValidationService _crossOptionValidation; - public EffectiveConfigPageEditor(IEffectiveConfigEditSession session, IEffectiveConfigDescriptorProvider descriptorProvider) + public EffectiveConfigPageEditor( + IEffectiveConfigEditSession session, + IEffectiveConfigDescriptorProvider descriptorProvider, + IEffectiveConfigCrossOptionValidationService crossOptionValidation) { _session = session; _descriptorProvider = descriptorProvider ?? throw new ArgumentNullException(nameof(descriptorProvider)); + _crossOptionValidation = crossOptionValidation ?? throw new ArgumentNullException(nameof(crossOptionValidation)); } public void EnsureEditMode() @@ -115,7 +120,9 @@ public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor public void RefreshCrossOptionIssues(DnsmasqServiceStatus status) { - var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(status, _session.PendingChanges.OfType().ToList()); + var issues = _crossOptionValidation.Validate( + status, + _session.PendingChanges.OfType().ToList()); _session.SetCrossOptionIssues(issues); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Abstractions/IEffectiveConfigCrossOptionRule.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Abstractions/IEffectiveConfigCrossOptionRule.cs new file mode 100644 index 0000000..b739448 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Abstractions/IEffectiveConfigCrossOptionRule.cs @@ -0,0 +1,11 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; + +public interface IEffectiveConfigCrossOptionRule : IApplicationMultiSingleton +{ + string Id { get; } + + IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Abstractions/IEffectiveConfigCrossOptionValidationService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Abstractions/IEffectiveConfigCrossOptionValidationService.cs new file mode 100644 index 0000000..4ac7db5 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Abstractions/IEffectiveConfigCrossOptionValidationService.cs @@ -0,0 +1,12 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; + +public interface IEffectiveConfigCrossOptionValidationService : IApplicationSingleton +{ + IReadOnlyList Validate( + DnsmasqServiceStatus? status, + IReadOnlyList pending); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionContext.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionContext.cs new file mode 100644 index 0000000..dcdc0ef --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionContext.cs @@ -0,0 +1,89 @@ +using System.Linq; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +///

+/// Effective option values for cross-option rules: parsed config overlaid with pending edits. +/// +public sealed class EffectiveConfigCrossOptionContext +{ + private readonly Dictionary _pendingByOption; + + public DnsmasqServiceStatus? Status { get; } + + public EffectiveConfigCrossOptionContext( + DnsmasqServiceStatus? status, + IReadOnlyList pending) + { + Status = status; + _pendingByOption = pending + .GroupBy(p => p.OptionName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Last(), StringComparer.OrdinalIgnoreCase); + } + + public bool GetBool(string optionName, Func fromConfig) + { + if (_pendingByOption.TryGetValue(optionName, out var change)) + { + if (change.NewValue is bool b) + return b; + return false; + } + + return Status?.EffectiveConfig is { } cfg && fromConfig(cfg); + } + + public int? GetInt(string optionName, Func fromConfig) + { + if (_pendingByOption.TryGetValue(optionName, out var change)) + { + if (change.NewValue is int i) + return i; + return null; + } + + return Status?.EffectiveConfig is { } cfg ? fromConfig(cfg) : null; + } + + public string? GetString(string optionName, Func fromConfig) + { + if (_pendingByOption.TryGetValue(optionName, out var change)) + return change.NewValue?.ToString(); + + return Status?.EffectiveConfig is { } cfg ? fromConfig(cfg) : null; + } + + public IReadOnlyList GetMulti(string optionName, Func> fromConfig) + { + if (_pendingByOption.TryGetValue(optionName, out var change) && + change.NewValue is IReadOnlyList list) + { + return list; + } + + return Status?.EffectiveConfig is { } cfg ? fromConfig(cfg) : []; + } + + /// + /// True when connmark-allowlist-enable is present (key-only uses empty string in effective config). + /// + public bool IsConnmarkAllowlistFilteringEnabled() + { + if (_pendingByOption.TryGetValue(DnsmasqConfKeys.ConnmarkAllowlistEnable, out var change)) + { + if (change.NewValue == null) + return false; + if (change.NewValue is bool b) + return b; + return true; + } + + return Status?.EffectiveConfig?.ConnmarkAllowlistEnable is not null; + } + + public static string FieldKey(string sectionId, string optionName) + => $"{sectionId}:{optionName}"; +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs deleted file mode 100644 index b7e418b..0000000 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs +++ /dev/null @@ -1,92 +0,0 @@ -using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; -using DnsmasqWebUI.Models.Dnsmasq; -using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; - -namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; - -/// -/// Evaluates cross-option rules on effective config (status merged with pending changes). -/// Returns validation issues (warnings/errors) for the UI and save guard. -/// -public static class EffectiveConfigCrossOptionEvaluator -{ - /// - /// Runs all cross-option rules and returns issues to display. Caller should pass result to . - /// - public static IReadOnlyList Evaluate( - DnsmasqServiceStatus? status, - IReadOnlyList pending) - { - var issues = new List(); - - var noResolv = GetEffectiveBool(status, pending, DnsmasqConfKeys.NoResolv, s => s?.EffectiveConfig?.NoResolv ?? false); - var serverValues = GetEffectiveServerValues(status, pending); - - // no-resolv set with no upstream servers: DNS may not work - if (noResolv && (serverValues == null || serverValues.Count == 0)) - { - var fieldKey = $"{EffectiveConfigSections.SectionResolver}:{DnsmasqConfKeys.Server}"; - issues.Add(new FieldIssue( - fieldKey, - "When no-resolv is set, add at least one server or DNS may not work.", - FieldIssueSeverity.Warning, - ItemIndex: null)); - } - - // conntrack cannot be combined with query-port (dnsmasq man page) - var conntrack = GetEffectiveBool(status, pending, DnsmasqConfKeys.Conntrack, s => s?.EffectiveConfig?.Conntrack ?? false); - var queryPort = GetEffectiveInt(status, pending, DnsmasqConfKeys.QueryPort, s => s?.EffectiveConfig?.QueryPort); - if (conntrack && queryPort is not null) - { - issues.Add(new FieldIssue( - $"{EffectiveConfigSections.SectionResolver}:{DnsmasqConfKeys.QueryPort}", - "query-port cannot be combined with conntrack.", - FieldIssueSeverity.Error, - null)); - } - - return issues; - } - - private static bool GetEffectiveBool( - DnsmasqServiceStatus? status, - IReadOnlyList? pending, - string optionName, - Func fromConfig) - { - var pendingChange = pending?.FirstOrDefault(c => string.Equals(c.OptionName, optionName, StringComparison.Ordinal)); - if (pendingChange != null) - { - if (pendingChange.NewValue is bool b) - return b; - return false; // pending change to clear or invalid; treat as off for cross-option purposes - } - return fromConfig(status); - } - - private static int? GetEffectiveInt( - DnsmasqServiceStatus? status, - IReadOnlyList? pending, - string optionName, - Func fromConfig) - { - var pendingChange = pending?.FirstOrDefault(c => string.Equals(c.OptionName, optionName, StringComparison.Ordinal)); - if (pendingChange != null) - { - if (pendingChange.NewValue is int i) - return i; - return null; // pending change to clear the value; use null so conntrack+query-port rule no longer blocks - } - return fromConfig(status); - } - - private static IReadOnlyList? GetEffectiveServerValues(DnsmasqServiceStatus? status, IReadOnlyList pending) - { - var fromConfig = status?.EffectiveConfig?.ServerValues; - var pendingChange = pending?.FirstOrDefault(c => - string.Equals(c.OptionName, DnsmasqConfKeys.Server, StringComparison.Ordinal)); - if (pendingChange?.NewValue is IReadOnlyList list) - return list; - return fromConfig; - } -} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionValidationService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionValidationService.cs new file mode 100644 index 0000000..b84c163 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionValidationService.cs @@ -0,0 +1,25 @@ +using System.Linq; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +public sealed class EffectiveConfigCrossOptionValidationService + : IEffectiveConfigCrossOptionValidationService +{ + private readonly IReadOnlyList _rules; + + public EffectiveConfigCrossOptionValidationService(IEnumerable rules) + { + _rules = rules.ToList(); + } + + public IReadOnlyList Validate( + DnsmasqServiceStatus? status, + IReadOnlyList pending) + { + var context = new EffectiveConfigCrossOptionContext(status, pending); + return _rules.SelectMany(r => r.Evaluate(context)).ToList(); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Rules/CrossOptionRules.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Rules/CrossOptionRules.cs new file mode 100644 index 0000000..6e95da9 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Rules/CrossOptionRules.cs @@ -0,0 +1,324 @@ +using System.Linq; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Rules; + +public sealed class NoResolvWithoutUpstreamsRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.no-resolv-without-upstreams"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + var noResolv = context.GetBool(DnsmasqConfKeys.NoResolv, cfg => cfg.NoResolv); + if (!noResolv) + return []; + + var servers = context.GetMulti(DnsmasqConfKeys.Server, cfg => cfg.ServerValues); + var resolvFiles = context.GetMulti(DnsmasqConfKeys.ResolvFile, cfg => cfg.ResolvFiles); + if (servers.Count > 0 || resolvFiles.Count > 0) + return []; + + return + [ + new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + 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) + ]; + } +} + +public sealed class ConntrackWithQueryPortRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.conntrack-query-port"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + var conntrack = context.GetBool(DnsmasqConfKeys.Conntrack, cfg => cfg.Conntrack); + var queryPort = context.GetInt(DnsmasqConfKeys.QueryPort, cfg => cfg.QueryPort); + if (!conntrack || queryPort is null) + return []; + + return + [ + new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + 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) + ]; + } +} + +public sealed class RebindExceptionsRequireStopDnsRebindRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.rebind-exceptions-without-stop-dns-rebind"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + var stopDnsRebind = context.GetBool(DnsmasqConfKeys.StopDnsRebind, cfg => cfg.StopDnsRebind); + if (stopDnsRebind) + return []; + + var issues = new List(); + + var localhostOk = context.GetBool(DnsmasqConfKeys.RebindLocalhostOk, cfg => cfg.RebindLocalhostOk); + if (localhostOk) + { + issues.Add(new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + 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)); + } + + var domainOk = context.GetMulti(DnsmasqConfKeys.RebindDomainOk, cfg => cfg.RebindDomainOkValues); + if (domainOk.Count > 0) + { + issues.Add(new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + 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)); + } + + return issues; + } +} + +public sealed class BogusPrivBlocksPrivateReverseServerRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.bogus-priv-private-reverse-server"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + var bogusPriv = context.GetBool(DnsmasqConfKeys.BogusPriv, cfg => cfg.BogusPriv); + if (!bogusPriv) + 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))); + + if (!hasPrivateReverseServer) + return []; + + return + [ + new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + DnsmasqConfKeys.Server), + "bogus-priv takes priority over private reverse lookup forwarding, so those PTR queries may never reach the configured upstream server.", + FieldIssueSeverity.Warning) + ]; + } +} + +public sealed class DnssecPrerequisitesRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.dnssec-prerequisites"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + var dnssec = context.GetBool(DnsmasqConfKeys.Dnssec, cfg => cfg.Dnssec); + if (!dnssec) + return []; + + var issues = new List(); + + if (context.Status is { DnsmasqSupportsDnssec: false }) + { + issues.Add(new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + 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)); + } + + var trustAnchors = context.GetMulti(DnsmasqConfKeys.TrustAnchor, cfg => cfg.TrustAnchorValues); + if (trustAnchors.Count == 0) + { + issues.Add(new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + 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)); + } + + return issues; + } +} + +public sealed class ProxyDnssecCacheWarningRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.proxy-dnssec-cache-warning"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + var proxyDnssec = context.GetBool(DnsmasqConfKeys.ProxyDnssec, cfg => cfg.ProxyDnssec); + if (!proxyDnssec) + return []; + + var cacheSize = context.GetInt(DnsmasqConfKeys.CacheSize, cfg => cfg.CacheSize); + if (cacheSize == 0) + return []; + + return + [ + new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + DnsmasqConfKeys.CacheSize), + "With proxy-dnssec, the dnsmasq docs recommend cache-size=0 if clients rely on the AD bit.", + FieldIssueSeverity.Warning) + ]; + } +} + +public sealed class ConnmarkAllowlistEnableRequiresAllowlistRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.connmark-allowlist-enable-requires-allowlist"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + if (!context.IsConnmarkAllowlistFilteringEnabled()) + return []; + + var allowlists = context.GetMulti( + DnsmasqConfKeys.ConnmarkAllowlist, + cfg => cfg.ConnmarkAllowlistValues); + if (allowlists.Count > 0) + return []; + + return + [ + new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + 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) + ]; + } +} + +public sealed class QueryPortIgnoredForSourceBoundServerRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.query-port-ignored-source-bound-server"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + if (context.GetInt(DnsmasqConfKeys.QueryPort, cfg => cfg.QueryPort) is null) + return []; + + var servers = context.GetMulti(DnsmasqConfKeys.Server, cfg => cfg.ServerValues); + if (!servers.Any(s => s.Contains('@', StringComparison.Ordinal))) + return []; + + return + [ + new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + 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) + ]; + } +} + +public sealed class AddSubnetCacheBehaviorRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.add-subnet-cache-behavior"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + var addSubnet = context.GetString(DnsmasqConfKeys.AddSubnet, cfg => cfg.AddSubnet); + if (string.IsNullOrWhiteSpace(addSubnet)) + return []; + + return + [ + new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + DnsmasqConfKeys.AddSubnet), + "add-subnet can disable caching for replies that vary by client subnet unless the forwarded subnet is constant.", + FieldIssueSeverity.Warning) + ]; + } +} + +public sealed class Filterwin2kSrvWarningRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.filterwin2k-srv-warning"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + var enabled = context.GetBool(DnsmasqConfKeys.Filterwin2k, cfg => cfg.Filterwin2k); + if (!enabled) + return []; + + return + [ + new FieldIssue( + EffectiveConfigCrossOptionContext.FieldKey( + EffectiveConfigSections.SectionResolver, + DnsmasqConfKeys.Filterwin2k), + "filterwin2k blocks SRV queries and can break Kerberos, SIP, XMPP, or similar service discovery.", + FieldIssueSeverity.Warning) + ]; + } +} + +public sealed class AddressLocalDnsmasq286CompatibilityRule : IEffectiveConfigCrossOptionRule +{ + public string Id => "resolver.address-local-dnsmasq-286-compat"; + + public IReadOnlyList Evaluate(EffectiveConfigCrossOptionContext context) + { + var addresses = context.GetMulti(DnsmasqConfKeys.Address, cfg => cfg.AddressValues); + if (addresses.Count == 0) + return []; + + var hasDomainLiteral = addresses.Any(LooksLikeDomainAddressDirective); + if (!hasDomainLiteral) + 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.", + FieldIssueSeverity.Warning) + ]; + } + + private static bool LooksLikeDomainAddressDirective(string value) + { + 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); + } +} diff --git a/testdata/dnsmasq-test.conf b/testdata/dnsmasq-test.conf index e365b75..47be137 100644 --- a/testdata/dnsmasq-test.conf +++ b/testdata/dnsmasq-test.conf @@ -18,6 +18,7 @@ domain-needed bogus-priv # Local overrides (multi-value: address=) +# Domain/literal form triggers dnsmasq 2.86+ compatibility guidance in the web UI (expected for this harness). address=/local.example/127.0.0.1 address=/test.lan/172.28.0.1