Cross-option validation: field keys, bogus-priv zones, edit UX

- Add FieldKeyForOption() using EffectiveConfigSections.GetSectionId so
  cross-option issues match UI field keys; tighten RFC1918 in-addr.arpa
  detection for bogus-priv vs server=.
- GetMulti: pending change always wins; empty list when NewValue is not
  a string list (with tests).
- EffectiveConfigSection: re-run cross-option when entering edit mode or
  continuing after save failure; allow opening save modal when only
  validation summary is present.
- Toolbar: validation link aria-label describes opening the summary.
This commit is contained in:
2026-03-30 11:31:02 +10:00
parent a3dc5ac45c
commit 84c58ccec7
6 changed files with 297 additions and 66 deletions
@@ -16,8 +16,7 @@ public class CrossOptionRulesTests
var cfg = CrossOptionTestHelpers.BaselineConfig() with { NoResolv = true }; var cfg = CrossOptionTestHelpers.BaselineConfig() with { NoResolv = true };
var rule = new NoResolvWithoutUpstreamsRule(); var rule = new NoResolvWithoutUpstreamsRule();
var issues = rule.Evaluate(Ctx(cfg)); var issues = rule.Evaluate(Ctx(cfg));
var key = EffectiveConfigCrossOptionContext.FieldKey( var key = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server);
EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server);
Assert.Single(issues); Assert.Single(issues);
Assert.Equal(key, issues[0].FieldKey); Assert.Equal(key, issues[0].FieldKey);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
@@ -70,6 +69,38 @@ public class CrossOptionRulesTests
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); 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] [Fact]
public void DnssecPrerequisitesRule_Error_when_build_lacks_dnssec() public void DnssecPrerequisitesRule_Error_when_build_lacks_dnssec()
{ {
@@ -78,7 +109,9 @@ public class CrossOptionRulesTests
var ctx = new EffectiveConfigCrossOptionContext(status, []); var ctx = new EffectiveConfigCrossOptionContext(status, []);
var rule = new DnssecPrerequisitesRule(); var rule = new DnssecPrerequisitesRule();
var issues = rule.Evaluate(ctx); 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] [Fact]
@@ -87,8 +120,10 @@ public class CrossOptionRulesTests
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true }; var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true };
var rule = new DnssecPrerequisitesRule(); var rule = new DnssecPrerequisitesRule();
var issues = rule.Evaluate(Ctx(cfg)); var issues = rule.Evaluate(Ctx(cfg));
var trustAnchorKey = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.TrustAnchor);
Assert.Contains(issues, i => Assert.Contains(issues, i =>
i.Severity == FieldIssueSeverity.Warning && i.Severity == FieldIssueSeverity.Warning &&
i.FieldKey == trustAnchorKey &&
i.Message.Contains("trust-anchor", StringComparison.OrdinalIgnoreCase)); i.Message.Contains("trust-anchor", StringComparison.OrdinalIgnoreCase));
} }
@@ -104,6 +139,9 @@ public class CrossOptionRulesTests
var issues = rule.Evaluate(Ctx(cfg)); var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues); Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.CacheSize),
issues[0].FieldKey);
} }
[Fact] [Fact]
@@ -149,6 +187,9 @@ public class CrossOptionRulesTests
var rule = new AddSubnetCacheBehaviorRule(); var rule = new AddSubnetCacheBehaviorRule();
var issues = rule.Evaluate(Ctx(cfg)); var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues); Assert.Single(issues);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.AddSubnet),
issues[0].FieldKey);
} }
[Fact] [Fact]
@@ -174,6 +215,52 @@ public class CrossOptionRulesTests
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity); 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] [Fact]
public void Pending_overlay_overrides_config_for_cross_option_rules() public void Pending_overlay_overrides_config_for_cross_option_rules()
{ {
@@ -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<string> { "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<string> { "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));
}
}
@@ -155,6 +155,7 @@
private void EnterEditMode() private void EnterEditMode()
{ {
Session.EnterEditMode(); Session.EnterEditMode();
RefreshCrossOptionAfterEnteringEditMode();
StateHasChanged(); StateHasChanged();
} }
@@ -206,6 +207,15 @@
Session.SetCrossOptionIssues(issues); Session.SetCrossOptionIssues(issues);
} }
/// <summary>
/// <see cref="IEffectiveConfigEditSession.EnterEditMode"/> clears cross-option issues; repopulate from disk + pending.
/// </summary>
private void RefreshCrossOptionAfterEnteringEditMode()
{
if (Status != null)
RunCrossOptionEvaluator();
}
private void OnRevertedFromSaveModal() private void OnRevertedFromSaveModal()
{ {
RunCrossOptionEvaluator(); RunCrossOptionEvaluator();
@@ -214,7 +224,10 @@
private void OpenSaveModal() 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; _showSaveModal = true;
StateHasChanged(); StateHasChanged();
} }
@@ -231,6 +244,7 @@
Session.ExitEditModeDiscard(); Session.ExitEditModeDiscard();
await OnSaveCompleted.InvokeAsync(); await OnSaveCompleted.InvokeAsync();
Session.EnterEditMode(); Session.EnterEditMode();
RefreshCrossOptionAfterEnteringEditMode();
StateHasChanged(); StateHasChanged();
} }
@@ -37,7 +37,7 @@
{ {
<span class="text-danger fw-medium ec-toolbar-changes-link" role="button" tabindex="0" <span class="text-danger fw-medium ec-toolbar-changes-link" role="button" tabindex="0"
title="@ValidationErrorTooltip" title="@ValidationErrorTooltip"
aria-label="Open save dialog" aria-label="Open validation summary"
@onclick="OnOpenSaveModal" @onclick="OnOpenSaveModal"
@onkeydown="OnValidationLinkKeyDown"> @onkeydown="OnValidationLinkKeyDown">
@ValidationErrorCount error(s) @ValidationErrorCount error(s)
@@ -48,7 +48,7 @@
{ {
<span class="text-warning ec-toolbar-changes-link" role="button" tabindex="0" <span class="text-warning ec-toolbar-changes-link" role="button" tabindex="0"
title="@ValidationWarningTooltip" title="@ValidationWarningTooltip"
aria-label="Open save dialog" aria-label="Open validation summary"
@onclick="OnOpenSaveModal" @onclick="OnOpenSaveModal"
@onkeydown="OnValidationLinkKeyDown"> @onkeydown="OnValidationLinkKeyDown">
@ValidationWarningCount warning(s) @ValidationWarningCount warning(s)
@@ -58,10 +58,13 @@ public sealed class EffectiveConfigCrossOptionContext
public IReadOnlyList<string> GetMulti(string optionName, Func<EffectiveDnsmasqConfig, IReadOnlyList<string>> fromConfig) public IReadOnlyList<string> GetMulti(string optionName, Func<EffectiveDnsmasqConfig, IReadOnlyList<string>> fromConfig)
{ {
if (_pendingByOption.TryGetValue(optionName, out var change) && if (_pendingByOption.TryGetValue(optionName, out var change))
change.NewValue is IReadOnlyList<string> list)
{ {
return list; if (change.NewValue is IReadOnlyList<string> 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) : []; return Status?.EffectiveConfig is { } cfg ? fromConfig(cfg) : [];
@@ -84,6 +87,14 @@ public sealed class EffectiveConfigCrossOptionContext
return Status?.EffectiveConfig?.ConnmarkAllowlistEnable is not null; return Status?.EffectiveConfig?.ConnmarkAllowlistEnable is not null;
} }
/// <summary>Explicit section (e.g. attach an issue to a different field than the option that triggered the rule).</summary>
public static string FieldKey(string sectionId, string optionName) public static string FieldKey(string sectionId, string optionName)
=> $"{sectionId}:{optionName}"; => $"{sectionId}:{optionName}";
/// <summary>
/// Field key for <paramref name="optionName"/> using the same option → section mapping as the effective-config UI
/// (<see cref="EffectiveConfigSections.GetSectionId"/>). Prefer this for cross-option rules so badges merge correctly.
/// </summary>
public static string FieldKeyForOption(string optionName) =>
FieldKey(EffectiveConfigSections.GetSectionId(optionName), optionName);
} }
@@ -1,4 +1,5 @@
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
@@ -23,9 +24,7 @@ public sealed class NoResolvWithoutUpstreamsRule : IEffectiveConfigCrossOptionRu
return return
[ [
new FieldIssue( new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server),
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.", "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) FieldIssueSeverity.Warning)
]; ];
@@ -46,9 +45,7 @@ public sealed class ConntrackWithQueryPortRule : IEffectiveConfigCrossOptionRule
return return
[ [
new FieldIssue( new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.QueryPort),
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.", "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) FieldIssueSeverity.Error)
]; ];
@@ -71,9 +68,7 @@ public sealed class RebindExceptionsRequireStopDnsRebindRule : IEffectiveConfigC
if (localhostOk) if (localhostOk)
{ {
issues.Add(new FieldIssue( issues.Add(new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RebindLocalhostOk),
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.", "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)); FieldIssueSeverity.Warning));
} }
@@ -82,9 +77,7 @@ public sealed class RebindExceptionsRequireStopDnsRebindRule : IEffectiveConfigC
if (domainOk.Count > 0) if (domainOk.Count > 0)
{ {
issues.Add(new FieldIssue( issues.Add(new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RebindDomainOk),
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.", "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)); FieldIssueSeverity.Warning));
} }
@@ -104,11 +97,7 @@ public sealed class BogusPrivBlocksPrivateReverseServerRule : IEffectiveConfigCr
return []; return [];
var servers = context.GetMulti(DnsmasqConfKeys.Server, cfg => cfg.ServerValues); var servers = context.GetMulti(DnsmasqConfKeys.Server, cfg => cfg.ServerValues);
var hasPrivateReverseServer = servers.Any(v => var hasPrivateReverseServer = servers.Any(LooksLikeRfc1918InAddrArpaServer);
v.Contains("in-addr.arpa", StringComparison.OrdinalIgnoreCase) &&
(v.Contains("192", StringComparison.OrdinalIgnoreCase) ||
v.Contains("172", StringComparison.OrdinalIgnoreCase) ||
v.Contains("10", StringComparison.OrdinalIgnoreCase)));
if (!hasPrivateReverseServer) if (!hasPrivateReverseServer)
return []; return [];
@@ -116,13 +105,34 @@ public sealed class BogusPrivBlocksPrivateReverseServerRule : IEffectiveConfigCr
return return
[ [
new FieldIssue( new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server),
EffectiveConfigSections.SectionResolver,
DnsmasqConfKeys.Server),
"bogus-priv takes priority over private reverse lookup forwarding, so those PTR queries may never reach the configured upstream server.", "bogus-priv takes priority over private reverse lookup forwarding, so those PTR queries may never reach the configured upstream server.",
FieldIssueSeverity.Warning) 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 public sealed class DnssecPrerequisitesRule : IEffectiveConfigCrossOptionRule
@@ -140,9 +150,7 @@ public sealed class DnssecPrerequisitesRule : IEffectiveConfigCrossOptionRule
if (context.Status is { DnsmasqSupportsDnssec: false }) if (context.Status is { DnsmasqSupportsDnssec: false })
{ {
issues.Add(new FieldIssue( issues.Add(new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Dnssec),
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.", "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)); FieldIssueSeverity.Error));
} }
@@ -151,9 +159,7 @@ public sealed class DnssecPrerequisitesRule : IEffectiveConfigCrossOptionRule
if (trustAnchors.Count == 0) if (trustAnchors.Count == 0)
{ {
issues.Add(new FieldIssue( issues.Add(new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.TrustAnchor),
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.", "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)); FieldIssueSeverity.Warning));
} }
@@ -179,9 +185,7 @@ public sealed class ProxyDnssecCacheWarningRule : IEffectiveConfigCrossOptionRul
return return
[ [
new FieldIssue( new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.CacheSize),
EffectiveConfigSections.SectionResolver,
DnsmasqConfKeys.CacheSize),
"With proxy-dnssec, the dnsmasq docs recommend cache-size=0 if clients rely on the AD bit.", "With proxy-dnssec, the dnsmasq docs recommend cache-size=0 if clients rely on the AD bit.",
FieldIssueSeverity.Warning) FieldIssueSeverity.Warning)
]; ];
@@ -206,9 +210,7 @@ public sealed class ConnmarkAllowlistEnableRequiresAllowlistRule : IEffectiveCon
return return
[ [
new FieldIssue( new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.ConnmarkAllowlist),
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.", "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) FieldIssueSeverity.Error)
]; ];
@@ -231,9 +233,7 @@ public sealed class QueryPortIgnoredForSourceBoundServerRule : IEffectiveConfigC
return return
[ [
new FieldIssue( new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.QueryPort),
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.", "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) FieldIssueSeverity.Warning)
]; ];
@@ -253,9 +253,7 @@ public sealed class AddSubnetCacheBehaviorRule : IEffectiveConfigCrossOptionRule
return return
[ [
new FieldIssue( new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.AddSubnet),
EffectiveConfigSections.SectionResolver,
DnsmasqConfKeys.AddSubnet),
"add-subnet can disable caching for replies that vary by client subnet unless the forwarded subnet is constant.", "add-subnet can disable caching for replies that vary by client subnet unless the forwarded subnet is constant.",
FieldIssueSeverity.Warning) FieldIssueSeverity.Warning)
]; ];
@@ -275,9 +273,7 @@ public sealed class Filterwin2kSrvWarningRule : IEffectiveConfigCrossOptionRule
return return
[ [
new FieldIssue( new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Filterwin2k),
EffectiveConfigSections.SectionResolver,
DnsmasqConfKeys.Filterwin2k),
"filterwin2k blocks SRV queries and can break Kerberos, SIP, XMPP, or similar service discovery.", "filterwin2k blocks SRV queries and can break Kerberos, SIP, XMPP, or similar service discovery.",
FieldIssueSeverity.Warning) FieldIssueSeverity.Warning)
]; ];
@@ -294,31 +290,108 @@ public sealed class AddressLocalDnsmasq286CompatibilityRule : IEffectiveConfigCr
if (addresses.Count == 0) if (addresses.Count == 0)
return []; return [];
var hasDomainLiteral = addresses.Any(LooksLikeDomainAddressDirective); var addressDomains = addresses
if (!hasDomainLiteral) .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<string> 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<string> 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<FieldIssue> 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 [];
return return
[ [
new FieldIssue( new FieldIssue(
EffectiveConfigCrossOptionContext.FieldKey( EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.DnssecCheckUnsigned),
EffectiveConfigSections.SectionResolver, "dnssec-check-unsigned only has effect when dnssec is enabled. Enable dnssec or remove dnssec-check-unsigned to avoid a no-op setting.",
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) 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<FieldIssue> Evaluate(EffectiveConfigCrossOptionContext context)
{ {
var t = value.Trim(); if (context.GetString(DnsmasqConfKeys.LocalService, cfg => cfg.LocalService) is null)
if (!t.StartsWith('/')) return [];
return false;
var rest = t[1..]; var hasInterface = context.GetMulti(DnsmasqConfKeys.Interface, cfg => cfg.Interfaces).Count > 0;
var idx = rest.IndexOf('/'); var hasExceptInterface = context.GetMulti(DnsmasqConfKeys.ExceptInterface, cfg => cfg.ExceptInterfaces).Count > 0;
if (idx <= 0) var hasListenAddress = context.GetMulti(DnsmasqConfKeys.ListenAddress, cfg => cfg.ListenAddresses).Count > 0;
return false; var hasAuthServer = context.GetMulti(DnsmasqConfKeys.AuthServer, cfg => cfg.AuthServerValues).Count > 0;
var domain = rest[..idx];
return domain.Length > 0 && !string.Equals(domain, "#", StringComparison.Ordinal); 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)
];
} }
} }