+
+ 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