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.
This commit is contained in:
2026-03-29 21:35:30 +10:00
parent 31c0f65c86
commit a3dc5ac45c
13 changed files with 929 additions and 98 deletions
@@ -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);
}
}
@@ -0,0 +1,226 @@
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
internal static class CrossOptionTestHelpers
{
/// <summary>Minimal baseline effective config; override with record <c>with</c> in tests.</summary>
public static EffectiveDnsmasqConfig BaselineConfig() =>
new(
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
HostsdirPath: null,
ServerValues: Array.Empty<string>(),
LocalValues: Array.Empty<string>(),
RevServerValues: Array.Empty<string>(),
AddressValues: Array.Empty<string>(),
Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(),
ExceptInterfaces: Array.Empty<string>(),
DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(),
DhcpOptionLines: Array.Empty<string>(),
DhcpMatchValues: Array.Empty<string>(),
DhcpBootValues: Array.Empty<string>(),
DhcpIgnoreValues: Array.Empty<string>(),
DhcpVendorclassValues: Array.Empty<string>(),
DhcpUserclassValues: Array.Empty<string>(),
RaParamValues: Array.Empty<string>(),
SlaacValues: Array.Empty<string>(),
PxeServiceValues: Array.Empty<string>(),
TrustAnchorValues: Array.Empty<string>(),
ResolvFiles: Array.Empty<string>(),
RebindDomainOkValues: Array.Empty<string>(),
BogusNxdomainValues: Array.Empty<string>(),
IgnoreAddressValues: Array.Empty<string>(),
AliasValues: Array.Empty<string>(),
FilterRrValues: Array.Empty<string>(),
CacheRrValues: Array.Empty<string>(),
AuthServerValues: Array.Empty<string>(),
NoDhcpInterfaceValues: Array.Empty<string>(),
NoDhcpv4InterfaceValues: Array.Empty<string>(),
NoDhcpv6InterfaceValues: Array.Empty<string>(),
DomainValues: Array.Empty<string>(),
CnameValues: Array.Empty<string>(),
MxHostValues: Array.Empty<string>(),
SrvValues: Array.Empty<string>(),
PtrRecordValues: Array.Empty<string>(),
TxtRecordValues: Array.Empty<string>(),
NaptrRecordValues: Array.Empty<string>(),
HostRecordValues: Array.Empty<string>(),
DynamicHostValues: Array.Empty<string>(),
InterfaceNameValues: Array.Empty<string>(),
DhcpOptionForceLines: Array.Empty<string>(),
IpsetValues: Array.Empty<string>(),
NftsetValues: Array.Empty<string>(),
DhcpMacValues: Array.Empty<string>(),
DhcpNameMatchValues: Array.Empty<string>(),
DhcpIgnoreNamesValues: Array.Empty<string>(),
DhcpHostsfilePaths: Array.Empty<string>(),
DhcpOptsfilePaths: Array.Empty<string>(),
DhcpHostsdirPaths: Array.Empty<string>(),
DhcpOptsdirPaths: Array.Empty<string>(),
ConnmarkAllowlistValues: Array.Empty<string>(),
CaaRecordValues: Array.Empty<string>(),
DnsRrValues: Array.Empty<string>(),
SynthDomainValues: Array.Empty<string>(),
AuthZoneValues: Array.Empty<string>(),
AuthSoaValues: Array.Empty<string>(),
AuthSecServersValues: Array.Empty<string>(),
AuthPeerValues: Array.Empty<string>(),
DhcpRelayValues: Array.Empty<string>(),
DhcpCircuitidValues: Array.Empty<string>(),
DhcpRemoteidValues: Array.Empty<string>(),
DhcpSubscridValues: Array.Empty<string>(),
DhcpProxyValues: Array.Empty<string>(),
TagIfValues: Array.Empty<string>(),
BridgeInterfaceValues: Array.Empty<string>(),
SharedNetworkValues: Array.Empty<string>(),
DhcpOptionPxeValues: Array.Empty<string>(),
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<string>(),
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<string>(),
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);
}
@@ -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<IEffectiveConfigCrossOptionRule> rules =
[
new ConntrackWithQueryPortRule(),
new Filterwin2kSrvWarningRule()
];
var service = new EffectiveConfigCrossOptionValidationService(rules);
var issues = service.Validate(status, []);
Assert.Equal(2, issues.Count);
}
}
@@ -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<PendingOptionChange>().ToList();
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, optionChanges);
var issues = CrossOptionValidation.Validate(Status, optionChanges);
Session.SetCrossOptionIssues(issues);
}
@@ -114,6 +114,10 @@
@if (ValidationErrors.Count > 0 || ValidationWarnings.Count > 0)
{
<div class="ec-save-modal-validation mb-3">
<p class="text-muted small mb-2">
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.
</p>
@if (ValidationErrors.Count > 0)
{
<div class="alert alert-danger py-2 mb-2">
@@ -140,7 +144,10 @@
}
</div>
}
@if (PendingChanges.Count > 0)
{
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
}
@if (TargetFileNames.Count > 0)
{
<p class="ec-save-modal-targets mb-3">
@@ -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<PendingOptionChange>().ToList());
var issues = _crossOptionValidation.Validate(
status,
_session.PendingChanges.OfType<PendingOptionChange>().ToList());
_session.SetCrossOptionIssues(issues);
}
@@ -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<FieldIssue> Evaluate(EffectiveConfigCrossOptionContext context);
}
@@ -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<FieldIssue> Validate(
DnsmasqServiceStatus? status,
IReadOnlyList<PendingOptionChange> pending);
}
@@ -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;
/// <summary>
/// Effective option values for cross-option rules: parsed config overlaid with pending edits.
/// </summary>
public sealed class EffectiveConfigCrossOptionContext
{
private readonly Dictionary<string, PendingOptionChange> _pendingByOption;
public DnsmasqServiceStatus? Status { get; }
public EffectiveConfigCrossOptionContext(
DnsmasqServiceStatus? status,
IReadOnlyList<PendingOptionChange> 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<EffectiveDnsmasqConfig, bool> 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<EffectiveDnsmasqConfig, int?> 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<EffectiveDnsmasqConfig, string?> fromConfig)
{
if (_pendingByOption.TryGetValue(optionName, out var change))
return change.NewValue?.ToString();
return Status?.EffectiveConfig is { } cfg ? fromConfig(cfg) : null;
}
public IReadOnlyList<string> GetMulti(string optionName, Func<EffectiveDnsmasqConfig, IReadOnlyList<string>> fromConfig)
{
if (_pendingByOption.TryGetValue(optionName, out var change) &&
change.NewValue is IReadOnlyList<string> list)
{
return list;
}
return Status?.EffectiveConfig is { } cfg ? fromConfig(cfg) : [];
}
/// <summary>
/// True when connmark-allowlist-enable is present (key-only uses empty string in effective config).
/// </summary>
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}";
}
@@ -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;
/// <summary>
/// Evaluates cross-option rules on effective config (status merged with pending changes).
/// Returns validation issues (warnings/errors) for the UI and save guard.
/// </summary>
public static class EffectiveConfigCrossOptionEvaluator
{
/// <summary>
/// Runs all cross-option rules and returns issues to display. Caller should pass result to <see cref="IEffectiveConfigEditSession.SetCrossOptionIssues"/>.
/// </summary>
public static IReadOnlyList<FieldIssue> Evaluate(
DnsmasqServiceStatus? status,
IReadOnlyList<PendingOptionChange> pending)
{
var issues = new List<FieldIssue>();
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<PendingOptionChange>? pending,
string optionName,
Func<DnsmasqServiceStatus?, bool> 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<PendingOptionChange>? pending,
string optionName,
Func<DnsmasqServiceStatus?, int?> 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<string>? GetEffectiveServerValues(DnsmasqServiceStatus? status, IReadOnlyList<PendingOptionChange> pending)
{
var fromConfig = status?.EffectiveConfig?.ServerValues;
var pendingChange = pending?.FirstOrDefault(c =>
string.Equals(c.OptionName, DnsmasqConfKeys.Server, StringComparison.Ordinal));
if (pendingChange?.NewValue is IReadOnlyList<string> list)
return list;
return fromConfig;
}
}
@@ -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<IEffectiveConfigCrossOptionRule> _rules;
public EffectiveConfigCrossOptionValidationService(IEnumerable<IEffectiveConfigCrossOptionRule> rules)
{
_rules = rules.ToList();
}
public IReadOnlyList<FieldIssue> Validate(
DnsmasqServiceStatus? status,
IReadOnlyList<PendingOptionChange> pending)
{
var context = new EffectiveConfigCrossOptionContext(status, pending);
return _rules.SelectMany(r => r.Evaluate(context)).ToList();
}
}
@@ -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<FieldIssue> 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<FieldIssue> 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<FieldIssue> Evaluate(EffectiveConfigCrossOptionContext context)
{
var stopDnsRebind = context.GetBool(DnsmasqConfKeys.StopDnsRebind, cfg => cfg.StopDnsRebind);
if (stopDnsRebind)
return [];
var issues = new List<FieldIssue>();
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<FieldIssue> 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<FieldIssue> Evaluate(EffectiveConfigCrossOptionContext context)
{
var dnssec = context.GetBool(DnsmasqConfKeys.Dnssec, cfg => cfg.Dnssec);
if (!dnssec)
return [];
var issues = new List<FieldIssue>();
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<FieldIssue> 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<FieldIssue> 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<FieldIssue> 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<FieldIssue> 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<FieldIssue> 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<FieldIssue> 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);
}
}
+1
View File
@@ -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