dnsmasq options: dhcp-optsdir, key-only-or-value semantics, capability detection

- Add dhcp-optsdir support (model, sections, parser, UI multi-value).
- Model leasequery, connmark-allowlist-enable, dhcp-generate-names, dhcp-broadcast,
  bootp-dynamic, dnssec-check-unsigned as key-only-or-value with validation.
- Parse compile-time capabilities from dnsmasq --version (DHCP, TFTP, DNSSEC, DBus).
- Expose capabilities via DnsmasqVersionInfo and status API (DnsmasqSupports*).
- Map options to required features; disable unsupported option editors with badge + tooltip.
- Save guard: reject changes for unsupported options; use UnsupportedCapabilities error code.
- MultiKeyOnlyOrValue write path: filter out readonly values to avoid duplicating in managed file.
- User-friendly tooltips for disabled options; dedicated UnsupportedCapabilities vs WriteFailed.
This commit is contained in:
2026-03-10 02:23:14 +10:00
parent 4c1c19b792
commit 214d6c686e
38 changed files with 970 additions and 91 deletions
+1
View File
@@ -122,6 +122,7 @@ OPTION_KEYS = [
"dhcp-hostsfile",
"dhcp-optsfile",
"dhcp-hostsdir",
"dhcp-optsdir",
"dhcp-boot",
"dhcp-ignore",
"dhcp-vendorclass",
@@ -0,0 +1,72 @@
using DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for parsing "Compile time options: ..." from dnsmasq --version output.
/// </summary>
public class DnsmasqCompileOptionsParserTests
{
[Fact]
public void Parse_WhenNoCompileLine_ReturnsAllCapabilitiesFalse()
{
var result = DnsmasqCompileOptionsParser.Parse("Dnsmasq version 2.89\nCopyright ...", null);
Assert.False(result.Dhcp);
Assert.False(result.Tftp);
Assert.False(result.Dnssec);
Assert.False(result.Dbus);
Assert.NotNull(result.RawTokens);
Assert.Empty(result.RawTokens);
}
[Fact]
public void Parse_WhenCompileLineHasDnssec_SetsDnssecTrue()
{
var stdout = "Dnsmasq version 2.89\nCompile time options: IPv6 GNU-getopt no-DBus no-Idn DHCP DHCPv6 TFTP DNSSEC auth";
var result = DnsmasqCompileOptionsParser.Parse(stdout, null);
Assert.True(result.Dnssec);
Assert.True(result.Dhcp);
Assert.True(result.Tftp);
Assert.False(result.Dbus);
Assert.Contains("DNSSEC", result.RawTokens, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void Parse_WhenCompileLineHasDhcpOnly_SetsDhcpTrueOthersFalse()
{
var result = DnsmasqCompileOptionsParser.Parse(null, "Compile time options: DHCP no-DBus no-DNSSEC");
Assert.True(result.Dhcp);
Assert.False(result.Tftp);
Assert.False(result.Dnssec);
Assert.False(result.Dbus);
}
[Fact]
public void Parse_WhenCompileLineHasDhcpv6_SetsDhcpTrue()
{
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: DHCPv6", null);
Assert.True(result.Dhcp);
}
[Fact]
public void Parse_WhenCompileLineHasTftp_SetsTftpTrue()
{
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: TFTP", null);
Assert.True(result.Tftp);
}
[Fact]
public void Parse_WhenCompileLineHasDbus_SetsDbusTrue()
{
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: DBus", null);
Assert.True(result.Dbus);
}
[Fact]
public void Parse_CombinesStdoutAndStderr()
{
var result = DnsmasqCompileOptionsParser.Parse("Version line\n", "Compile time options: DNSSEC");
Assert.True(result.Dnssec);
}
}
@@ -283,7 +283,7 @@ public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
}
[Fact]
public void GetFlagFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsTrue()
public void GetLastValueFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
@@ -291,8 +291,26 @@ public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
try
{
File.WriteAllText(conf, "dnssec-check-unsigned\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
Assert.True(result);
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
Assert.NotNull(value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_DnssecCheckUnsigned_WhenEqualsNo_ReturnsNo()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-no-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dnssec-check-unsigned=no\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
Assert.Equal("no", value);
}
finally
{
@@ -80,9 +80,9 @@ public class DnsmasqConfIncludeParserNewOptionsTests
}
[Fact]
public void GetFlagFromConfigFiles_ConnmarkAllowlistEnable_WhenPresent_ReturnsTrue()
public void GetLastValueFromConfigFiles_ConnmarkAllowlistEnable_WhenPresent_ReturnsValue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.ConnmarkAllowlistEnable);
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.ConnmarkAllowlistEnable);
}
[Fact]
@@ -104,9 +104,32 @@ public class DnsmasqConfIncludeParserNewOptionsTests
}
[Fact]
public void GetFlagFromConfigFiles_Leasequery_WhenPresent_ReturnsTrue()
public void GetMultiValueFromConfigFiles_Leasequery_WhenPresent_ReturnsValues()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.Leasequery);
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "leasequery\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Leasequery);
Assert.Single(result);
Assert.Equal("", result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_Leasequery_WithValue_IsParsed()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "leasequery=10.0.0.0/24\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Leasequery);
Assert.Equal("10.0.0.0/24", Assert.Single(result));
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
@@ -122,15 +145,15 @@ public class DnsmasqConfIncludeParserNewOptionsTests
}
[Fact]
public void GetFlagFromConfigFiles_DhcpGenerateNames_WhenPresent_ReturnsTrue()
public void GetLastValueFromConfigFiles_DhcpGenerateNames_WhenPresent_ReturnsValue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpGenerateNames);
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.DhcpGenerateNames);
}
[Fact]
public void GetFlagFromConfigFiles_DhcpBroadcast_WhenPresent_ReturnsTrue()
public void GetLastValueFromConfigFiles_DhcpBroadcast_WhenPresent_ReturnsValue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpBroadcast);
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.DhcpBroadcast);
}
[Fact]
@@ -146,9 +169,9 @@ public class DnsmasqConfIncludeParserNewOptionsTests
}
[Fact]
public void GetFlagFromConfigFiles_BootpDynamic_WhenPresent_ReturnsTrue()
public void GetLastValueFromConfigFiles_BootpDynamic_WhenPresent_ReturnsValue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.BootpDynamic);
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.BootpDynamic);
}
[Fact]
@@ -403,6 +426,19 @@ public class DnsmasqConfIncludeParserNewOptionsTests
finally { Directory.Delete(dir, recursive: true); }
}
private static void WriteLastWinsKeyOnlyAndAssertPresent(string key)
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, key + "\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, key);
Assert.NotNull(value);
}
finally { Directory.Delete(dir, recursive: true); }
}
private static string CreateTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-newopts-" + Guid.NewGuid().ToString("N"));
@@ -159,6 +159,41 @@ public class DnsmasqConfigServiceApplyChangesTests
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_DnssecCheckUnsignedNo_WritesEqualsNo()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var text = await File.ReadAllTextAsync(managedPath);
Assert.Contains("dnssec-check-unsigned=no", text);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_KeyOnlyOrValue_KeyOnly_WritesBareKey()
{
@@ -475,4 +510,55 @@ public class DnsmasqConfigServiceApplyChangesTests
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_Leasequery_MultiKeyOnlyOrValue_DoesNotDuplicateReadonlyValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var readonlyPath = Path.Combine(dir, "readonly.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, $"port=53\nconf-file={readonlyPath}\n");
File.WriteAllText(readonlyPath, "leasequery\nleasequery=10.0.0.0/24\n");
File.WriteAllText(managedPath, "leasequery=172.16.0.0/16\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
// Simulate UI effective values (readonly + managed). Save should write only managed-only values.
var changes = new List<PendingEffectiveConfigChange>
{
new(
EffectiveConfigSections.SectionDhcp,
DnsmasqConfKeys.Leasequery,
null,
new List<string> { "", "10.0.0.0/24", "192.168.50.0/24" },
null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var lines = (await File.ReadAllLinesAsync(managedPath))
.Select(l => l.Trim())
.Where(l => l.StartsWith("leasequery", StringComparison.Ordinal))
.ToList();
Assert.Single(lines);
Assert.Equal("leasequery=192.168.50.0/24", lines[0]);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,142 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for option -> feature mapping and capability checks.
/// </summary>
public class EffectiveConfigFeatureRequirementsTests
{
[Theory]
[InlineData(DnsmasqConfKeys.Dnssec, DnsmasqFeature.Dnssec)]
[InlineData(DnsmasqConfKeys.DnssecCheckUnsigned, DnsmasqFeature.Dnssec)]
[InlineData(DnsmasqConfKeys.TrustAnchor, DnsmasqFeature.Dnssec)]
[InlineData(DnsmasqConfKeys.DhcpRange, DnsmasqFeature.Dhcp)]
[InlineData(DnsmasqConfKeys.EnableTftp, DnsmasqFeature.Tftp)]
[InlineData(DnsmasqConfKeys.EnableDbus, DnsmasqFeature.Dbus)]
public void GetRequiredFeature_ReturnsExpectedFeature(string optionName, DnsmasqFeature expected)
{
var feature = EffectiveConfigFeatureRequirements.GetRequiredFeature(optionName);
Assert.NotNull(feature);
Assert.Equal(expected, feature.Value);
}
[Fact]
public void GetRequiredFeature_ForUnknownOption_ReturnsNull()
{
var feature = EffectiveConfigFeatureRequirements.GetRequiredFeature("no-such-option");
Assert.Null(feature);
}
[Fact]
public void IsSupportedByCapabilities_WhenCapabilityPresent_ReturnsTrue()
{
var caps = new DnsmasqCompileCapabilities(true, true, true, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP", "DNSSEC" });
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, caps));
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DhcpRange, caps));
}
[Fact]
public void IsSupportedByCapabilities_WhenDnssecMissing_ReturnsFalseForDnssecOptions()
{
var caps = new DnsmasqCompileCapabilities(true, true, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase));
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.Dnssec, caps));
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, caps));
}
[Fact]
public void IsSupportedByCapabilities_ForOptionWithNoRequiredFeature_ReturnsTrue()
{
var caps = new DnsmasqCompileCapabilities(false, false, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase));
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.Port, caps));
}
[Fact]
public void GetCapabilityDisabled_WhenStatusSupportsFeature_ReturnsNotDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: true);
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, status);
Assert.False(isDisabled);
Assert.Null(reason);
}
[Fact]
public void GetCapabilityDisabled_WhenStatusMissingDnssec_ReturnsDisabledWithReason()
{
var status = CreateStatusWithCapabilities(dnssec: false);
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, status);
Assert.True(isDisabled);
Assert.NotNull(reason);
Assert.Contains("DNSSEC", reason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetCapabilityDisabled_WhenStatusNull_ReturnsNotDisabled()
{
var (isDisabled, _) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, null);
Assert.False(isDisabled);
}
/// <summary>
/// Save path uses IsSupportedByCapabilities(optionName, version.Capabilities) to reject changes before write.
/// This test asserts that logic: a build without DNSSEC would reject dnssec-check-unsigned.
/// </summary>
[Fact]
public void SaveGuard_WhenBuildHasNoDnssec_DnssecOptionChangesWouldBeRejected()
{
var noDnssecCaps = new DnsmasqCompileCapabilities(true, true, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP" });
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, noDnssecCaps));
var reason = EffectiveConfigFeatureRequirements.GetUnsupportedReason(DnsmasqFeature.Dnssec);
Assert.Contains("DNSSEC", reason, StringComparison.OrdinalIgnoreCase);
}
private static DnsmasqServiceStatus CreateStatusWithCapabilities(bool dhcp = true, bool tftp = true, bool dnssec = true, bool dbus = false)
{
return new DnsmasqServiceStatus(
SystemHostsPath: null,
SystemHostsPathExists: false,
ManagedHostsFilePath: null,
ManagedHostsPathExists: false,
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
EffectiveConfig: null,
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: dhcp,
DnsmasqSupportsTftp: tftp,
DnsmasqSupportsDnssec: dnssec,
DnsmasqSupportsDbus: dbus);
}
}
@@ -0,0 +1,104 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests that field descriptors get IsCapabilityDisabled set when the status indicates the option is not supported by the dnsmasq build.
/// </summary>
public class EffectiveConfigFieldBuilderCapabilityTests
{
[Fact]
public void GetSingleDescriptor_WhenDnssecOptionAndStatusHasNoDnssec_ReturnsDescriptorWithCapabilityDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: false);
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
null,
DnsmasqConfKeys.DnssecCheckUnsigned,
status,
_ => null,
_ => null,
null);
Assert.True(descriptor.IsCapabilityDisabled);
Assert.NotNull(descriptor.CapabilityDisabledReason);
Assert.Contains("DNSSEC", descriptor.CapabilityDisabledReason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetSingleDescriptor_WhenDnssecOptionAndStatusHasDnssec_ReturnsDescriptorWithCapabilityNotDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: true);
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
null,
DnsmasqConfKeys.DnssecCheckUnsigned,
status,
_ => null,
_ => null,
null);
Assert.False(descriptor.IsCapabilityDisabled);
Assert.Null(descriptor.CapabilityDisabledReason);
}
[Fact]
public void GetSingleDescriptor_WhenOptionWithNoFeatureRequirement_ReturnsDescriptorWithCapabilityNotDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: false);
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
null,
DnsmasqConfKeys.Port,
status,
_ => null,
_ => null,
null);
Assert.False(descriptor.IsCapabilityDisabled);
Assert.Null(descriptor.CapabilityDisabledReason);
}
private static DnsmasqServiceStatus CreateStatusWithCapabilities(bool dhcp = true, bool tftp = true, bool dnssec = true, bool dbus = false)
{
return new DnsmasqServiceStatus(
SystemHostsPath: null,
SystemHostsPathExists: false,
ManagedHostsFilePath: null,
ManagedHostsPathExists: false,
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
EffectiveConfig: null,
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: dhcp,
DnsmasqSupportsTftp: tftp,
DnsmasqSupportsDnssec: dnssec,
DnsmasqSupportsDbus: dbus);
}
}
@@ -54,6 +54,7 @@ public class EffectiveDnsmasqConfigTests
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>(),
@@ -97,7 +98,7 @@ public class EffectiveDnsmasqConfigTests
TftpNoFail: false,
TftpNoBlocksize: false,
Dnssec: false,
DnssecCheckUnsigned: false,
DnssecCheckUnsigned: null,
ReadEthers: false,
DhcpRapidCommit: false,
Localmx: false,
@@ -107,16 +108,16 @@ public class EffectiveDnsmasqConfigTests
KeepInForeground: false,
NoDaemon: false,
ProxyDnssec: false,
ConnmarkAllowlistEnable: false,
ConnmarkAllowlistEnable: null,
NoRoundRobin: false,
DnssecNoTimecheck: false,
DnssecDebug: false,
Leasequery: false,
DhcpGenerateNames: false,
DhcpBroadcast: false,
LeasequeryValues: Array.Empty<string>(),
DhcpGenerateNames: null,
DhcpBroadcast: null,
DhcpSequentialIp: false,
DhcpIgnoreClid: false,
BootpDynamic: false,
BootpDynamic: null,
NoPing: false,
ScriptArp: false,
ScriptOnRenewal: false,
@@ -0,0 +1,48 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using Xunit;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Validation tests for SpecialOptionValidators, including malformed numeric and structured values.
/// </summary>
public class SpecialOptionValidatorsTests
{
[Theory]
[InlineData("", true)]
[InlineData("0xff", true)]
[InlineData("255", true)]
[InlineData("0", true)]
[InlineData("0xZZ", false)]
[InlineData("-1", false)]
[InlineData("not-a-number", false)]
public void ValidateConnmarkAllowlistEnable_AcceptsValid_RejectsInvalid(string input, bool valid)
{
var err = SpecialOptionValidators.ValidateConnmarkAllowlistEnable(input);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("", true)]
[InlineData("no", true)]
[InlineData("yes", false)]
[InlineData("0", false)]
public void ValidateDnssecCheckUnsigned_AcceptsValid_RejectsInvalid(string input, bool valid)
{
var err = SpecialOptionValidators.ValidateDnssecCheckUnsigned(input);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("", true)]
[InlineData("10.0.0.1", true)]
[InlineData("10.0.0.0/24", true)]
[InlineData("::1", true)]
[InlineData("invalid", false)]
[InlineData("10.0.0.0/abc", false)]
public void ValidateLeasequeryValue_AcceptsValid_RejectsInvalid(string? input, bool valid)
{
var err = SpecialOptionValidators.ValidateLeasequeryValue(input);
Assert.Equal(valid, err is null);
}
}
@@ -13,8 +13,8 @@
{
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
var showEditableBadge = !isReadonly;
var isActiveEditor = IsEditMode && IsActive;
var showEditableBadge = !isReadonly && !Descriptor.IsCapabilityDisabled;
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
var fragmentWithCallback = isActiveEditor ? _fragmentWithCallback : null;
var effectiveValue = _effectiveValue;
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
@@ -30,7 +30,7 @@
</CascadingValue>
</CascadingValue>
</div>
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
</div>
}
else
@@ -38,7 +38,7 @@
<CascadingValue Value="@false">
@fragment(Descriptor)
</CascadingValue>
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
</span>
</li>
@@ -49,17 +49,18 @@ else if (Descriptor.IsMultiValue)
if (customMulti is not null)
{
@* Custom multi editor (e.g. server): same row shell as generic multi, value slot uses custom fragment. *@
<li class="ec-field @(IsEditMode && IsActive ? "ec-field-editing" : "")">
<li class="ec-field @(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled ? "ec-field-editing" : "")">
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
<span class="ec-field-value">
<CascadingValue Value="@(IsEditMode && IsActive)">
<CascadingValue Value="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)">
<CascadingValue Value="@IsEditMode">
@customMulti(Descriptor)
</CascadingValue>
</CascadingValue>
</span>
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
IsInteractive="@IsEditMode" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive)"
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
</li>
}
@@ -67,8 +68,8 @@ else if (Descriptor.IsMultiValue)
{
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
<EffectiveConfigMultiValueRow Descriptor="@Descriptor" Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
Source="@_multiValueRowSource" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
}
@@ -78,7 +79,8 @@ else
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
var showReadonlyHint = IsEditMode && isReadonly;
var isActiveEditor = IsEditMode && IsActive;
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
var showEditableForDefault = !Descriptor.IsCapabilityDisabled;
@if (showReadonlyHint)
{
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
@@ -90,8 +92,9 @@ else
{
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
ShowEditableBadge="true" FieldKey="@FieldKey" IsActive="@IsActive"
ShowEditableBadge="@showEditableForDefault" FieldKey="@FieldKey" IsActive="@IsActive"
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
DisplayValueChanged="@HandleDefaultValueChanged" OnBlur="@HandleDefaultBlur" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
</li>
}
@@ -19,6 +19,7 @@
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
}
else
@@ -37,8 +38,8 @@
else
{
<span class="text-muted">(none)</span>
<OptionStateBadge OptionName="@Label" ShowEditableBadge="true" EditableAsButton="true"
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" />
<OptionStateBadge OptionName="@Label" ShowEditableBadge="@(!(Descriptor?.IsCapabilityDisabled == true))" EditableAsButton="true"
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
}
</div>
}
@@ -3,18 +3,18 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><strong>@Label:</strong></OptionHelpLabel>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true)
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true && !IsCapabilityDisabled)
{
<span class="ec-field-value" @onclick:stopPropagation="true">
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="true" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="true" IsCapabilityDisabled="@IsCapabilityDisabled" CapabilityDisabledReason="@CapabilityDisabledReason" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
</span>
}
else
{
<span class="ec-field-value">
@DisplayValue
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="false" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="false" IsCapabilityDisabled="@IsCapabilityDisabled" CapabilityDisabledReason="@CapabilityDisabledReason" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
</span>
}
@@ -38,6 +38,10 @@ else
[Parameter] public string FieldKey { get; set; } = "";
/// <summary>When true, add ec-field-editing class and show input when DisplayMode is Edit.</summary>
[Parameter] public bool IsActive { get; set; }
/// <summary>When true, option is disabled because dnsmasq build does not support it; show disabled badge and no editor.</summary>
[Parameter] public bool IsCapabilityDisabled { get; set; }
/// <summary>Tooltip when <see cref="IsCapabilityDisabled"/> is true.</summary>
[Parameter] public string? CapabilityDisabledReason { get; set; }
/// <summary>When Edit and not readonly: raised when the user changes the value. Not yet wired to save.</summary>
[Parameter] public EventCallback<string> DisplayValueChanged { get; set; }
/// <summary>When Edit and not readonly: raised when the input loses focus; argument is the current value.</summary>
@@ -2,7 +2,14 @@
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@if (Source?.IsReadOnly == true)
@if (IsCapabilityDisabled)
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly"
Text="disabled"
Title="@CapabilityDisabledReason"
Class="status-badge-inline" />
}
else if (Source?.IsReadOnly == true)
{
var readonlyTitle = !string.IsNullOrEmpty(ReadonlyTitleOverride) ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip();
if (ReadonlyClickActivatesField && Ui != null && !string.IsNullOrEmpty(FieldKey))
@@ -85,6 +92,10 @@ else if (ShowEditableBadge)
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
/// <summary>Optional tooltip for readonly badge when <see cref="ReadonlyClickActivatesField"/> is true (e.g. "New values can still be added. Click to edit.").</summary>
[Parameter] public string? ReadonlyTitleOverride { get; set; }
/// <summary>When true, option is disabled because dnsmasq build does not support it (e.g. no DNSSEC).</summary>
[Parameter] public bool IsCapabilityDisabled { get; set; }
/// <summary>Tooltip shown when <see cref="IsCapabilityDisabled"/> is true (e.g. "This dnsmasq build does not support DNSSEC; option not written.").</summary>
[Parameter] public string? CapabilityDisabledReason { get; set; }
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
private async Task OnConfirmClick()
@@ -110,7 +110,11 @@ public class StatusController : ControllerBase
DnsmasqVersion: versionInfo.InstalledVersion?.ToString(),
MinimumSupportedDnsmasqVersion: versionInfo.MinimumVersion.ToString(),
DnsmasqVersionSupported: versionInfo.IsSupported,
DnsmasqVersionError: versionInfo.Error
DnsmasqVersionError: versionInfo.Error,
DnsmasqSupportsDhcp: versionInfo.Capabilities.Dhcp,
DnsmasqSupportsTftp: versionInfo.Capabilities.Tftp,
DnsmasqSupportsDnssec: versionInfo.Capabilities.Dnssec,
DnsmasqSupportsDbus: versionInfo.Capabilities.Dbus
);
return Ok(status);
}
@@ -25,6 +25,7 @@ public static class DnsmasqConfKeys
public const string DhcpHostsfile = "dhcp-hostsfile";
public const string DhcpOptsfile = "dhcp-optsfile";
public const string DhcpHostsdir = "dhcp-hostsdir";
public const string DhcpOptsdir = "dhcp-optsdir";
// --- Multi-value (ARG_DUP) ---
public const string Server = "server";
@@ -118,6 +118,7 @@ public static class DnsmasqOptionTooltips
[DnsmasqConfKeys.DhcpHostsfile] = "File(s) containing dhcp-host-style lines. Can repeat.",
[DnsmasqConfKeys.DhcpOptsfile] = "File containing dhcp-option-style lines.",
[DnsmasqConfKeys.DhcpHostsdir] = "Directory of files read like dhcp-hostsfile (watched for changes). Can repeat.",
[DnsmasqConfKeys.DhcpOptsdir] = "Directory of files read like dhcp-optsfile (watched for changes). Can repeat.",
[DnsmasqConfKeys.DhcpBoot] = "Boot file and server for PXE. Can repeat.",
[DnsmasqConfKeys.DhcpIgnore] = "Ignore DHCP clients (e.g. by tag). Can repeat.",
[DnsmasqConfKeys.DhcpVendorclass] = "Match by DHCP vendor class. Can repeat.",
@@ -0,0 +1,140 @@
using DnsmasqWebUI.Models.Dnsmasq;
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// <summary>Dnsmasq compile-time feature required for certain options.</summary>
public enum DnsmasqFeature
{
Dhcp,
Tftp,
Dnssec,
Dbus
}
/// <summary>
/// Maps effective-config option names to the dnsmasq compile-time feature they require.
/// Used to disable option editors and block writes when the running dnsmasq binary does not support the feature.
/// </summary>
public static class EffectiveConfigFeatureRequirements
{
private static readonly IReadOnlyDictionary<string, DnsmasqFeature> RequiredByOption =
new Dictionary<string, DnsmasqFeature>(StringComparer.Ordinal)
{
// DNSSEC
[DnsmasqConfKeys.Dnssec] = DnsmasqFeature.Dnssec,
[DnsmasqConfKeys.DnssecCheckUnsigned] = DnsmasqFeature.Dnssec,
[DnsmasqConfKeys.ProxyDnssec] = DnsmasqFeature.Dnssec,
[DnsmasqConfKeys.TrustAnchor] = DnsmasqFeature.Dnssec,
[DnsmasqConfKeys.DnssecNoTimecheck] = DnsmasqFeature.Dnssec,
[DnsmasqConfKeys.DnssecDebug] = DnsmasqFeature.Dnssec,
[DnsmasqConfKeys.DnssecTimestamp] = DnsmasqFeature.Dnssec,
[DnsmasqConfKeys.DnssecLimits] = DnsmasqFeature.Dnssec,
// DHCP
[DnsmasqConfKeys.DhcpLeasefile] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpLeaseMax] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpRange] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpHost] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpOption] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpOptionForce] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpAuthoritative] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpRapidCommit] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpGenerateNames] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpBroadcast] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpBoot] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpScript] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpHostsfile] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpOptsfile] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpHostsdir] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpOptsdir] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.Leasequery] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.LeasefileRo] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpTtl] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpMatch] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpIgnore] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpIgnoreNames] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpRelay] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpSequentialIp] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpIgnoreClid] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.BootpDynamic] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.NoPing] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.ScriptArp] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.ScriptOnRenewal] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpNoOverride] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpAlternatePort] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpDuid] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpLuascript] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpScriptuser] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpMac] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpNameMatch] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpVendorclass] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpUserclass] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.RaParam] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.Slaac] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpCircuitid] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpRemoteid] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpSubscrid] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpProxy] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpPxeVendor] = DnsmasqFeature.Dhcp,
[DnsmasqConfKeys.DhcpOptionPxe] = DnsmasqFeature.Dhcp,
// TFTP
[DnsmasqConfKeys.EnableTftp] = DnsmasqFeature.Tftp,
[DnsmasqConfKeys.TftpSecure] = DnsmasqFeature.Tftp,
[DnsmasqConfKeys.TftpNoFail] = DnsmasqFeature.Tftp,
[DnsmasqConfKeys.TftpNoBlocksize] = DnsmasqFeature.Tftp,
[DnsmasqConfKeys.TftpRoot] = DnsmasqFeature.Tftp,
[DnsmasqConfKeys.PxePrompt] = DnsmasqFeature.Tftp,
[DnsmasqConfKeys.PxeService] = DnsmasqFeature.Tftp,
// DBus
[DnsmasqConfKeys.EnableDbus] = DnsmasqFeature.Dbus,
};
public static DnsmasqFeature? GetRequiredFeature(string optionName) =>
RequiredByOption.TryGetValue(optionName, out var f) ? f : null;
public static bool IsSupported(DnsmasqFeature feature, DnsmasqServiceStatus? status)
{
if (status == null) return true;
return feature switch
{
DnsmasqFeature.Dhcp => status.DnsmasqSupportsDhcp,
DnsmasqFeature.Tftp => status.DnsmasqSupportsTftp,
DnsmasqFeature.Dnssec => status.DnsmasqSupportsDnssec,
DnsmasqFeature.Dbus => status.DnsmasqSupportsDbus,
_ => true
};
}
/// <summary>User-facing tooltip when an option is disabled because the running dnsmasq was built without the required feature.</summary>
public static string GetUnsupportedReason(DnsmasqFeature feature) => feature switch
{
DnsmasqFeature.Dhcp => "Your dnsmasq was built without DHCP support, so this option can't be used. Install or build dnsmasq with DHCP enabled to change it.",
DnsmasqFeature.Tftp => "Your dnsmasq was built without TFTP support, so this option can't be used. Install or build dnsmasq with TFTP enabled to change it.",
DnsmasqFeature.Dnssec => "Your dnsmasq was built without DNSSEC support, so this option can't be used. Install or build dnsmasq with DNSSEC enabled to change it.",
DnsmasqFeature.Dbus => "Your dnsmasq was built without DBus support, so this option can't be used. Install or build dnsmasq with DBus enabled to change it.",
_ => "This option isn't available with your current dnsmasq build."
};
/// <summary>Returns (isDisabled, reason) for the option given current status. Used to set descriptor capability flags.</summary>
public static (bool IsDisabled, string? Reason) GetCapabilityDisabled(string optionName, DnsmasqServiceStatus? status)
{
var feature = GetRequiredFeature(optionName);
if (feature == null || status == null) return (false, null);
if (IsSupported(feature.Value, status)) return (false, null);
return (true, GetUnsupportedReason(feature.Value));
}
/// <summary>True when the option is supported by the given compile-time capabilities (for save-path guard).</summary>
public static bool IsSupportedByCapabilities(string optionName, DnsmasqCompileCapabilities capabilities)
{
var feature = GetRequiredFeature(optionName);
if (feature == null) return true;
return feature.Value switch
{
DnsmasqFeature.Dhcp => capabilities.Dhcp,
DnsmasqFeature.Tftp => capabilities.Tftp,
DnsmasqFeature.Dnssec => capabilities.Dnssec,
DnsmasqFeature.Dbus => capabilities.Dbus,
_ => true
};
}
}
@@ -44,7 +44,12 @@ public static class EffectiveConfigFieldBuilder
return Array.Empty<EffectiveConfigFieldDescriptor>();
var list = new List<EffectiveConfigFieldDescriptor>();
AddAllDescriptors(list, status, registry);
return list;
}
private static void AddAllDescriptors(List<EffectiveConfigFieldDescriptor> list, DnsmasqServiceStatus? status, IEffectiveConfigRenderFragmentRegistry? registry)
{
// Hosts
list.AddDescriptor(registry, DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null);
list.AddDescriptor(registry, DnsmasqConfKeys.AddnHosts, status, null, null, s => ToValueWithSourceList(s?.AddnHostsPaths, Sources(s)?.AddnHostsPaths));
@@ -119,7 +124,7 @@ public static class EffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpRapidCommit, status, s => Config(s)?.DhcpRapidCommit, s => Sources(s)?.DhcpRapidCommit, null);
list.AddDescriptor(registry, DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, null);
list.AddDescriptor(registry, DnsmasqConfKeys.Leasequery, status, s => Config(s)?.Leasequery, s => Sources(s)?.Leasequery, null);
list.AddDescriptor(registry, DnsmasqConfKeys.Leasequery, status, null, null, Items(ec => ec?.LeasequeryValues, src => src?.LeasequeryValues));
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpGenerateNames, status, s => Config(s)?.DhcpGenerateNames, s => Sources(s)?.DhcpGenerateNames, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpBroadcast, status, s => Config(s)?.DhcpBroadcast, s => Sources(s)?.DhcpBroadcast, null);
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpSequentialIp, status, s => Config(s)?.DhcpSequentialIp, s => Sources(s)?.DhcpSequentialIp, null);
@@ -148,6 +153,7 @@ public static class EffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpHostsfile, status, null, null, Items(ec => ec?.DhcpHostsfilePaths, src => src?.DhcpHostsfilePaths));
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpOptsfile, status, null, null, Items(ec => ec?.DhcpOptsfilePaths, src => src?.DhcpOptsfilePaths));
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpHostsdir, status, null, null, Items(ec => ec?.DhcpHostsdirPaths, src => src?.DhcpHostsdirPaths));
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpOptsdir, status, null, null, Items(ec => ec?.DhcpOptsdirPaths, src => src?.DhcpOptsdirPaths));
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpRelay, status, null, null, Items(ec => ec?.DhcpRelayValues, src => src?.DhcpRelayValues));
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpCircuitid, status, null, null, Items(ec => ec?.DhcpCircuitidValues, src => src?.DhcpCircuitidValues));
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpRemoteid, status, null, null, Items(ec => ec?.DhcpRemoteidValues, src => src?.DhcpRemoteidValues));
@@ -232,8 +238,6 @@ public static class EffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.QuietDhcp6, status, s => Config(s)?.QuietDhcp6, s => Sources(s)?.QuietDhcp6, null);
list.AddDescriptor(registry, DnsmasqConfKeys.QuietRa, status, s => Config(s)?.QuietRa, s => Sources(s)?.QuietRa, null);
list.AddDescriptor(registry, DnsmasqConfKeys.QuietTftp, status, s => Config(s)?.QuietTftp, s => Sources(s)?.QuietTftp, null);
return list;
}
/// <summary>Creates a single-value descriptor; uses registry factory when registered (e.g. integer), otherwise a plain descriptor. Used by <see cref="EffectiveConfigFieldDescriptorListExtensions.AddSingleDescriptor"/>.</summary>
@@ -247,9 +251,11 @@ public static class EffectiveConfigFieldBuilder
{
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
var factory = registry?.GetDescriptorFactory(sectionId, optionName);
if (factory != null)
return (EffectiveConfigFieldDescriptor)factory(sectionId, optionName, status, getValue, getSource, getItems);
return Single(optionName, status, getValue, getSource, getItems);
var descriptor = factory != null
? (EffectiveConfigFieldDescriptor)factory(sectionId, optionName, status, getValue, getSource, getItems)
: Single(optionName, status, getValue, getSource, getItems);
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(optionName, status);
return descriptor with { IsCapabilityDisabled = isDisabled, CapabilityDisabledReason = reason };
}
private static EffectiveConfigFieldDescriptor Single(string optionName, DnsmasqServiceStatus? status,
@@ -55,6 +55,8 @@ public static class EffectiveConfigFieldDescriptorListExtensions
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
var factory = registry?.GetMultiDescriptorFactory(sectionId, optionName)
?? throw new InvalidOperationException($"No multi descriptor factory registered for option '{optionName}' (section '{sectionId}'). Register all multi options via RegisterMultiDescriptor.");
list.Add(factory(sectionId, optionName, status, getItems));
var descriptor = factory(sectionId, optionName, status, getItems);
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(optionName, status);
list.Add(descriptor with { IsCapabilityDisabled = isDisabled, CapabilityDisabledReason = reason });
}
}
@@ -102,6 +102,7 @@ public static class EffectiveConfigOptionKindMap
[DnsmasqConfKeys.DhcpHostsfile] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.DhcpOptsfile] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.DhcpHostsdir] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.DhcpOptsdir] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.DhcpBoot] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.DhcpIgnore] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.DhcpVendorclass] = EffectiveConfigFieldKind.Multi,
@@ -155,7 +156,7 @@ public static class EffectiveConfigOptionKindMap
[DnsmasqConfKeys.NoRoundRobin] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.DnssecNoTimecheck] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.DnssecDebug] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.Leasequery] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.Leasequery] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.DhcpGenerateNames] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.DhcpBroadcast] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.DhcpSequentialIp] = EffectiveConfigFieldKind.Single,
@@ -291,6 +292,7 @@ public static class EffectiveConfigParserBehaviorMap
[DnsmasqConfKeys.DhcpHostsfile] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.DhcpOptsfile] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.DhcpHostsdir] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.DhcpOptsdir] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.DhcpBoot] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.DhcpIgnore] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.DhcpVendorclass] = EffectiveConfigParserBehavior.Multi,
@@ -305,7 +307,7 @@ public static class EffectiveConfigParserBehaviorMap
[DnsmasqConfKeys.PxePrompt] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.PxeService] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.Dnssec] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.DnssecCheckUnsigned] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.DnssecCheckUnsigned] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.ProxyDnssec] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.TrustAnchor] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.CacheRr] = EffectiveConfigParserBehavior.Multi,
@@ -340,16 +342,16 @@ public static class EffectiveConfigParserBehaviorMap
[DnsmasqConfKeys.KeepInForeground] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.NoDaemon] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.Conntrack] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.NoRoundRobin] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.DnssecNoTimecheck] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.DnssecDebug] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.Leasequery] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.DhcpGenerateNames] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.DhcpBroadcast] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.Leasequery] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.DhcpGenerateNames] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.DhcpBroadcast] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.DhcpSequentialIp] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.DhcpIgnoreClid] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.BootpDynamic] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.BootpDynamic] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.NoPing] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.ScriptArp] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.ScriptOnRenewal] = EffectiveConfigParserBehavior.Flag,
@@ -128,6 +128,7 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.DhcpHostsfile,
DnsmasqConfKeys.DhcpOptsfile,
DnsmasqConfKeys.DhcpHostsdir,
DnsmasqConfKeys.DhcpOptsdir,
DnsmasqConfKeys.DhcpBoot,
DnsmasqConfKeys.DhcpIgnore,
DnsmasqConfKeys.DhcpVendorclass,
@@ -50,6 +50,36 @@ public static class EffectiveConfigSpecialOptionSemantics
EffectiveConfigParserBehavior.Flag,
EffectiveConfigWriteBehavior.InversePair,
Validator: null),
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = new OptionSemantics(
DnsmasqConfKeys.ConnmarkAllowlistEnable,
EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateConnmarkAllowlistEnable),
[DnsmasqConfKeys.DnssecCheckUnsigned] = new OptionSemantics(
DnsmasqConfKeys.DnssecCheckUnsigned,
EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateDnssecCheckUnsigned),
[DnsmasqConfKeys.Leasequery] = new OptionSemantics(
DnsmasqConfKeys.Leasequery,
EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue,
null),
[DnsmasqConfKeys.DhcpGenerateNames] = new OptionSemantics(
DnsmasqConfKeys.DhcpGenerateNames,
EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
null),
[DnsmasqConfKeys.DhcpBroadcast] = new OptionSemantics(
DnsmasqConfKeys.DhcpBroadcast,
EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
null),
[DnsmasqConfKeys.BootpDynamic] = new OptionSemantics(
DnsmasqConfKeys.BootpDynamic,
EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
null),
};
/// <summary>Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints.</summary>
@@ -10,6 +10,7 @@ public enum EffectiveConfigWriteBehavior
SingleValue,
MultiValue,
KeyOnlyOrValue,
MultiKeyOnlyOrValue,
InversePair,
}
@@ -0,0 +1,37 @@
using System.Text.RegularExpressions;
using DnsmasqWebUI.Models.Dnsmasq;
namespace DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq;
/// <summary>
/// Parses "Compile time options: ..." line from dnsmasq --version output
/// to determine DHCP, TFTP, DNSSEC, DBus support.
/// </summary>
public static class DnsmasqCompileOptionsParser
{
private static readonly Regex CompileLine = new(
@"Compile\s+time\s+options\s*:\s*(?<opts>.+)$",
RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);
public static DnsmasqCompileCapabilities Parse(string? stdout, string? stderr)
{
var text = $"{stdout}\n{stderr}";
var m = CompileLine.Match(text);
if (!m.Success)
return new DnsmasqCompileCapabilities(false, false, false, false, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
var tokens = m.Groups["opts"].Value
.Split(new[] { ' ', '\t', ',' }, StringSplitOptions.RemoveEmptyEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
bool Has(string s) => tokens.Contains(s);
return new DnsmasqCompileCapabilities(
Dhcp: Has("DHCP") || Has("DHCPv4") || Has("DHCPv6"),
Tftp: Has("TFTP"),
Dnssec: Has("DNSSEC"),
Dbus: Has("DBus"),
RawTokens: tokens
);
}
}
@@ -293,6 +293,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var dhcpHostsfilePaths = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpHostsfile);
var dhcpOptsfilePaths = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpOptsfile);
var dhcpHostsdirPaths = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpHostsdir);
var dhcpOptsdirPaths = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpOptsdir);
var connmarkAllowlistValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ConnmarkAllowlist);
var caaRecordValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.CaaRecord);
var dnsRrValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnsRr);
@@ -335,7 +336,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var enableTftp = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.EnableTftp);
var tftpSecure = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.TftpSecure);
var dnssec = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Dnssec);
var dnssecCheckUnsigned = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned);
var (dnssecCheckUnsignedVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned);
var dnssecCheckUnsigned = dnssecCheckUnsignedVal == null ? null : (string.IsNullOrWhiteSpace(dnssecCheckUnsignedVal) ? "" : dnssecCheckUnsignedVal.Trim());
var readEthers = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ReadEthers);
var dhcpRapidCommit = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpRapidCommit);
var tftpNoFail = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.TftpNoFail);
@@ -347,16 +349,20 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var keepInForeground = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.KeepInForeground);
var noDaemon = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.NoDaemon);
var proxyDnssec = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ProxyDnssec);
var connmarkAllowlistEnable = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ConnmarkAllowlistEnable);
var (connmarkAllowlistEnableVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ConnmarkAllowlistEnable);
var connmarkAllowlistEnable = connmarkAllowlistEnableVal == null ? null : (string.IsNullOrWhiteSpace(connmarkAllowlistEnableVal) ? "" : connmarkAllowlistEnableVal.Trim());
var noRoundRobin = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.NoRoundRobin);
var dnssecNoTimecheck = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnssecNoTimecheck);
var dnssecDebug = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnssecDebug);
var leasequery = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Leasequery);
var dhcpGenerateNames = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpGenerateNames);
var dhcpBroadcast = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpBroadcast);
var leasequeryValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Leasequery);
var (dhcpGenerateNamesVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpGenerateNames);
var dhcpGenerateNames = dhcpGenerateNamesVal == null ? null : (string.IsNullOrWhiteSpace(dhcpGenerateNamesVal) ? "" : dhcpGenerateNamesVal.Trim());
var (dhcpBroadcastVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpBroadcast);
var dhcpBroadcast = dhcpBroadcastVal == null ? null : (string.IsNullOrWhiteSpace(dhcpBroadcastVal) ? "" : dhcpBroadcastVal.Trim());
var dhcpSequentialIp = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpSequentialIp);
var dhcpIgnoreClid = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpIgnoreClid);
var bootpDynamic = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.BootpDynamic);
var (bootpDynamicVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.BootpDynamic);
var bootpDynamic = bootpDynamicVal == null ? null : (string.IsNullOrWhiteSpace(bootpDynamicVal) ? "" : bootpDynamicVal.Trim());
var noPing = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.NoPing);
var scriptArp = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ScriptArp);
var scriptOnRenewal = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ScriptOnRenewal);
@@ -471,14 +477,14 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
rebindDomainOk, bogusNxdomain, ignoreAddress, alias, filterRr, cacheRr, authServer, noDhcpInterface, noDhcpv4Interface, noDhcpv6Interface,
domainValues, cnameValues, mxHostValues, srvValues, ptrRecordValues, txtRecordValues, naptrRecordValues, hostRecordValues, dynamicHostValues, interfaceNameValues,
dhcpOptionForceLines, ipsetValues, nftsetValues, dhcpMacValues, dhcpNameMatchValues, dhcpIgnoreNamesValues,
dhcpHostsfilePaths, dhcpOptsfilePaths, dhcpHostsdirPaths,
dhcpHostsfilePaths, dhcpOptsfilePaths, dhcpHostsdirPaths, dhcpOptsdirPaths,
connmarkAllowlistValues, caaRecordValues, dnsRrValues, synthDomainValues, authZoneValues, authSoaValues, authSecServersValues, authPeerValues,
dhcpRelayValues, dhcpCircuitidValues, dhcpRemoteidValues, dhcpSubscridValues, dhcpProxyValues, tagIfValues, bridgeInterfaceValues, sharedNetworkValues, dhcpOptionPxeValues,
expandHosts, bogusPriv, strictOrder, allServers, noResolv, domainNeeded, noPoll, bindInterfaces, bindDynamic, noNegcache,
dnsLoopDetect, stopDnsRebind, rebindLocalhostOk, clearOnReload, filterwin2k, filterA, filterAaaa, localiseQueries, logDebug, dhcpAuthoritative, leasefileRo,
enableTftp, tftpSecure, tftpNoFail, tftpNoBlocksize, dnssec, dnssecCheckUnsigned, readEthers, dhcpRapidCommit, localmx, selfmx, enableRa, logDhcp,
keepInForeground, noDaemon, proxyDnssec,
connmarkAllowlistEnable, noRoundRobin, dnssecNoTimecheck, dnssecDebug, leasequery, dhcpGenerateNames, dhcpBroadcast, dhcpSequentialIp, dhcpIgnoreClid, bootpDynamic, noPing, scriptArp, scriptOnRenewal, dhcpNoOverride, quietDhcp, quietDhcp6, quietRa, quietTftp,
connmarkAllowlistEnable, noRoundRobin, dnssecNoTimecheck, dnssecDebug, leasequeryValues, dhcpGenerateNames, dhcpBroadcast, dhcpSequentialIp, dhcpIgnoreClid, bootpDynamic, noPing, scriptArp, scriptOnRenewal, dhcpNoOverride, quietDhcp, quietDhcp6, quietRa, quietTftp,
dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, logQueries,
authTtl, ednsPacketMax, queryPort, portLimit, minPort, maxPort, logAsync, localService, dhcpLeaseMax,
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl, tftpRootPath, pxePrompt, enableDbus, enableUbus, fastDnsRetry,
@@ -541,6 +547,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var dhcpHostsfileWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHostsfile, managedFilePath);
var dhcpOptsfileWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOptsfile, managedFilePath);
var dhcpHostsdirWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHostsdir, managedFilePath);
var dhcpOptsdirWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOptsdir, managedFilePath);
var connmarkAllowlistWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ConnmarkAllowlist, managedFilePath);
var caaRecordWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.CaaRecord, managedFilePath);
var dnsRrWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnsRr, managedFilePath);
@@ -583,7 +590,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var (_, enableTftpSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.EnableTftp, managedFilePath);
var (_, tftpSecureSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.TftpSecure, managedFilePath);
var (_, dnssecSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Dnssec, managedFilePath);
var (_, dnssecCheckUnsignedSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned, managedFilePath);
var (_, dnssecCheckUnsignedSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned, managedFilePath);
var (_, readEthersSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ReadEthers, managedFilePath);
var (_, dhcpRapidCommitSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpRapidCommit, managedFilePath);
var (_, tftpNoFailSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.TftpNoFail, managedFilePath);
@@ -595,16 +602,16 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var (_, keepInForegroundSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.KeepInForeground, managedFilePath);
var (_, noDaemonSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.NoDaemon, managedFilePath);
var (_, proxyDnssecSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ProxyDnssec, managedFilePath);
var (_, connmarkAllowlistEnableSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ConnmarkAllowlistEnable, managedFilePath);
var (_, connmarkAllowlistEnableSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ConnmarkAllowlistEnable, managedFilePath);
var (_, noRoundRobinSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.NoRoundRobin, managedFilePath);
var (_, dnssecNoTimecheckSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecNoTimecheck, managedFilePath);
var (_, dnssecDebugSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecDebug, managedFilePath);
var (_, leasequerySource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Leasequery, managedFilePath);
var (_, dhcpGenerateNamesSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpGenerateNames, managedFilePath);
var (_, dhcpBroadcastSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpBroadcast, managedFilePath);
var leasequeryWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Leasequery, managedFilePath);
var (_, dhcpGenerateNamesSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpGenerateNames, managedFilePath);
var (_, dhcpBroadcastSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpBroadcast, managedFilePath);
var (_, dhcpSequentialIpSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpSequentialIp, managedFilePath);
var (_, dhcpIgnoreClidSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpIgnoreClid, managedFilePath);
var (_, bootpDynamicSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.BootpDynamic, managedFilePath);
var (_, bootpDynamicSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.BootpDynamic, managedFilePath);
var (_, noPingSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.NoPing, managedFilePath);
var (_, scriptArpSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ScriptArp, managedFilePath);
var (_, scriptOnRenewalSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ScriptOnRenewal, managedFilePath);
@@ -718,6 +725,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
dhcpHostsfileWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpOptsfileWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpHostsdirWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpOptsdirWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
connmarkAllowlistWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
caaRecordWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dnsRrWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
@@ -775,7 +783,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
noRoundRobinSource,
dnssecNoTimecheckSource,
dnssecDebugSource,
leasequerySource,
leasequeryWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpGenerateNamesSource,
dhcpBroadcastSource,
dhcpSequentialIpSource,
@@ -890,7 +898,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
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>(),
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>(),
@@ -898,10 +906,10 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
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: 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: false, NoRoundRobin: false, DnssecNoTimecheck: false, DnssecDebug: false, Leasequery: false, DhcpGenerateNames: false, DhcpBroadcast: false, DhcpSequentialIp: false, DhcpIgnoreClid: false, BootpDynamic: false, NoPing: false, ScriptArp: false, ScriptOnRenewal: false, DhcpNoOverride: false, QuietDhcp: false, QuietDhcp6: false, QuietRa: false, QuietTftp: 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,
@@ -931,7 +939,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
HostRecordValues: Array.Empty<ValueWithSource>(), DynamicHostValues: Array.Empty<ValueWithSource>(), InterfaceNameValues: Array.Empty<ValueWithSource>(),
DhcpOptionForceLines: Array.Empty<ValueWithSource>(), IpsetValues: Array.Empty<ValueWithSource>(), NftsetValues: Array.Empty<ValueWithSource>(),
DhcpMacValues: Array.Empty<ValueWithSource>(), DhcpNameMatchValues: Array.Empty<ValueWithSource>(), DhcpIgnoreNamesValues: Array.Empty<ValueWithSource>(),
DhcpHostsfilePaths: Array.Empty<ValueWithSource>(), DhcpOptsfilePaths: Array.Empty<ValueWithSource>(), DhcpHostsdirPaths: Array.Empty<ValueWithSource>(),
DhcpHostsfilePaths: Array.Empty<ValueWithSource>(), DhcpOptsfilePaths: Array.Empty<ValueWithSource>(), DhcpHostsdirPaths: Array.Empty<ValueWithSource>(), DhcpOptsdirPaths: Array.Empty<ValueWithSource>(),
ConnmarkAllowlistValues: Array.Empty<ValueWithSource>(), CaaRecordValues: Array.Empty<ValueWithSource>(), DnsRrValues: Array.Empty<ValueWithSource>(), SynthDomainValues: Array.Empty<ValueWithSource>(),
AuthZoneValues: Array.Empty<ValueWithSource>(), AuthSoaValues: Array.Empty<ValueWithSource>(), AuthSecServersValues: Array.Empty<ValueWithSource>(), AuthPeerValues: Array.Empty<ValueWithSource>(),
DhcpRelayValues: Array.Empty<ValueWithSource>(), DhcpCircuitidValues: Array.Empty<ValueWithSource>(), DhcpRemoteidValues: Array.Empty<ValueWithSource>(), DhcpSubscridValues: Array.Empty<ValueWithSource>(), DhcpProxyValues: Array.Empty<ValueWithSource>(),
@@ -942,7 +950,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
EnableTftp: null, TftpSecure: null, TftpNoFail: null, TftpNoBlocksize: null, Dnssec: null, DnssecCheckUnsigned: null,
ReadEthers: null, DhcpRapidCommit: null, Localmx: null, Selfmx: null, EnableRa: null, LogDhcp: null,
KeepInForeground: null, NoDaemon: null, ProxyDnssec: null,
ConnmarkAllowlistEnable: null, NoRoundRobin: null, DnssecNoTimecheck: null, DnssecDebug: null, Leasequery: null, DhcpGenerateNames: null, DhcpBroadcast: null, DhcpSequentialIp: null, DhcpIgnoreClid: null, BootpDynamic: null, NoPing: null, ScriptArp: null, ScriptOnRenewal: null, DhcpNoOverride: null, QuietDhcp: null, QuietDhcp6: null, QuietRa: null, QuietTftp: null,
ConnmarkAllowlistEnable: null, NoRoundRobin: null, DnssecNoTimecheck: null, DnssecDebug: null, LeasequeryValues: Array.Empty<ValueWithSource>(), DhcpGenerateNames: null, DhcpBroadcast: null, DhcpSequentialIp: null, DhcpIgnoreClid: null, BootpDynamic: null, NoPing: null, ScriptArp: null, ScriptOnRenewal: null, DhcpNoOverride: null, QuietDhcp: null, QuietDhcp6: null, QuietRa: null, QuietTftp: null,
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,
@@ -253,6 +253,19 @@ public class DnsmasqConfigService : IDnsmasqConfigService
continue;
}
if (writeBehavior == EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue && TryGetMultiValues(c.NewValue, out var multiKeyOnlyValues))
{
IReadOnlyList<string> readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty<string>();
var valuesToWrite = FilterManagedOnly(multiKeyOnlyValues, readonlyValues);
RemoveAllMatchingLines(list, MatchesOption);
foreach (var v in valuesToWrite)
{
var raw = string.IsNullOrEmpty(v) ? confKey : $"{confKey}={v.Trim()}";
list.Add(new OtherLine { LineNumber = ++maxLineNumber, RawLine = raw });
}
continue;
}
if (writeBehavior == EffectiveConfigWriteBehavior.InversePair)
{
var pair = EffectiveConfigWriteSemantics.GetInversePairKeys(confKey);
@@ -29,13 +29,15 @@ public sealed class DnsmasqVersionService : IDnsmasqVersionService
if (string.IsNullOrWhiteSpace(command))
{
var emptyCaps = new DnsmasqCompileCapabilities(false, false, false, false, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
return new DnsmasqVersionInfo(
InstalledVersion: null,
MinimumVersion: minimumVersion,
ProbeSucceeded: false,
IsSupported: false,
ProbeCommand: "",
Error: "Version command is not configured.");
Error: "Version command is not configured.",
Capabilities: emptyCaps);
}
var result = await _processRunner.RunAsync(command, _options.VersionTimeout, ct);
@@ -52,6 +54,7 @@ public sealed class DnsmasqVersionService : IDnsmasqVersionService
var probeSucceeded = installed != null;
var isSupported = probeSucceeded && installed!.CompareTo(minimumVersion) >= 0;
var capabilities = DnsmasqCompileOptionsParser.Parse(result.Stdout, result.Stderr);
return new DnsmasqVersionInfo(
InstalledVersion: installed,
@@ -59,6 +62,7 @@ public sealed class DnsmasqVersionService : IDnsmasqVersionService
ProbeSucceeded: probeSucceeded,
IsSupported: isSupported,
ProbeCommand: command,
Error: error);
Error: error,
Capabilities: capabilities);
}
}
@@ -76,23 +76,17 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Selfmx);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.Dnssec);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.EnableRa);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDhcp);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.KeepInForeground);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDaemon);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.Conntrack);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.ProxyDnssec);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoRoundRobin);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecNoTimecheck);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecDebug);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpGenerateNames);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpBroadcast);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpSequentialIp);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpIgnoreClid);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.BootpDynamic);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.NoPing);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.ScriptArp);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.ScriptOnRenewal);
@@ -117,6 +111,17 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, typeof(KeyOnlyOrValueDisplay));
RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, SpecialOptionValidators.ValidateUmbrella);
// connmark-allowlist-enable, dnssec-check-unsigned: key-only or key=value with validation.
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable, typeof(KeyOnlyOrValueDisplay));
RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable, SpecialOptionValidators.ValidateConnmarkAllowlistEnable);
RegisterComponent(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, typeof(KeyOnlyOrValueDisplay));
RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, SpecialOptionValidators.ValidateDnssecCheckUnsigned);
// DHCP key-only-or-value options (optional value; no custom validator).
RegisterComponent(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpGenerateNames, typeof(KeyOnlyOrValueDisplay));
RegisterComponent(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpBroadcast, typeof(KeyOnlyOrValueDisplay));
RegisterComponent(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.BootpDynamic, typeof(KeyOnlyOrValueDisplay));
// log-queries: dropdown (Off / On / extra / proto / auth).
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay));
@@ -176,6 +181,8 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsfile);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsfile);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsdir);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsdir);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRelay);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpCircuitid);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRemoteid);
@@ -1,3 +1,4 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
@@ -94,6 +95,38 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
var managedPath = set.ManagedFilePath!;
var backupPath = BuildBackupPath(managedPath);
// Reject changes for options not supported by this dnsmasq build (e.g. DNSSEC when built without DNSSEC).
var unsupported = changes
.Where(c => !EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(c.OptionName, version.Capabilities))
.Select(c => c.OptionName)
.Distinct(StringComparer.Ordinal)
.ToList();
if (unsupported.Count > 0)
{
var firstReason = EffectiveConfigFeatureRequirements.GetRequiredFeature(unsupported[0]) is { } f
? EffectiveConfigFeatureRequirements.GetUnsupportedReason(f)
: "This option isn't available with your current dnsmasq build.";
var optionList = string.Join(", ", unsupported);
_logger.LogWarning("Save rejected: unsupported options in pending changes: {Options}", optionList);
var userMessage = unsupported.Count == 1
? firstReason
: $"Some of the options you changed aren't supported by your dnsmasq build. Options not saved: {optionList}.";
return new EffectiveConfigSaveResult(
BackupCreated: false,
BackupPath: null,
Saved: false,
Validated: false,
ValidationExitCode: -1,
ValidationStdOut: null,
ValidationStdErr: null,
Restarted: false,
RestartExitCode: -1,
RestartStdOut: null,
RestartStdErr: null,
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.UnsupportedCapabilities,
UserMessage: userMessage);
}
try
{
CreateBackupIfSourceExists(managedPath, backupPath);
@@ -45,4 +45,35 @@ public static class SpecialOptionValidators
if (s.Length == 0) return null; // key-only
return null; // token parsing can be tightened later
}
/// <summary>connmark-allowlist-enable: key-only or optional mask (decimal uint or 0x hex).</summary>
public static string? ValidateConnmarkAllowlistEnable(object? value)
{
if (value is null) return null;
var s = value.ToString()?.Trim() ?? "";
if (s.Length == 0) return null; // key-only
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return uint.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out _)
? null
: "Mask must be valid hex after 0x.";
return uint.TryParse(s, out _) ? null : "Mask must be empty, decimal uint, or hex (0x...).";
}
/// <summary>dnssec-check-unsigned: key-only (enable check) or 'no' (disable).</summary>
public static string? ValidateDnssecCheckUnsigned(object? value)
{
if (value is null) return null;
var s = value.ToString()?.Trim() ?? "";
return s is "" or "no" ? null : "Allowed values: empty (enable check) or 'no'.";
}
/// <summary>Per-item validation for leasequery: key-only or IP[/prefix].</summary>
public static string? ValidateLeasequeryValue(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null; // key-only
var parts = s.Trim().Split('/', 2);
if (!System.Net.IPAddress.TryParse(parts[0], out _)) return "Leasequery source must be an IP address.";
if (parts.Length == 2 && !int.TryParse(parts[1], out _)) return "Prefix must be numeric.";
return null;
}
}
@@ -0,0 +1,13 @@
namespace DnsmasqWebUI.Models.Dnsmasq;
/// <summary>
/// Compile-time capabilities parsed from dnsmasq --version (e.g. "Compile time options: ... DHCP ... DNSSEC ...").
/// Used to disable/hide options in the UI when the running dnsmasq binary does not support them.
/// </summary>
public record DnsmasqCompileCapabilities(
bool Dhcp,
bool Tftp,
bool Dnssec,
bool Dbus,
IReadOnlySet<string> RawTokens
);
@@ -41,6 +41,10 @@ namespace DnsmasqWebUI.Models.Dnsmasq;
/// <param name="MinimumSupportedDnsmasqVersion">Configured minimum required dnsmasq version (e.g. "2.91").</param>
/// <param name="DnsmasqVersionSupported">True when version probe succeeded and installed version is at least minimum.</param>
/// <param name="DnsmasqVersionError">Error message when version probe failed or could not parse; null on success.</param>
/// <param name="DnsmasqSupportsDhcp">True when dnsmasq was compiled with DHCP support (from --version compile options).</param>
/// <param name="DnsmasqSupportsTftp">True when dnsmasq was compiled with TFTP support.</param>
/// <param name="DnsmasqSupportsDnssec">True when dnsmasq was compiled with DNSSEC support.</param>
/// <param name="DnsmasqSupportsDbus">True when dnsmasq was compiled with DBus support.</param>
public record DnsmasqServiceStatus(
string? SystemHostsPath,
bool SystemHostsPathExists,
@@ -76,5 +80,9 @@ public record DnsmasqServiceStatus(
string? DnsmasqVersion,
string MinimumSupportedDnsmasqVersion,
bool DnsmasqVersionSupported,
string? DnsmasqVersionError
string? DnsmasqVersionError,
bool DnsmasqSupportsDhcp,
bool DnsmasqSupportsTftp,
bool DnsmasqSupportsDnssec,
bool DnsmasqSupportsDbus
);
@@ -7,10 +7,12 @@ namespace DnsmasqWebUI.Models.Dnsmasq;
/// <param name="IsSupported">True when probe succeeded and installed version is at least minimum.</param>
/// <param name="ProbeCommand">The command that was run (e.g. "dnsmasq --version").</param>
/// <param name="Error">Error message when probe failed or version could not be parsed; null on success.</param>
/// <param name="Capabilities">Compile-time options parsed from version output (DHCP, TFTP, DNSSEC, DBus). Empty when probe failed or line not found.</param>
public record DnsmasqVersionInfo(
Version? InstalledVersion,
Version MinimumVersion,
bool ProbeSucceeded,
bool IsSupported,
string ProbeCommand,
string? Error);
string? Error,
DnsmasqCompileCapabilities Capabilities);
@@ -17,7 +17,9 @@ public record EffectiveConfigFieldDescriptor(
Func<DnsmasqServiceStatus?, object?>? ResolveValue,
Func<DnsmasqServiceStatus?, ConfigValueSource?>? ResolveSource,
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? ResolveItems,
EffectiveConfigSingleValueValidator? ValidateValue = null
EffectiveConfigSingleValueValidator? ValidateValue = null,
bool IsCapabilityDisabled = false,
string? CapabilityDisabledReason = null
) : IEffectiveConfigFieldDescriptor
{
public object? GetValue() => ResolveValue?.Invoke(Status);
@@ -35,6 +35,8 @@ public record EffectiveConfigSaveResult(
public const string NoChanges = "no_changes";
public const string MissingManagedPath = "missing_managed_path";
public const string WriteFailed = "write_failed";
/// <summary>Save blocked before write because one or more changed options require dnsmasq capabilities not present in this build.</summary>
public const string UnsupportedCapabilities = "unsupported_capabilities";
public const string ValidateFailed = "validate_failed";
public const string RestartFailed = "restart_failed";
public const string UnsupportedVersion = "unsupported_version";
@@ -65,6 +65,7 @@ public record EffectiveConfigSources(
IReadOnlyList<ValueWithSource> DhcpHostsfilePaths,
IReadOnlyList<ValueWithSource> DhcpOptsfilePaths,
IReadOnlyList<ValueWithSource> DhcpHostsdirPaths,
IReadOnlyList<ValueWithSource> DhcpOptsdirPaths,
IReadOnlyList<ValueWithSource> ConnmarkAllowlistValues,
IReadOnlyList<ValueWithSource> CaaRecordValues,
IReadOnlyList<ValueWithSource> DnsRrValues,
@@ -110,7 +111,7 @@ public record EffectiveConfigSources(
ConfigValueSource? TftpNoFail,
ConfigValueSource? TftpNoBlocksize,
ConfigValueSource? Dnssec,
ConfigValueSource? DnssecCheckUnsigned,
ConfigValueSource? DnssecCheckUnsigned, // single value (empty or "no")
ConfigValueSource? ReadEthers,
ConfigValueSource? DhcpRapidCommit,
ConfigValueSource? Localmx,
@@ -120,11 +121,11 @@ public record EffectiveConfigSources(
ConfigValueSource? KeepInForeground,
ConfigValueSource? NoDaemon,
ConfigValueSource? ProxyDnssec,
ConfigValueSource? ConnmarkAllowlistEnable,
ConfigValueSource? ConnmarkAllowlistEnable, // single value (empty or mask)
ConfigValueSource? NoRoundRobin,
ConfigValueSource? DnssecNoTimecheck,
ConfigValueSource? DnssecDebug,
ConfigValueSource? Leasequery,
IReadOnlyList<ValueWithSource> LeasequeryValues,
ConfigValueSource? DhcpGenerateNames,
ConfigValueSource? DhcpBroadcast,
ConfigValueSource? DhcpSequentialIp,
@@ -69,6 +69,7 @@ public record EffectiveDnsmasqConfig(
IReadOnlyList<string> DhcpHostsfilePaths,
IReadOnlyList<string> DhcpOptsfilePaths,
IReadOnlyList<string> DhcpHostsdirPaths,
IReadOnlyList<string> DhcpOptsdirPaths,
IReadOnlyList<string> ConnmarkAllowlistValues,
IReadOnlyList<string> CaaRecordValues,
IReadOnlyList<string> DnsRrValues,
@@ -114,7 +115,7 @@ public record EffectiveDnsmasqConfig(
bool TftpNoFail,
bool TftpNoBlocksize,
bool Dnssec,
bool DnssecCheckUnsigned,
string? DnssecCheckUnsigned,
bool ReadEthers,
bool DhcpRapidCommit,
bool Localmx,
@@ -124,16 +125,16 @@ public record EffectiveDnsmasqConfig(
bool KeepInForeground,
bool NoDaemon,
bool ProxyDnssec,
bool ConnmarkAllowlistEnable,
string? ConnmarkAllowlistEnable,
bool NoRoundRobin,
bool DnssecNoTimecheck,
bool DnssecDebug,
bool Leasequery,
bool DhcpGenerateNames,
bool DhcpBroadcast,
IReadOnlyList<string> LeasequeryValues,
string? DhcpGenerateNames,
string? DhcpBroadcast,
bool DhcpSequentialIp,
bool DhcpIgnoreClid,
bool BootpDynamic,
string? BootpDynamic,
bool NoPing,
bool ScriptArp,
bool ScriptOnRenewal,
@@ -0,0 +1,2 @@
<dt><b>--dhcp-optsdir=&lt;path&gt;</b></dt>
<dd>This is equivalent to <b>--dhcp-optsfile</b>, with the differences noted for <b>--dhcp-hostsdir</b>.</dd>