mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+226
@@ -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);
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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>
|
||||
}
|
||||
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
|
||||
@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">
|
||||
|
||||
+10
-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);
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
+89
@@ -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}";
|
||||
}
|
||||
-92
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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();
|
||||
}
|
||||
}
|
||||
+324
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user