mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
+90
-3
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
+46
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-1
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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)
|
||||||
|
|||||||
+14
-3
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-57
@@ -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)
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user