mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
EffectiveConfig: centralize semantic validation and improve editor UX
Unify parser, editor, and save-time validation around semantic metadata and handlers, add path-aware and structured option checks, and improve effective-config editing placeholders and unset behavior.
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests;
|
||||||
|
|
||||||
@@ -9,35 +11,19 @@ namespace DnsmasqWebUI.Tests;
|
|||||||
public class EffectiveConfigSemanticsWiringTests
|
public class EffectiveConfigSemanticsWiringTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParserBehaviorMap_UsesSpecialSemantics_ForSpecialOptions()
|
public void ParserBehaviorMap_UsesSpecialSemantics_ForAllSpecialOptions()
|
||||||
{
|
{
|
||||||
var specialOptions = new[]
|
foreach (var option in EffectiveConfigSpecialOptionSemantics.GetAllOptionNames())
|
||||||
{
|
{
|
||||||
DnsmasqConfKeys.UseStaleCache,
|
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(option)!;
|
||||||
DnsmasqConfKeys.AddMac,
|
Assert.Equal(semantics.ParserBehavior, EffectiveConfigParserBehaviorMap.GetBehavior(option));
|
||||||
DnsmasqConfKeys.AddSubnet,
|
|
||||||
DnsmasqConfKeys.Umbrella,
|
|
||||||
DnsmasqConfKeys.Do0x20Encode,
|
|
||||||
DnsmasqConfKeys.ConnmarkAllowlistEnable,
|
|
||||||
DnsmasqConfKeys.DnssecCheckUnsigned,
|
|
||||||
DnsmasqConfKeys.Leasequery,
|
|
||||||
DnsmasqConfKeys.DhcpGenerateNames,
|
|
||||||
DnsmasqConfKeys.DhcpBroadcast,
|
|
||||||
DnsmasqConfKeys.BootpDynamic,
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var option in specialOptions)
|
|
||||||
{
|
|
||||||
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(option);
|
|
||||||
Assert.NotNull(semantics);
|
|
||||||
Assert.Equal(semantics!.ParserBehavior, EffectiveConfigParserBehaviorMap.GetBehavior(option));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics()
|
public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics()
|
||||||
{
|
{
|
||||||
var registry = new EffectiveConfigRenderFragmentRegistry();
|
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new LeasequerySemanticHandler()]));
|
||||||
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery);
|
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery);
|
||||||
Assert.NotNull(factory);
|
Assert.NotNull(factory);
|
||||||
|
|
||||||
@@ -51,4 +37,20 @@ public class EffectiveConfigSemanticsWiringTests
|
|||||||
Assert.NotNull(descriptor.Validator!.ValidateItem("not-an-ip", Array.Empty<string>()));
|
Assert.NotNull(descriptor.Validator!.ValidateItem("not-an-ip", Array.Empty<string>()));
|
||||||
Assert.Null(descriptor.Validator.ValidateItem("10.0.0.0/24", Array.Empty<string>()));
|
Assert.Null(descriptor.Validator.ValidateItem("10.0.0.0/24", Array.Empty<string>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Pre-save semantic blocking: invalid leasequery value produces errors; SaveService uses this and returns SemanticValidationFailed before write.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void SemanticValidationService_InvalidLeasequery_ReturnsError()
|
||||||
|
{
|
||||||
|
var validator = new OptionSemanticValidator([new LeasequerySemanticHandler()]);
|
||||||
|
var service = new EffectiveConfigSemanticValidationService(validator);
|
||||||
|
var changes = new List<PendingEffectiveConfigChange>
|
||||||
|
{
|
||||||
|
new(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery, null,
|
||||||
|
new List<string> { "not-an-ip" }, null)
|
||||||
|
};
|
||||||
|
var issues = service.Validate(changes);
|
||||||
|
Assert.NotEmpty(issues);
|
||||||
|
Assert.Contains(issues, i => i.Severity == FieldIssueSeverity.Error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the central semantic validator (handler dispatch and generic kind validation).
|
||||||
|
/// </summary>
|
||||||
|
public class OptionSemanticValidatorTests
|
||||||
|
{
|
||||||
|
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
|
||||||
|
new LeasequerySemanticHandler(),
|
||||||
|
new ServerSemanticHandler(),
|
||||||
|
new RevServerSemanticHandler(),
|
||||||
|
new AddressSemanticHandler(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateMultiItem_LeasequeryHandler_InvalidIp_ReturnsError()
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||||
|
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Leasequery, "not-an-ip", semantics));
|
||||||
|
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Leasequery, "10.0.0.0/24", semantics));
|
||||||
|
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Leasequery, "", semantics));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1.2.3.4", true)]
|
||||||
|
[InlineData("::1", true)]
|
||||||
|
[InlineData("", true)]
|
||||||
|
[InlineData("x.y.z", false)]
|
||||||
|
public void ValidateMultiItem_IpAddressKind_AcceptsValid_RejectsInvalid(string value, bool valid)
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.IpAddress, allowEmpty: true);
|
||||||
|
var err = _validator.ValidateMultiItem("option", value, semantics);
|
||||||
|
Assert.Equal(valid, err is null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", true, true)]
|
||||||
|
[InlineData("x", true, true)]
|
||||||
|
[InlineData("", false, false)]
|
||||||
|
public void ValidateMultiItem_StringKind_RespectsAllowEmpty(string value, bool allowEmpty, bool expectValid)
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.String, allowEmpty: allowEmpty);
|
||||||
|
var err = _validator.ValidateMultiItem("option", value, semantics);
|
||||||
|
Assert.Equal(expectValid, err is null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("8.8.8.8", true)]
|
||||||
|
[InlineData("dns.example.com", true)]
|
||||||
|
[InlineData("/internal.lan/192.168.2.1", true)]
|
||||||
|
[InlineData("/google.com/#", true)]
|
||||||
|
[InlineData("//", true)]
|
||||||
|
[InlineData("", false)]
|
||||||
|
[InlineData("http://bad", false)]
|
||||||
|
[InlineData("/internal$lan/192.168.2.1", false)]
|
||||||
|
[InlineData("/google.com/192.168.2.1#70000", false)]
|
||||||
|
[InlineData("/google.com/@eth0", false)]
|
||||||
|
public void ValidateMultiItem_Server_UsesHandler(string value, bool valid)
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||||
|
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Server, value, semantics);
|
||||||
|
Assert.Equal(valid, err is null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("192.168.1.1", true)]
|
||||||
|
[InlineData("::1", true)]
|
||||||
|
[InlineData("", false)]
|
||||||
|
[InlineData("dns.example.com", false)]
|
||||||
|
public void ValidateMultiItem_ListenAddress_UsesIpAddressSemantics(string value, bool valid)
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.IpAddress, allowEmpty: false);
|
||||||
|
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.ListenAddress, value, semantics);
|
||||||
|
Assert.Equal(valid, err is null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1.2.3.0/24,192.168.1.1", true)]
|
||||||
|
[InlineData("2001:db8::/64,2001:4860:4860::8888", true)]
|
||||||
|
[InlineData("1.2.3.0/33,192.168.1.1", false)]
|
||||||
|
[InlineData("not-an-ip/24,192.168.1.1", false)]
|
||||||
|
[InlineData("1.2.3.0/x,192.168.1.1", false)]
|
||||||
|
[InlineData("1.2.3.0/24,", false)]
|
||||||
|
public void ValidateMultiItem_RevServer_UsesHandler(string value, bool valid)
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||||
|
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.RevServer, value, semantics);
|
||||||
|
Assert.Equal(valid, err is null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/example.local/192.168.1.10", true)]
|
||||||
|
[InlineData("/#/1.2.3.4", true)]
|
||||||
|
[InlineData("/example.local/#", true)]
|
||||||
|
[InlineData("/example.local/", true)]
|
||||||
|
[InlineData("example.local/192.168.1.10", false)]
|
||||||
|
[InlineData("//192.168.1.10", false)]
|
||||||
|
[InlineData("/example.local/not-an-ip", false)]
|
||||||
|
public void ValidateMultiItem_Address_UsesHandler(string value, bool valid)
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||||
|
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Address, value, semantics);
|
||||||
|
Assert.Equal(valid, err is null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateSingle_UseStaleCache_UsesEngineRule()
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||||
|
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.UseStaleCache, "", semantics));
|
||||||
|
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.UseStaleCache, "0", semantics));
|
||||||
|
Assert.NotNull(_validator.ValidateSingle(DnsmasqConfKeys.UseStaleCache, "x", semantics));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateSingle_AddMac_UsesEngineRule()
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||||
|
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.AddMac, "", semantics));
|
||||||
|
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.AddMac, "base64", semantics));
|
||||||
|
Assert.NotNull(_validator.ValidateSingle(DnsmasqConfKeys.AddMac, "bogus", semantics));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("24,96")]
|
||||||
|
[InlineData("0/0")]
|
||||||
|
public void ValidateSingle_AddSubnet_RemainsIntentionallyPermissive(string input)
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||||
|
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.AddSubnet, input, semantics));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("org-id,asset-id")]
|
||||||
|
[InlineData("device-123")]
|
||||||
|
public void ValidateSingle_Umbrella_RemainsIntentionallyPermissive(string input)
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||||
|
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.Umbrella, input, semantics));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", true)]
|
||||||
|
[InlineData("0xff", true)]
|
||||||
|
[InlineData("255", true)]
|
||||||
|
[InlineData("0xZZ", false)]
|
||||||
|
[InlineData("-1", false)]
|
||||||
|
public void ValidateSingle_ConnmarkAllowlistEnable_UsesEngineRule(string input, bool valid)
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||||
|
var err = _validator.ValidateSingle(DnsmasqConfKeys.ConnmarkAllowlistEnable, input, semantics);
|
||||||
|
Assert.Equal(valid, err is null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", true)]
|
||||||
|
[InlineData("no", true)]
|
||||||
|
[InlineData("yes", false)]
|
||||||
|
public void ValidateSingle_DnssecCheckUnsigned_UsesEngineRule(string input, bool valid)
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||||
|
var err = _validator.ValidateSingle(DnsmasqConfKeys.DnssecCheckUnsigned, input, semantics);
|
||||||
|
Assert.Equal(valid, err is null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateSingle_IntKind_AcceptsIntOrNumericString()
|
||||||
|
{
|
||||||
|
var semantics = new OptionValidationSemantics(OptionValidationKind.Int);
|
||||||
|
Assert.Null(_validator.ValidateSingle("option", 42, semantics));
|
||||||
|
Assert.Null(_validator.ValidateSingle("option", "99", semantics));
|
||||||
|
Assert.NotNull(_validator.ValidateSingle("option", "abc", semantics));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OptionValidationSemantics_PathPolicy_OnNonPathKind_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() =>
|
||||||
|
new OptionValidationSemantics(
|
||||||
|
OptionValidationKind.Int,
|
||||||
|
pathPolicy: PathExistencePolicy.MustExist));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateMultiItem_PathFile_RejectsDirectoryPath()
|
||||||
|
{
|
||||||
|
var validator = _validator;
|
||||||
|
var semantics = new OptionValidationSemantics(
|
||||||
|
OptionValidationKind.PathFile,
|
||||||
|
allowEmpty: true,
|
||||||
|
pathPolicy: PathExistencePolicy.MustExist);
|
||||||
|
|
||||||
|
var dir = Directory.CreateTempSubdirectory("dnsmasq-webui-pathfile-");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsfile, dir.FullName, semantics);
|
||||||
|
Assert.Equal("File does not exist.", err);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
dir.Delete(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateMultiItem_PathDirectory_RejectsFilePath()
|
||||||
|
{
|
||||||
|
var validator = _validator;
|
||||||
|
var semantics = new OptionValidationSemantics(
|
||||||
|
OptionValidationKind.PathDirectory,
|
||||||
|
allowEmpty: true,
|
||||||
|
pathPolicy: PathExistencePolicy.MustExist);
|
||||||
|
|
||||||
|
var filePath = Path.Combine(Path.GetTempPath(), $"dnsmasq-webui-pathdir-{Guid.NewGuid():N}.txt");
|
||||||
|
File.WriteAllText(filePath, "test");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsdir, filePath, semantics);
|
||||||
|
Assert.Equal("Directory does not exist.", err);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateMultiItem_PathFile_AcceptsExistingFile()
|
||||||
|
{
|
||||||
|
var validator = _validator;
|
||||||
|
var semantics = new OptionValidationSemantics(
|
||||||
|
OptionValidationKind.PathFile,
|
||||||
|
allowEmpty: true,
|
||||||
|
pathPolicy: PathExistencePolicy.MustExist);
|
||||||
|
|
||||||
|
var filePath = Path.Combine(Path.GetTempPath(), $"dnsmasq-webui-pathfile-{Guid.NewGuid():N}.txt");
|
||||||
|
File.WriteAllText(filePath, "test");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsfile, filePath, semantics);
|
||||||
|
Assert.Null(err);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateMultiItem_PathDirectory_AcceptsExistingDirectory()
|
||||||
|
{
|
||||||
|
var validator = _validator;
|
||||||
|
var semantics = new OptionValidationSemantics(
|
||||||
|
OptionValidationKind.PathDirectory,
|
||||||
|
allowEmpty: true,
|
||||||
|
pathPolicy: PathExistencePolicy.MustExist);
|
||||||
|
|
||||||
|
var dir = Directory.CreateTempSubdirectory("dnsmasq-webui-pathdir-");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsdir, dir.FullName, semantics);
|
||||||
|
Assert.Null(err);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
dir.Delete(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,20 @@
|
|||||||
var fileCount = hasList ? configFileList!.Count : 2;
|
var fileCount = hasList ? configFileList!.Count : 2;
|
||||||
var showToggle = hasMoreThanOne || (!hasList && Status.ManagedFilePath != null);
|
var showToggle = hasMoreThanOne || (!hasList && Status.ManagedFilePath != null);
|
||||||
}
|
}
|
||||||
<p class="config-file-list-title mb-0">Config files (load order)</p>
|
<div class="config-file-list-header">
|
||||||
|
<p class="config-file-list-title mb-0">Config files (load order)</p>
|
||||||
|
@if (showToggle)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="config-file-list-toggle"
|
||||||
|
@onclick="ToggleExpanded"
|
||||||
|
aria-expanded="@_expanded"
|
||||||
|
aria-label="@(_expanded ? "Show only main config" : "Show all config files")">
|
||||||
|
<i class="bi @(_expanded ? "bi-chevron-up" : "bi-chevron-down")" aria-hidden="true"></i>
|
||||||
|
<span>@(_expanded ? "Show less" : $"Show all ({fileCount} files)")</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<div class="config-file-list-content-row">
|
<div class="config-file-list-content-row">
|
||||||
<div class="config-file-list-list">
|
<div class="config-file-list-list">
|
||||||
<ul class="list-unstyled config-file-list-main">
|
<ul class="list-unstyled config-file-list-main">
|
||||||
@@ -45,17 +58,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (showToggle)
|
|
||||||
{
|
|
||||||
<button type="button"
|
|
||||||
class="config-file-list-toggle"
|
|
||||||
@onclick="ToggleExpanded"
|
|
||||||
aria-expanded="@_expanded"
|
|
||||||
aria-label="@(_expanded ? "Show only main config" : "Show all config files")">
|
|
||||||
<i class="bi @(_expanded ? "bi-chevron-up" : "bi-chevron-down")" aria-hidden="true"></i>
|
|
||||||
<span>@(_expanded ? "Show less" : $"Show all ({fileCount} files)")</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,19 @@
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-file-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.config-file-list .config-file-list-title {
|
.config-file-list .config-file-list-title {
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #212529;
|
color: #212529;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-file-list ul {
|
.config-file-list ul {
|
||||||
@@ -34,10 +42,10 @@
|
|||||||
padding: 0.2rem 0;
|
padding: 0.2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content and toggle on one row: list grows, toggle at bottom-right */
|
/* List content row (toggle is in header now) */
|
||||||
.config-file-list-content-row {
|
.config-file-list-content-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
@inherits EffectiveConfigValueDisplayBase
|
@inherits EffectiveConfigValueDisplayBase
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
{
|
{
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control form-control-sm ec-key-only-or-value-input"
|
class="form-control form-control-sm ec-key-only-or-value-input"
|
||||||
placeholder="Optional value"
|
placeholder="@(DnsmasqOptionPlaceholders.Get(Descriptor.OptionName) ?? "Optional value")"
|
||||||
value="@valuePart"
|
value="@valuePart"
|
||||||
@oninput="OnValueInput" />
|
@oninput="OnValueInput" />
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -77,6 +77,7 @@ else
|
|||||||
{
|
{
|
||||||
var value = _effectiveValue;
|
var value = _effectiveValue;
|
||||||
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
|
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
|
||||||
|
var editValue = value?.ToString() ?? "";
|
||||||
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
|
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
|
||||||
var showReadonlyHint = IsEditMode && isReadonly;
|
var showReadonlyHint = IsEditMode && isReadonly;
|
||||||
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
|
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
|
||||||
@@ -92,6 +93,7 @@ else
|
|||||||
{
|
{
|
||||||
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
<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"
|
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||||
|
EditValue="@editValue"
|
||||||
ShowEditableBadge="@showEditableForDefault" FieldKey="@FieldKey" IsActive="@IsActive"
|
ShowEditableBadge="@showEditableForDefault" FieldKey="@FieldKey" IsActive="@IsActive"
|
||||||
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange"
|
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange"
|
||||||
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
||||||
@@ -214,6 +216,8 @@ else
|
|||||||
private async Task<bool> CommitIfChangedAsync()
|
private async Task<bool> CommitIfChangedAsync()
|
||||||
{
|
{
|
||||||
var newValue = _hasDraftValue ? _draftValue : _effectiveValue;
|
var newValue = _hasDraftValue ? _draftValue : _effectiveValue;
|
||||||
|
if (IsDefaultSingleValueField)
|
||||||
|
newValue = NormalizeDefaultSingleCommitValue(newValue);
|
||||||
if (ValuesEqual(_valueAtActivation, newValue))
|
if (ValuesEqual(_valueAtActivation, newValue))
|
||||||
{
|
{
|
||||||
_draftValue = null;
|
_draftValue = null;
|
||||||
@@ -244,6 +248,9 @@ else
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsDefaultSingleValueField =>
|
||||||
|
!Descriptor.IsMultiValue && _customFragment is null;
|
||||||
|
|
||||||
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
|
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
|
||||||
|
|
||||||
private void HandleDefaultValueChanged(string value)
|
private void HandleDefaultValueChanged(string value)
|
||||||
@@ -255,12 +262,19 @@ else
|
|||||||
private async Task HandleDefaultBlur(string? value)
|
private async Task HandleDefaultBlur(string? value)
|
||||||
{
|
{
|
||||||
if (!IsActive) return;
|
if (!IsActive) return;
|
||||||
_draftValue = value;
|
_draftValue = NormalizeDefaultSingleCommitValue(value);
|
||||||
_hasDraftValue = true;
|
_hasDraftValue = true;
|
||||||
if (!await CommitIfChangedAsync() && Ui != null)
|
if (!await CommitIfChangedAsync() && Ui != null)
|
||||||
await Ui.ActivateFieldAsync(FieldKey);
|
await Ui.ActivateFieldAsync(FieldKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static object? NormalizeDefaultSingleCommitValue(object? value)
|
||||||
|
{
|
||||||
|
if (value is string s && string.IsNullOrWhiteSpace(s))
|
||||||
|
return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleMultiValueChanged(IReadOnlyList<string> values)
|
private void HandleMultiValueChanged(IReadOnlyList<string> values)
|
||||||
{
|
{
|
||||||
_draftValue = values.ToList();
|
_draftValue = values.ToList();
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
<div class="ec-multi-list-row-controls">
|
<div class="ec-multi-list-row-controls">
|
||||||
<div class="ec-multi-list-input-group">
|
<div class="ec-multi-list-input-group">
|
||||||
<input class="form-control form-control-sm ec-multi-list-input"
|
<input class="form-control form-control-sm ec-multi-list-input"
|
||||||
placeholder="@(Behavior?.Placeholder ?? "")"
|
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
|
||||||
@bind="_draftValue" @bind:event="oninput" />
|
@bind="_draftValue" @bind:event="oninput" />
|
||||||
</div>
|
</div>
|
||||||
<span class="status-badge-inline">
|
<span class="status-badge-inline">
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
<div class="ec-multi-list-row-controls">
|
<div class="ec-multi-list-row-controls">
|
||||||
<div class="ec-multi-list-input-group">
|
<div class="ec-multi-list-input-group">
|
||||||
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input"
|
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input"
|
||||||
placeholder="@(Behavior?.Placeholder ?? "New value")"
|
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
|
||||||
@bind="_draftValue" @bind:event="oninput" />
|
@bind="_draftValue" @bind:event="oninput" />
|
||||||
</div>
|
</div>
|
||||||
<span class="status-badge-inline">
|
<span class="status-badge-inline">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
|
||||||
@@ -6,7 +7,12 @@
|
|||||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true && !IsCapabilityDisabled)
|
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true && !IsCapabilityDisabled)
|
||||||
{
|
{
|
||||||
<span class="ec-field-value" @onclick:stopPropagation="true">
|
<span class="ec-field-value" @onclick:stopPropagation="true">
|
||||||
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
|
<input type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
placeholder="@(DnsmasqOptionPlaceholders.Get(Label) ?? "Value")"
|
||||||
|
value="@(_editValue ?? EditValue)"
|
||||||
|
@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" IsCapabilityDisabled="@IsCapabilityDisabled" CapabilityDisabledReason="@CapabilityDisabledReason" 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>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -21,6 +27,7 @@ else
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public string Label { get; set; } = "";
|
[Parameter] public string Label { get; set; } = "";
|
||||||
[Parameter] public string DisplayValue { get; set; } = "";
|
[Parameter] public string DisplayValue { get; set; } = "";
|
||||||
|
[Parameter] public string EditValue { get; set; } = "";
|
||||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||||
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
|
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
|
||||||
[Parameter] public string? LabelTooltip { get; set; }
|
[Parameter] public string? LabelTooltip { get; set; }
|
||||||
@@ -72,6 +79,6 @@ else
|
|||||||
private async Task OnInputBlur(FocusEventArgs _)
|
private async Task OnInputBlur(FocusEventArgs _)
|
||||||
{
|
{
|
||||||
if (OnBlur.HasDelegate)
|
if (OnBlur.HasDelegate)
|
||||||
await OnBlur.InvokeAsync(_editValue ?? DisplayValue);
|
await OnBlur.InvokeAsync(_editValue ?? EditValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,12 +48,13 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
delegate void RegisterService(IServiceCollection s, Type iface, Type impl);
|
delegate void RegisterService(IServiceCollection s, Type iface, Type impl);
|
||||||
|
|
||||||
static readonly (Type MarkerInterface, RegisterService Register)[] ApplicationRegistrations =
|
static readonly (Type MarkerInterface, RegisterService Register, bool AllowMultipleImplementations)[] ApplicationRegistrations =
|
||||||
[
|
[
|
||||||
(typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)),
|
(typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl), false),
|
||||||
(typeof(IApplicationScopedConcrete), (s, i, impl) => s.AddScoped(impl, impl)),
|
(typeof(IApplicationScopedConcrete), (s, i, impl) => s.AddScoped(impl, impl), false),
|
||||||
(typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)),
|
(typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl), false),
|
||||||
(typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl)),
|
(typeof(IApplicationMultiSingleton), (s, i, impl) => s.AddSingleton(i, impl), true),
|
||||||
|
(typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl), true),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -65,8 +66,8 @@ public static class ServiceCollectionExtensions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
|
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
foreach (var (markerInterface, register) in ApplicationRegistrations)
|
foreach (var (markerInterface, register, allowMultipleImplementations) in ApplicationRegistrations)
|
||||||
ScanAndRegister(services, markerInterface, register);
|
ScanAndRegister(services, markerInterface, register, allowMultipleImplementations);
|
||||||
AddOptionsValidatorsFromAssembly(services, Assembly.GetExecutingAssembly());
|
AddOptionsValidatorsFromAssembly(services, Assembly.GetExecutingAssembly());
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,8 @@ public static class ServiceCollectionExtensions
|
|||||||
static void ScanAndRegister(
|
static void ScanAndRegister(
|
||||||
IServiceCollection services,
|
IServiceCollection services,
|
||||||
Type markerInterface,
|
Type markerInterface,
|
||||||
RegisterService register)
|
RegisterService register,
|
||||||
|
bool allowMultipleImplementations)
|
||||||
{
|
{
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
|
||||||
@@ -116,12 +118,20 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
if (implementations.Count == 0)
|
if (implementations.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
if (implementations.Count > 1)
|
if (!allowMultipleImplementations && implementations.Count > 1)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " +
|
$"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " +
|
||||||
"Register one explicitly or exclude the others from the scan.");
|
"Register one explicitly or exclude the others from the scan.");
|
||||||
|
|
||||||
register(services, iface, implementations[0]);
|
if (allowMultipleImplementations)
|
||||||
|
{
|
||||||
|
foreach (var impl in implementations)
|
||||||
|
register(services, iface, impl);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
register(services, iface, implementations[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Placeholder values shown in effective-config editors before the user enters a value.
|
||||||
|
/// Kept separate from validation semantics and aligned with the existing option tooltip/help metadata pattern.
|
||||||
|
/// </summary>
|
||||||
|
public static class DnsmasqOptionPlaceholders
|
||||||
|
{
|
||||||
|
private static readonly FrozenDictionary<string, string> Values = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
// --- Hosts ---
|
||||||
|
[DnsmasqConfKeys.AddnHosts] = "/etc/dnsmasq.hosts",
|
||||||
|
[DnsmasqConfKeys.Hostsdir] = "/etc/dnsmasq.hosts.d",
|
||||||
|
|
||||||
|
// --- Resolver / DNS ---
|
||||||
|
[DnsmasqConfKeys.Server] = "IP or hostname, e.g. 8.8.8.8",
|
||||||
|
[DnsmasqConfKeys.Local] = "/example.local/",
|
||||||
|
[DnsmasqConfKeys.RevServer] = "CIDR,server, e.g. 1.2.3.0/24,192.168.1.1",
|
||||||
|
[DnsmasqConfKeys.Address] = "/example.local/192.168.1.10",
|
||||||
|
[DnsmasqConfKeys.ResolvFile] = "/etc/resolv.dnsmasq.conf",
|
||||||
|
[DnsmasqConfKeys.FastDnsRetry] = "1000,10000",
|
||||||
|
[DnsmasqConfKeys.RebindDomainOk] = "example.local",
|
||||||
|
[DnsmasqConfKeys.BogusNxdomain] = "64.94.110.11",
|
||||||
|
[DnsmasqConfKeys.IgnoreAddress] = "64.94.110.11",
|
||||||
|
[DnsmasqConfKeys.Alias] = "192.168.0.0,10.0.0.0,255.255.255.0",
|
||||||
|
[DnsmasqConfKeys.FilterRr] = "ANY",
|
||||||
|
[DnsmasqConfKeys.AuthServer] = "example.local,eth0",
|
||||||
|
[DnsmasqConfKeys.NoDhcpInterface] = "eth1",
|
||||||
|
[DnsmasqConfKeys.NoDhcpv4Interface] = "eth1",
|
||||||
|
[DnsmasqConfKeys.NoDhcpv6Interface] = "eth1",
|
||||||
|
[DnsmasqConfKeys.Ipset] = "example.local/ipsetname",
|
||||||
|
[DnsmasqConfKeys.Nftset] = "example.local#inet#filter#setname",
|
||||||
|
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = "mask, e.g. 0xff",
|
||||||
|
[DnsmasqConfKeys.ConnmarkAllowlist] = "0xff,example.local",
|
||||||
|
|
||||||
|
// --- DNS records ---
|
||||||
|
[DnsmasqConfKeys.Domain] = "example.local,192.168.1.1",
|
||||||
|
[DnsmasqConfKeys.Cname] = "router.example.local,router",
|
||||||
|
[DnsmasqConfKeys.MxHost] = "example.local,mail.example.local,10",
|
||||||
|
[DnsmasqConfKeys.MxTarget] = "mail.example.local",
|
||||||
|
[DnsmasqConfKeys.Srv] = "_sip._tcp.example.local,sip.example.local,443,10,5",
|
||||||
|
[DnsmasqConfKeys.PtrRecord] = "10.1.168.192.in-addr.arpa,router.example.local",
|
||||||
|
[DnsmasqConfKeys.TxtRecord] = "example.local,\"hello world\"",
|
||||||
|
[DnsmasqConfKeys.NaptrRecord] = "example.local,100,50,\"s\",\"SIP+D2U\",\"\",_sip._udp.example.local",
|
||||||
|
[DnsmasqConfKeys.HostRecord] = "router.example.local,192.168.1.1",
|
||||||
|
[DnsmasqConfKeys.DynamicHost] = "router.example.local,192.168.1.1,10m",
|
||||||
|
[DnsmasqConfKeys.InterfaceName] = "router.example.local,eth0",
|
||||||
|
[DnsmasqConfKeys.CaaRecord] = "example.local,0,issue,\"letsencrypt.org\"",
|
||||||
|
[DnsmasqConfKeys.DnsRr] = "example.local,16,\"hello world\"",
|
||||||
|
[DnsmasqConfKeys.SynthDomain] = "example.local,192.168.1.0/24,host-*",
|
||||||
|
[DnsmasqConfKeys.AuthZone] = "example.local,192.168.1.0/24",
|
||||||
|
[DnsmasqConfKeys.AuthSoa] = "ns1.example.local,hostmaster.example.local,1,3600,1200,604800",
|
||||||
|
[DnsmasqConfKeys.AuthSecServers] = "example.local,ns2.example.local",
|
||||||
|
[DnsmasqConfKeys.AuthPeer] = "192.168.1.2",
|
||||||
|
|
||||||
|
// --- DHCP ---
|
||||||
|
[DnsmasqConfKeys.Leasequery] = "IP[/prefix], e.g. 10.0.0.0/24",
|
||||||
|
[DnsmasqConfKeys.DhcpGenerateNames] = "tag name, e.g. set:known",
|
||||||
|
[DnsmasqConfKeys.DhcpBroadcast] = "tag:legacy-clients",
|
||||||
|
[DnsmasqConfKeys.DhcpLeasefile] = "/var/lib/misc/dnsmasq.leases",
|
||||||
|
[DnsmasqConfKeys.DhcpRange] = "start,end[,lease], e.g. 192.168.1.50,192.168.1.150,12h",
|
||||||
|
[DnsmasqConfKeys.DhcpHost] = "aa:bb:cc:dd:ee:ff,192.168.1.10,host1,12h",
|
||||||
|
[DnsmasqConfKeys.DhcpOption] = "option:dns-server,192.168.1.1",
|
||||||
|
[DnsmasqConfKeys.DhcpOptionForce] = "option:dns-server,192.168.1.1",
|
||||||
|
[DnsmasqConfKeys.DhcpMatch] = "set:tag,option:vendor-class,Example",
|
||||||
|
[DnsmasqConfKeys.DhcpMac] = "set:tag,aa:bb:cc:*:*:*",
|
||||||
|
[DnsmasqConfKeys.DhcpNameMatch] = "set:tag,hostname*",
|
||||||
|
[DnsmasqConfKeys.DhcpIgnoreNames] = "tag:ignore-names",
|
||||||
|
[DnsmasqConfKeys.DhcpHostsfile] = "/path/to/dhcp-hosts.conf",
|
||||||
|
[DnsmasqConfKeys.DhcpOptsfile] = "/path/to/dhcp-opts.conf",
|
||||||
|
[DnsmasqConfKeys.DhcpHostsdir] = "/path/to/dhcp-hosts.d",
|
||||||
|
[DnsmasqConfKeys.DhcpOptsdir] = "/path/to/dhcp-opts.d",
|
||||||
|
[DnsmasqConfKeys.DhcpBoot] = "pxelinux.0,,192.168.1.2",
|
||||||
|
[DnsmasqConfKeys.DhcpIgnore] = "tag:blocked",
|
||||||
|
[DnsmasqConfKeys.DhcpVendorclass] = "set:pxe,PXEClient",
|
||||||
|
[DnsmasqConfKeys.DhcpUserclass] = "set:userclass,ExampleClient",
|
||||||
|
[DnsmasqConfKeys.RaParam] = "eth0,mtu:1500,high",
|
||||||
|
[DnsmasqConfKeys.Slaac] = "eth0,::10",
|
||||||
|
[DnsmasqConfKeys.DhcpRelay] = "192.168.1.0,192.168.2.1",
|
||||||
|
[DnsmasqConfKeys.DhcpCircuitid] = "tag:uplink,example-circuit",
|
||||||
|
[DnsmasqConfKeys.DhcpRemoteid] = "tag:uplink,example-remote",
|
||||||
|
[DnsmasqConfKeys.DhcpSubscrid] = "tag:uplink,example-subscriber",
|
||||||
|
[DnsmasqConfKeys.DhcpProxy] = "192.168.1.0,192.168.1.1",
|
||||||
|
[DnsmasqConfKeys.TagIf] = "tag:guest,tag:wifi",
|
||||||
|
[DnsmasqConfKeys.BridgeInterface] = "br0,eth0",
|
||||||
|
[DnsmasqConfKeys.SharedNetwork] = "sharednet,192.168.10.0,255.255.255.0",
|
||||||
|
[DnsmasqConfKeys.BootpDynamic] = "tag:bootp",
|
||||||
|
[DnsmasqConfKeys.DhcpAlternatePort] = "1067",
|
||||||
|
[DnsmasqConfKeys.DhcpDuid] = "00:01:00:01:2a:11:22:33:44:55:66:77:88:99",
|
||||||
|
[DnsmasqConfKeys.DhcpLuascript] = "/etc/dnsmasq/dhcp.lua",
|
||||||
|
[DnsmasqConfKeys.DhcpScript] = "/usr/local/bin/dhcp-script.sh",
|
||||||
|
[DnsmasqConfKeys.DhcpScriptuser] = "dnsmasq",
|
||||||
|
[DnsmasqConfKeys.DhcpPxeVendor] = "PXEClient",
|
||||||
|
|
||||||
|
// --- TFTP / PXE ---
|
||||||
|
[DnsmasqConfKeys.TftpRoot] = "/srv/tftp",
|
||||||
|
[DnsmasqConfKeys.PxePrompt] = "\"Boot menu\",5",
|
||||||
|
[DnsmasqConfKeys.PxeService] = "x86PC,\"PXE Boot\",pxelinux",
|
||||||
|
[DnsmasqConfKeys.DhcpOptionPxe] = "vendor:PXEClient,1,0.0.0.0",
|
||||||
|
|
||||||
|
// --- DNSSEC ---
|
||||||
|
[DnsmasqConfKeys.DnssecCheckUnsigned] = "no",
|
||||||
|
[DnsmasqConfKeys.TrustAnchor] = ".,20326,8,2,e06d44b80b8f1d39...",
|
||||||
|
[DnsmasqConfKeys.AddCpeId] = "my-cpe-id",
|
||||||
|
[DnsmasqConfKeys.DnssecTimestamp] = "/var/lib/misc/dnsmasq.timestamp",
|
||||||
|
[DnsmasqConfKeys.DnssecLimits] = "150,1500",
|
||||||
|
|
||||||
|
// --- Cache ---
|
||||||
|
[DnsmasqConfKeys.CacheRr] = "A,AAAA,TXT",
|
||||||
|
[DnsmasqConfKeys.Dumpfile] = "/tmp/dnsmasq.dump",
|
||||||
|
[DnsmasqConfKeys.Dumpmask] = "0x0000ffff",
|
||||||
|
[DnsmasqConfKeys.UseStaleCache] = "seconds, e.g. 60",
|
||||||
|
[DnsmasqConfKeys.AddMac] = "base64 or text",
|
||||||
|
[DnsmasqConfKeys.AddSubnet] = "IPv4/IPv6 prefix, e.g. 24,96",
|
||||||
|
[DnsmasqConfKeys.Umbrella] = "org-id,asset-id",
|
||||||
|
|
||||||
|
// --- Process / networking ---
|
||||||
|
[DnsmasqConfKeys.Interface] = "eth0",
|
||||||
|
[DnsmasqConfKeys.ListenAddress] = "192.168.1.1",
|
||||||
|
[DnsmasqConfKeys.ExceptInterface] = "lo",
|
||||||
|
[DnsmasqConfKeys.LogAsync] = "queue size, e.g. 25",
|
||||||
|
[DnsmasqConfKeys.PidFile] = "/run/dnsmasq.pid",
|
||||||
|
[DnsmasqConfKeys.User] = "dnsmasq",
|
||||||
|
[DnsmasqConfKeys.Group] = "dnsmasq",
|
||||||
|
[DnsmasqConfKeys.LogFacility] = "local0",
|
||||||
|
[DnsmasqConfKeys.EnableDbus] = "uk.org.thekelleys.dnsmasq",
|
||||||
|
[DnsmasqConfKeys.EnableUbus] = "dnsmasq",
|
||||||
|
}.ToFrozenDictionary();
|
||||||
|
|
||||||
|
/// <summary>Returns the placeholder value for an option, or null when none is defined.</summary>
|
||||||
|
public static string? Get(string optionName) =>
|
||||||
|
Values.TryGetValue(optionName, out var value) ? value : null;
|
||||||
|
}
|
||||||
+132
-38
@@ -1,6 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
using System.Linq;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
|
||||||
@@ -13,21 +12,42 @@ public sealed record OptionSemantics(
|
|||||||
string OptionName,
|
string OptionName,
|
||||||
EffectiveConfigParserBehavior ParserBehavior,
|
EffectiveConfigParserBehavior ParserBehavior,
|
||||||
EffectiveConfigWriteBehavior WriteBehavior,
|
EffectiveConfigWriteBehavior WriteBehavior,
|
||||||
EffectiveConfigSingleValueValidator? SingleValueValidator,
|
OptionValidationSemantics Validation
|
||||||
EffectiveConfigMultiItemValidator? MultiItemValidator
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delegate for validating one item in a multi-value option editor.
|
|
||||||
/// </summary>
|
|
||||||
public delegate string? EffectiveConfigMultiItemValidator(string? value);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lookup for special-option semantics. Used by EffectiveConfigWriteSemantics and registry for validators.
|
/// Lookup for special-option semantics. Used by EffectiveConfigWriteSemantics and registry for validators.
|
||||||
/// Inverse-pair options (e.g. Do0x20Encode) have their (enabled key, disabled key) in a separate table.
|
/// Inverse-pair options (e.g. Do0x20Encode) have their (enabled key, disabled key) in a separate table.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class EffectiveConfigSpecialOptionSemantics
|
public static class EffectiveConfigSpecialOptionSemantics
|
||||||
{
|
{
|
||||||
|
private static readonly OptionValidationSemantics KeyOnlyOrValue = new(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||||
|
private static readonly OptionValidationSemantics InversePair = new(OptionValidationKind.InversePair);
|
||||||
|
private static readonly OptionValidationSemantics ComplexMulti = new(OptionValidationKind.Complex, allowEmpty: true);
|
||||||
|
private static readonly OptionValidationSemantics IpAddressMulti = new(
|
||||||
|
OptionValidationKind.IpAddress,
|
||||||
|
allowEmpty: false);
|
||||||
|
private static readonly OptionValidationSemantics PathFileMulti = new(
|
||||||
|
OptionValidationKind.PathFile,
|
||||||
|
allowEmpty: true,
|
||||||
|
pathPolicy: PathExistencePolicy.MustExist);
|
||||||
|
private static readonly OptionValidationSemantics PathDirectoryMulti = new(
|
||||||
|
OptionValidationKind.PathDirectory,
|
||||||
|
allowEmpty: true,
|
||||||
|
pathPolicy: PathExistencePolicy.MustExist);
|
||||||
|
private static readonly OptionValidationSemantics PathFileSingleMustExist = new(
|
||||||
|
OptionValidationKind.PathFile,
|
||||||
|
allowEmpty: true,
|
||||||
|
pathPolicy: PathExistencePolicy.MustExist);
|
||||||
|
private static readonly OptionValidationSemantics PathDirectorySingleMustExist = new(
|
||||||
|
OptionValidationKind.PathDirectory,
|
||||||
|
allowEmpty: true,
|
||||||
|
pathPolicy: PathExistencePolicy.MustExist);
|
||||||
|
private static readonly OptionValidationSemantics PathFileSingleParentMustExist = new(
|
||||||
|
OptionValidationKind.PathFile,
|
||||||
|
allowEmpty: true,
|
||||||
|
pathPolicy: PathExistencePolicy.ParentMustExist);
|
||||||
|
|
||||||
private static readonly IReadOnlyDictionary<string, OptionSemantics> ByOptionName =
|
private static readonly IReadOnlyDictionary<string, OptionSemantics> ByOptionName =
|
||||||
new Dictionary<string, OptionSemantics>(StringComparer.Ordinal)
|
new Dictionary<string, OptionSemantics>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
@@ -35,68 +55,147 @@ public static class EffectiveConfigSpecialOptionSemantics
|
|||||||
DnsmasqConfKeys.UseStaleCache,
|
DnsmasqConfKeys.UseStaleCache,
|
||||||
EffectiveConfigParserBehavior.LastWins,
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||||
SpecialOptionValidators.ValidateUseStaleCache,
|
KeyOnlyOrValue),
|
||||||
MultiItemValidator: null),
|
|
||||||
[DnsmasqConfKeys.AddMac] = new OptionSemantics(
|
[DnsmasqConfKeys.AddMac] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.AddMac,
|
DnsmasqConfKeys.AddMac,
|
||||||
EffectiveConfigParserBehavior.LastWins,
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||||
SpecialOptionValidators.ValidateAddMac,
|
KeyOnlyOrValue),
|
||||||
MultiItemValidator: null),
|
|
||||||
[DnsmasqConfKeys.AddSubnet] = new OptionSemantics(
|
[DnsmasqConfKeys.AddSubnet] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.AddSubnet,
|
DnsmasqConfKeys.AddSubnet,
|
||||||
EffectiveConfigParserBehavior.LastWins,
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||||
SpecialOptionValidators.ValidateAddSubnet,
|
KeyOnlyOrValue),
|
||||||
MultiItemValidator: null),
|
|
||||||
[DnsmasqConfKeys.Umbrella] = new OptionSemantics(
|
[DnsmasqConfKeys.Umbrella] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.Umbrella,
|
DnsmasqConfKeys.Umbrella,
|
||||||
EffectiveConfigParserBehavior.LastWins,
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||||
SpecialOptionValidators.ValidateUmbrella,
|
KeyOnlyOrValue),
|
||||||
MultiItemValidator: null),
|
|
||||||
[DnsmasqConfKeys.Do0x20Encode] = new OptionSemantics(
|
[DnsmasqConfKeys.Do0x20Encode] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.Do0x20Encode,
|
DnsmasqConfKeys.Do0x20Encode,
|
||||||
EffectiveConfigParserBehavior.Flag,
|
EffectiveConfigParserBehavior.Flag,
|
||||||
EffectiveConfigWriteBehavior.InversePair,
|
EffectiveConfigWriteBehavior.InversePair,
|
||||||
SingleValueValidator: null,
|
InversePair),
|
||||||
MultiItemValidator: null),
|
|
||||||
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = new OptionSemantics(
|
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.ConnmarkAllowlistEnable,
|
DnsmasqConfKeys.ConnmarkAllowlistEnable,
|
||||||
EffectiveConfigParserBehavior.LastWins,
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||||
SpecialOptionValidators.ValidateConnmarkAllowlistEnable,
|
KeyOnlyOrValue),
|
||||||
MultiItemValidator: null),
|
|
||||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = new OptionSemantics(
|
[DnsmasqConfKeys.DnssecCheckUnsigned] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.DnssecCheckUnsigned,
|
DnsmasqConfKeys.DnssecCheckUnsigned,
|
||||||
EffectiveConfigParserBehavior.LastWins,
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||||
SpecialOptionValidators.ValidateDnssecCheckUnsigned,
|
KeyOnlyOrValue),
|
||||||
MultiItemValidator: null),
|
|
||||||
[DnsmasqConfKeys.Leasequery] = new OptionSemantics(
|
[DnsmasqConfKeys.Leasequery] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.Leasequery,
|
DnsmasqConfKeys.Leasequery,
|
||||||
EffectiveConfigParserBehavior.Multi,
|
EffectiveConfigParserBehavior.Multi,
|
||||||
EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue,
|
EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue,
|
||||||
SingleValueValidator: null,
|
ComplexMulti),
|
||||||
SpecialOptionValidators.ValidateLeasequeryValue),
|
[DnsmasqConfKeys.Server] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.Server,
|
||||||
|
EffectiveConfigParserBehavior.Multi,
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
|
ComplexMulti),
|
||||||
|
[DnsmasqConfKeys.RevServer] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.RevServer,
|
||||||
|
EffectiveConfigParserBehavior.Multi,
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
|
ComplexMulti),
|
||||||
|
[DnsmasqConfKeys.Address] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.Address,
|
||||||
|
EffectiveConfigParserBehavior.Multi,
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
|
ComplexMulti),
|
||||||
|
[DnsmasqConfKeys.ListenAddress] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.ListenAddress,
|
||||||
|
EffectiveConfigParserBehavior.Multi,
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
|
IpAddressMulti),
|
||||||
[DnsmasqConfKeys.DhcpGenerateNames] = new OptionSemantics(
|
[DnsmasqConfKeys.DhcpGenerateNames] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.DhcpGenerateNames,
|
DnsmasqConfKeys.DhcpGenerateNames,
|
||||||
EffectiveConfigParserBehavior.LastWins,
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||||
SingleValueValidator: null,
|
KeyOnlyOrValue),
|
||||||
MultiItemValidator: null),
|
|
||||||
[DnsmasqConfKeys.DhcpBroadcast] = new OptionSemantics(
|
[DnsmasqConfKeys.DhcpBroadcast] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.DhcpBroadcast,
|
DnsmasqConfKeys.DhcpBroadcast,
|
||||||
EffectiveConfigParserBehavior.LastWins,
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||||
SingleValueValidator: null,
|
KeyOnlyOrValue),
|
||||||
MultiItemValidator: null),
|
|
||||||
[DnsmasqConfKeys.BootpDynamic] = new OptionSemantics(
|
[DnsmasqConfKeys.BootpDynamic] = new OptionSemantics(
|
||||||
DnsmasqConfKeys.BootpDynamic,
|
DnsmasqConfKeys.BootpDynamic,
|
||||||
EffectiveConfigParserBehavior.LastWins,
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||||
SingleValueValidator: null,
|
KeyOnlyOrValue),
|
||||||
MultiItemValidator: null),
|
[DnsmasqConfKeys.AddnHosts] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.AddnHosts,
|
||||||
|
EffectiveConfigParserBehavior.Multi,
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
|
PathFileMulti),
|
||||||
|
[DnsmasqConfKeys.ResolvFile] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.ResolvFile,
|
||||||
|
EffectiveConfigParserBehavior.Multi,
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
|
PathFileMulti),
|
||||||
|
[DnsmasqConfKeys.Hostsdir] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.Hostsdir,
|
||||||
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
|
EffectiveConfigWriteBehavior.SingleValue,
|
||||||
|
PathDirectorySingleMustExist),
|
||||||
|
[DnsmasqConfKeys.DhcpLeasefile] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.DhcpLeasefile,
|
||||||
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
|
EffectiveConfigWriteBehavior.SingleValue,
|
||||||
|
PathFileSingleParentMustExist),
|
||||||
|
[DnsmasqConfKeys.TftpRoot] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.TftpRoot,
|
||||||
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
|
EffectiveConfigWriteBehavior.SingleValue,
|
||||||
|
PathDirectorySingleMustExist),
|
||||||
|
[DnsmasqConfKeys.PidFile] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.PidFile,
|
||||||
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
|
EffectiveConfigWriteBehavior.SingleValue,
|
||||||
|
PathFileSingleParentMustExist),
|
||||||
|
[DnsmasqConfKeys.Dumpfile] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.Dumpfile,
|
||||||
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
|
EffectiveConfigWriteBehavior.SingleValue,
|
||||||
|
PathFileSingleParentMustExist),
|
||||||
|
[DnsmasqConfKeys.DnssecTimestamp] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.DnssecTimestamp,
|
||||||
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
|
EffectiveConfigWriteBehavior.SingleValue,
|
||||||
|
PathFileSingleParentMustExist),
|
||||||
|
[DnsmasqConfKeys.DhcpScript] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.DhcpScript,
|
||||||
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
|
EffectiveConfigWriteBehavior.SingleValue,
|
||||||
|
PathFileSingleMustExist),
|
||||||
|
[DnsmasqConfKeys.DhcpLuascript] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.DhcpLuascript,
|
||||||
|
EffectiveConfigParserBehavior.LastWins,
|
||||||
|
EffectiveConfigWriteBehavior.SingleValue,
|
||||||
|
PathFileSingleMustExist),
|
||||||
|
[DnsmasqConfKeys.DhcpHostsfile] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.DhcpHostsfile,
|
||||||
|
EffectiveConfigParserBehavior.Multi,
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
|
PathFileMulti),
|
||||||
|
[DnsmasqConfKeys.DhcpOptsfile] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.DhcpOptsfile,
|
||||||
|
EffectiveConfigParserBehavior.Multi,
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
|
PathFileMulti),
|
||||||
|
[DnsmasqConfKeys.DhcpHostsdir] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.DhcpHostsdir,
|
||||||
|
EffectiveConfigParserBehavior.Multi,
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
|
PathDirectoryMulti),
|
||||||
|
[DnsmasqConfKeys.DhcpOptsdir] = new OptionSemantics(
|
||||||
|
DnsmasqConfKeys.DhcpOptsdir,
|
||||||
|
EffectiveConfigParserBehavior.Multi,
|
||||||
|
EffectiveConfigWriteBehavior.MultiValue,
|
||||||
|
PathDirectoryMulti),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints.</summary>
|
/// <summary>Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints.</summary>
|
||||||
@@ -118,11 +217,6 @@ public static class EffectiveConfigSpecialOptionSemantics
|
|||||||
public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
|
public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
|
||||||
InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null;
|
InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null;
|
||||||
|
|
||||||
/// <summary>Returns validator from semantics for special options; otherwise null.</summary>
|
/// <summary>Returns all option names that have special semantics. Used by wiring tests to avoid hardcoded lists.</summary>
|
||||||
public static EffectiveConfigSingleValueValidator? GetValidator(string optionName) =>
|
public static IReadOnlyCollection<string> GetAllOptionNames() => ByOptionName.Keys.ToList();
|
||||||
TryGetSemantics(optionName)?.SingleValueValidator;
|
|
||||||
|
|
||||||
/// <summary>Returns per-item multi validator from semantics for special options; otherwise null.</summary>
|
|
||||||
public static EffectiveConfigMultiItemValidator? GetMultiItemValidator(string optionName) =>
|
|
||||||
TryGetSemantics(optionName)?.MultiItemValidator;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared validation kinds for effective-config options.
|
||||||
|
/// Describes the generic value shape; option-specific rules live in the semantic validator or handlers.
|
||||||
|
/// </summary>
|
||||||
|
public enum OptionValidationKind
|
||||||
|
{
|
||||||
|
Flag,
|
||||||
|
Int,
|
||||||
|
String,
|
||||||
|
PathFile,
|
||||||
|
PathDirectory,
|
||||||
|
PathFileOrDirectory,
|
||||||
|
IpAddress,
|
||||||
|
HostOrIp,
|
||||||
|
KeyOnlyOrValue,
|
||||||
|
InversePair,
|
||||||
|
Complex
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a path value must exist (file/dir) or only its parent, for validation.
|
||||||
|
/// </summary>
|
||||||
|
public enum PathExistencePolicy
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
MustExist,
|
||||||
|
ParentMustExist
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reusable validation metadata for an option: validation kind, path policy, severity, and empty handling.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OptionValidationSemantics
|
||||||
|
{
|
||||||
|
public OptionValidationKind Kind { get; }
|
||||||
|
public FieldIssueSeverity Severity { get; }
|
||||||
|
public bool AllowEmpty { get; }
|
||||||
|
public PathExistencePolicy? PathPolicy { get; }
|
||||||
|
|
||||||
|
public OptionValidationSemantics(
|
||||||
|
OptionValidationKind kind,
|
||||||
|
FieldIssueSeverity severity = FieldIssueSeverity.Error,
|
||||||
|
bool allowEmpty = true,
|
||||||
|
PathExistencePolicy? pathPolicy = null)
|
||||||
|
{
|
||||||
|
if (pathPolicy is not null && kind is not (
|
||||||
|
OptionValidationKind.PathFile or
|
||||||
|
OptionValidationKind.PathDirectory or
|
||||||
|
OptionValidationKind.PathFileOrDirectory))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("PathPolicy can only be used with path validation kinds.", nameof(pathPolicy));
|
||||||
|
}
|
||||||
|
|
||||||
|
Kind = kind;
|
||||||
|
Severity = severity;
|
||||||
|
AllowEmpty = allowEmpty;
|
||||||
|
PathPolicy = pathPolicy;
|
||||||
|
}
|
||||||
|
}
|
||||||
+274
-165
@@ -17,17 +17,38 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
{
|
{
|
||||||
private static readonly IMultiValueEditBehavior DefaultMultiBehavior = new DefaultMultiValueEditBehavior();
|
private static readonly IMultiValueEditBehavior DefaultMultiBehavior = new DefaultMultiValueEditBehavior();
|
||||||
|
|
||||||
|
private readonly IOptionSemanticValidator _semanticValidator;
|
||||||
private readonly Dictionary<(string SectionId, string OptionName), Type> _displayComponents = new();
|
private readonly Dictionary<(string SectionId, string OptionName), Type> _displayComponents = new();
|
||||||
private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigDescriptorFactory> _descriptorFactories = new();
|
private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigDescriptorFactory> _descriptorFactories = new();
|
||||||
private readonly Dictionary<(string SectionId, string OptionName), Type> _multiDisplayComponents = new();
|
private readonly Dictionary<(string SectionId, string OptionName), Type> _multiDisplayComponents = new();
|
||||||
private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigMultiDescriptorFactory> _multiDescriptorFactories = new();
|
private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigMultiDescriptorFactory> _multiDescriptorFactories = new();
|
||||||
|
|
||||||
public EffectiveConfigRenderFragmentRegistry()
|
public EffectiveConfigRenderFragmentRegistry(IOptionSemanticValidator semanticValidator)
|
||||||
{
|
{
|
||||||
// Port: single-field display (e.g. "53 (default DNS port)").
|
_semanticValidator = semanticValidator;
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay));
|
RegisterAll();
|
||||||
|
}
|
||||||
|
|
||||||
// Integer single-value options: component type + factory that creates EffectiveIntegerConfigFieldDescriptor.
|
private void RegisterAll()
|
||||||
|
{
|
||||||
|
RegisterCustomSingles();
|
||||||
|
RegisterIntegerDescriptors();
|
||||||
|
RegisterFlagDisplays();
|
||||||
|
RegisterKeyOnlyOrValueDisplays();
|
||||||
|
RegisterPathValidatedSingles();
|
||||||
|
RegisterMultiValueDescriptors();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterCustomSingles()
|
||||||
|
{
|
||||||
|
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay));
|
||||||
|
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay));
|
||||||
|
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay));
|
||||||
|
RegisterComponent(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterIntegerDescriptors()
|
||||||
|
{
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AuthTtl, unit: "seconds");
|
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AuthTtl, unit: "seconds");
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.EdnsPacketMax, max: 65535);
|
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.EdnsPacketMax, max: 65535);
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.QueryPort, max: 65535, defaultValue: 0);
|
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.QueryPort, max: 65535, defaultValue: 0);
|
||||||
@@ -43,170 +64,252 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.MinCacheTtl, unit: "seconds");
|
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.MinCacheTtl, unit: "seconds");
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, min: 0);
|
RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, min: 0);
|
||||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpTtl);
|
RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpTtl);
|
||||||
|
}
|
||||||
|
|
||||||
// Boolean flags: shared display ("Enabled" / "Disabled") for all flag options.
|
private void RegisterFlagDisplays()
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts);
|
{
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ExpandHosts);
|
RegisterFlags(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ReadEthers);
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusPriv);
|
RegisterFlags(
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StrictOrder);
|
EffectiveConfigFieldBuilder.SectionResolver,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AllServers);
|
DnsmasqConfKeys.ExpandHosts,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoResolv);
|
DnsmasqConfKeys.BogusPriv,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DomainNeeded);
|
DnsmasqConfKeys.StrictOrder,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.NoNegcache);
|
DnsmasqConfKeys.AllServers,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoPoll);
|
DnsmasqConfKeys.NoResolv,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindInterfaces);
|
DnsmasqConfKeys.DomainNeeded,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindDynamic);
|
DnsmasqConfKeys.DnsLoopDetect,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDebug);
|
DnsmasqConfKeys.StopDnsRebind,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DnsLoopDetect);
|
DnsmasqConfKeys.RebindLocalhostOk,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StopDnsRebind);
|
DnsmasqConfKeys.ClearOnReload,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RebindLocalhostOk);
|
DnsmasqConfKeys.Filterwin2k,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ClearOnReload);
|
DnsmasqConfKeys.FilterA,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Filterwin2k);
|
DnsmasqConfKeys.FilterAaaa,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterA);
|
DnsmasqConfKeys.LocaliseQueries,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterAaaa);
|
DnsmasqConfKeys.NoRoundRobin);
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LocaliseQueries);
|
RegisterFlags(
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative);
|
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.ReadEthers);
|
DnsmasqConfKeys.DhcpAuthoritative,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRapidCommit);
|
DnsmasqConfKeys.DhcpRapidCommit,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.LeasefileRo);
|
DnsmasqConfKeys.LeasefileRo,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.EnableTftp);
|
DnsmasqConfKeys.DhcpSequentialIp,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpSecure);
|
DnsmasqConfKeys.DhcpIgnoreClid,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoFail);
|
DnsmasqConfKeys.NoPing,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoBlocksize);
|
DnsmasqConfKeys.ScriptArp,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx);
|
DnsmasqConfKeys.ScriptOnRenewal,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Selfmx);
|
DnsmasqConfKeys.DhcpNoOverride);
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.Dnssec);
|
RegisterFlags(
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.EnableRa);
|
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDhcp);
|
DnsmasqConfKeys.EnableTftp,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.KeepInForeground);
|
DnsmasqConfKeys.TftpSecure,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDaemon);
|
DnsmasqConfKeys.TftpNoFail,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.Conntrack);
|
DnsmasqConfKeys.TftpNoBlocksize);
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.ProxyDnssec);
|
RegisterFlags(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx, DnsmasqConfKeys.Selfmx);
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoRoundRobin);
|
RegisterFlags(
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecNoTimecheck);
|
EffectiveConfigFieldBuilder.SectionDnssec,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecDebug);
|
DnsmasqConfKeys.Dnssec,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpSequentialIp);
|
DnsmasqConfKeys.ProxyDnssec,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpIgnoreClid);
|
DnsmasqConfKeys.DnssecNoTimecheck,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.NoPing);
|
DnsmasqConfKeys.DnssecDebug);
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.ScriptArp);
|
RegisterFlags(
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.ScriptOnRenewal);
|
EffectiveConfigFieldBuilder.SectionCache,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpNoOverride);
|
DnsmasqConfKeys.NoNegcache,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietDhcp);
|
DnsmasqConfKeys.StripMac,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietDhcp6);
|
DnsmasqConfKeys.StripSubnet);
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietRa);
|
RegisterFlags(
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietTftp);
|
EffectiveConfigFieldBuilder.SectionProcess,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.StripMac);
|
DnsmasqConfKeys.NoPoll,
|
||||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.StripSubnet);
|
DnsmasqConfKeys.BindInterfaces,
|
||||||
|
DnsmasqConfKeys.BindDynamic,
|
||||||
|
DnsmasqConfKeys.LogDebug,
|
||||||
|
DnsmasqConfKeys.EnableRa,
|
||||||
|
DnsmasqConfKeys.LogDhcp,
|
||||||
|
DnsmasqConfKeys.KeepInForeground,
|
||||||
|
DnsmasqConfKeys.NoDaemon,
|
||||||
|
DnsmasqConfKeys.Conntrack,
|
||||||
|
DnsmasqConfKeys.QuietDhcp,
|
||||||
|
DnsmasqConfKeys.QuietDhcp6,
|
||||||
|
DnsmasqConfKeys.QuietRa,
|
||||||
|
DnsmasqConfKeys.QuietTftp);
|
||||||
|
}
|
||||||
|
|
||||||
// do-0x20-encode / no-0x20-encode: tri-state dropdown (Default / Enabled / Disabled).
|
private void RegisterKeyOnlyOrValueDisplays()
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay));
|
{
|
||||||
|
RegisterSemanticSingleComponent(
|
||||||
|
EffectiveConfigFieldBuilder.SectionCache,
|
||||||
|
typeof(KeyOnlyOrValueDisplay),
|
||||||
|
DnsmasqConfKeys.UseStaleCache,
|
||||||
|
DnsmasqConfKeys.AddMac,
|
||||||
|
DnsmasqConfKeys.AddSubnet,
|
||||||
|
DnsmasqConfKeys.Umbrella);
|
||||||
|
RegisterSemanticSingleComponent(
|
||||||
|
EffectiveConfigFieldBuilder.SectionResolver,
|
||||||
|
typeof(KeyOnlyOrValueDisplay),
|
||||||
|
DnsmasqConfKeys.ConnmarkAllowlistEnable);
|
||||||
|
RegisterSemanticSingleComponent(
|
||||||
|
EffectiveConfigFieldBuilder.SectionDnssec,
|
||||||
|
typeof(KeyOnlyOrValueDisplay),
|
||||||
|
DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||||
|
RegisterComponents(
|
||||||
|
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||||
|
typeof(KeyOnlyOrValueDisplay),
|
||||||
|
DnsmasqConfKeys.DhcpGenerateNames,
|
||||||
|
DnsmasqConfKeys.DhcpBroadcast,
|
||||||
|
DnsmasqConfKeys.BootpDynamic);
|
||||||
|
}
|
||||||
|
|
||||||
// Key-only or key=value options: checkbox (On) + optional value input; with semantic validators.
|
private void RegisterPathValidatedSingles()
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache, typeof(KeyOnlyOrValueDisplay));
|
{
|
||||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache);
|
RegisterSemanticSingles(
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac, typeof(KeyOnlyOrValueDisplay));
|
EffectiveConfigFieldBuilder.SectionHosts,
|
||||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac);
|
DnsmasqConfKeys.Hostsdir);
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet, typeof(KeyOnlyOrValueDisplay));
|
RegisterSemanticSingles(
|
||||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet);
|
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, typeof(KeyOnlyOrValueDisplay));
|
DnsmasqConfKeys.DhcpLeasefile,
|
||||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella);
|
DnsmasqConfKeys.DhcpScript,
|
||||||
|
DnsmasqConfKeys.DhcpLuascript);
|
||||||
|
RegisterSemanticSingles(
|
||||||
|
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
||||||
|
DnsmasqConfKeys.TftpRoot);
|
||||||
|
RegisterSemanticSingles(
|
||||||
|
EffectiveConfigFieldBuilder.SectionDnssec,
|
||||||
|
DnsmasqConfKeys.DnssecTimestamp);
|
||||||
|
RegisterSemanticSingles(
|
||||||
|
EffectiveConfigFieldBuilder.SectionCache,
|
||||||
|
DnsmasqConfKeys.Dumpfile);
|
||||||
|
RegisterSemanticSingles(
|
||||||
|
EffectiveConfigFieldBuilder.SectionProcess,
|
||||||
|
DnsmasqConfKeys.PidFile);
|
||||||
|
}
|
||||||
|
|
||||||
// connmark-allowlist-enable, dnssec-check-unsigned: key-only or key=value with validation.
|
private void RegisterMultiValueDescriptors()
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable, typeof(KeyOnlyOrValueDisplay));
|
{
|
||||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable);
|
RegisterSemanticMultiDescriptor(
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, typeof(KeyOnlyOrValueDisplay));
|
|
||||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned);
|
|
||||||
|
|
||||||
// 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));
|
|
||||||
|
|
||||||
// local-service: dropdown (not set / net / host).
|
|
||||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay));
|
|
||||||
|
|
||||||
// Multi-value: server — descriptor only (behavior + validator); no custom display.
|
|
||||||
RegisterMultiDescriptor(
|
|
||||||
EffectiveConfigFieldBuilder.SectionResolver,
|
EffectiveConfigFieldBuilder.SectionResolver,
|
||||||
DnsmasqConfKeys.Server,
|
DnsmasqConfKeys.Server,
|
||||||
behavior: new ServerMultiBehavior(),
|
behavior: new DistinctMultiValueEditBehavior());
|
||||||
validator: new ServerMultiValidator());
|
RegisterSemanticMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.ListenAddress);
|
||||||
|
|
||||||
RegisterMultiDescriptor(
|
RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts);
|
||||||
|
RegisterSemanticMultis(
|
||||||
|
EffectiveConfigFieldBuilder.SectionResolver,
|
||||||
|
DnsmasqConfKeys.ResolvFile,
|
||||||
|
DnsmasqConfKeys.RevServer,
|
||||||
|
DnsmasqConfKeys.Address);
|
||||||
|
RegisterSemanticMultis(
|
||||||
|
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||||
|
DnsmasqConfKeys.DhcpHostsfile,
|
||||||
|
DnsmasqConfKeys.DhcpOptsfile,
|
||||||
|
DnsmasqConfKeys.DhcpHostsdir,
|
||||||
|
DnsmasqConfKeys.DhcpOptsdir,
|
||||||
|
DnsmasqConfKeys.Leasequery);
|
||||||
|
|
||||||
|
RegisterMultis(
|
||||||
|
EffectiveConfigFieldBuilder.SectionResolver,
|
||||||
|
DnsmasqConfKeys.Local,
|
||||||
|
DnsmasqConfKeys.RebindDomainOk,
|
||||||
|
DnsmasqConfKeys.BogusNxdomain,
|
||||||
|
DnsmasqConfKeys.IgnoreAddress,
|
||||||
|
DnsmasqConfKeys.Alias,
|
||||||
|
DnsmasqConfKeys.FilterRr,
|
||||||
|
DnsmasqConfKeys.Ipset,
|
||||||
|
DnsmasqConfKeys.Nftset,
|
||||||
|
DnsmasqConfKeys.ConnmarkAllowlist);
|
||||||
|
RegisterMultis(
|
||||||
|
EffectiveConfigFieldBuilder.SectionDnsRecords,
|
||||||
|
DnsmasqConfKeys.Domain,
|
||||||
|
DnsmasqConfKeys.Cname,
|
||||||
|
DnsmasqConfKeys.MxHost,
|
||||||
|
DnsmasqConfKeys.Srv,
|
||||||
|
DnsmasqConfKeys.PtrRecord,
|
||||||
|
DnsmasqConfKeys.TxtRecord,
|
||||||
|
DnsmasqConfKeys.NaptrRecord,
|
||||||
|
DnsmasqConfKeys.HostRecord,
|
||||||
|
DnsmasqConfKeys.DynamicHost,
|
||||||
|
DnsmasqConfKeys.InterfaceName,
|
||||||
|
DnsmasqConfKeys.CaaRecord,
|
||||||
|
DnsmasqConfKeys.DnsRr,
|
||||||
|
DnsmasqConfKeys.SynthDomain,
|
||||||
|
DnsmasqConfKeys.AuthZone,
|
||||||
|
DnsmasqConfKeys.AuthSoa,
|
||||||
|
DnsmasqConfKeys.AuthSecServers,
|
||||||
|
DnsmasqConfKeys.AuthPeer);
|
||||||
|
RegisterMultis(
|
||||||
|
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||||
|
DnsmasqConfKeys.DhcpRange,
|
||||||
|
DnsmasqConfKeys.DhcpHost,
|
||||||
|
DnsmasqConfKeys.DhcpOption,
|
||||||
|
DnsmasqConfKeys.DhcpOptionForce,
|
||||||
|
DnsmasqConfKeys.DhcpMatch,
|
||||||
|
DnsmasqConfKeys.DhcpMac,
|
||||||
|
DnsmasqConfKeys.DhcpNameMatch,
|
||||||
|
DnsmasqConfKeys.DhcpIgnoreNames,
|
||||||
|
DnsmasqConfKeys.DhcpRelay,
|
||||||
|
DnsmasqConfKeys.DhcpCircuitid,
|
||||||
|
DnsmasqConfKeys.DhcpRemoteid,
|
||||||
|
DnsmasqConfKeys.DhcpSubscrid,
|
||||||
|
DnsmasqConfKeys.DhcpProxy,
|
||||||
|
DnsmasqConfKeys.TagIf,
|
||||||
|
DnsmasqConfKeys.BridgeInterface,
|
||||||
|
DnsmasqConfKeys.SharedNetwork,
|
||||||
|
DnsmasqConfKeys.DhcpBoot,
|
||||||
|
DnsmasqConfKeys.DhcpIgnore,
|
||||||
|
DnsmasqConfKeys.DhcpVendorclass,
|
||||||
|
DnsmasqConfKeys.DhcpUserclass,
|
||||||
|
DnsmasqConfKeys.RaParam,
|
||||||
|
DnsmasqConfKeys.Slaac);
|
||||||
|
RegisterMultis(
|
||||||
|
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
||||||
|
DnsmasqConfKeys.PxeService,
|
||||||
|
DnsmasqConfKeys.DhcpOptionPxe);
|
||||||
|
RegisterMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor);
|
||||||
|
RegisterMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr);
|
||||||
|
RegisterMultis(
|
||||||
EffectiveConfigFieldBuilder.SectionProcess,
|
EffectiveConfigFieldBuilder.SectionProcess,
|
||||||
DnsmasqConfKeys.ListenAddress,
|
DnsmasqConfKeys.Interface,
|
||||||
validator: new ListenAddressMultiValidator());
|
DnsmasqConfKeys.ExceptInterface,
|
||||||
|
DnsmasqConfKeys.AuthServer,
|
||||||
|
DnsmasqConfKeys.NoDhcpInterface,
|
||||||
|
DnsmasqConfKeys.NoDhcpv4Interface,
|
||||||
|
DnsmasqConfKeys.NoDhcpv6Interface);
|
||||||
|
}
|
||||||
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts);
|
private void RegisterComponents(string sectionId, Type componentType, params string[] optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Local);
|
{
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RevServer);
|
foreach (var optionName in optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Address);
|
RegisterComponent(sectionId, optionName, componentType);
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ResolvFile);
|
}
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RebindDomainOk);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusNxdomain);
|
private void RegisterFlags(string sectionId, params string[] optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.IgnoreAddress);
|
{
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Alias);
|
foreach (var optionName in optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterRr);
|
RegisterFlag(sectionId, optionName);
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Ipset);
|
}
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Nftset);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlist);
|
private void RegisterSemanticSingles(string sectionId, params string[] optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Domain);
|
{
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Cname);
|
foreach (var optionName in optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.MxHost);
|
RegisterSemanticSingleValidator(sectionId, optionName);
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Srv);
|
}
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.PtrRecord);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.TxtRecord);
|
private void RegisterSemanticSingleComponent(string sectionId, Type componentType, params string[] optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.NaptrRecord);
|
{
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.HostRecord);
|
foreach (var optionName in optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DynamicHost);
|
{
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.InterfaceName);
|
RegisterComponent(sectionId, optionName, componentType);
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.CaaRecord);
|
RegisterSemanticSingleValidator(sectionId, optionName);
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DnsRr);
|
}
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.SynthDomain);
|
}
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthZone);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthSoa);
|
private void RegisterMultis(string sectionId, params string[] optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthSecServers);
|
{
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthPeer);
|
foreach (var optionName in optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRange);
|
RegisterMultiDescriptor(sectionId, optionName);
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHost);
|
}
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOption);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptionForce);
|
private void RegisterSemanticMultis(string sectionId, params string[] optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpMatch);
|
{
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpMac);
|
foreach (var optionName in optionNames)
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpNameMatch);
|
RegisterSemanticMultiDescriptor(sectionId, optionName);
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpIgnoreNames);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsfile);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsfile);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsdir);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsdir);
|
|
||||||
RegisterSemanticMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRelay);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpCircuitid);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRemoteid);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpSubscrid);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpProxy);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.TagIf);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.BridgeInterface);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.SharedNetwork);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpBoot);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpIgnore);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpVendorclass);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpUserclass);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.RaParam);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Slaac);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.PxeService);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.DhcpOptionPxe);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.Interface);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.ExceptInterface);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.AuthServer);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDhcpInterface);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDhcpv4Interface);
|
|
||||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDhcpv6Interface);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterMultiDescriptor(
|
private void RegisterMultiDescriptor(
|
||||||
@@ -275,16 +378,22 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
|
|
||||||
private void RegisterSemanticSingleValidator(string sectionId, string optionName)
|
private void RegisterSemanticSingleValidator(string sectionId, string optionName)
|
||||||
{
|
{
|
||||||
var validator = EffectiveConfigSpecialOptionSemantics.GetValidator(optionName);
|
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName);
|
||||||
if (validator is not null)
|
if (semantics is null)
|
||||||
RegisterValidatedSingle(sectionId, optionName, validator);
|
return;
|
||||||
|
|
||||||
|
RegisterValidatedSingle(
|
||||||
|
sectionId,
|
||||||
|
optionName,
|
||||||
|
value => _semanticValidator.ValidateSingle(optionName, value, semantics.Validation));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterSemanticMultiDescriptor(string sectionId, string optionName, IMultiValueEditBehavior? behavior = null)
|
private void RegisterSemanticMultiDescriptor(string sectionId, string optionName, IMultiValueEditBehavior? behavior = null)
|
||||||
{
|
{
|
||||||
var validator = EffectiveConfigSpecialOptionSemantics.GetMultiItemValidator(optionName) is { } validate
|
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName);
|
||||||
? new DelegateMultiValueOptionValidator(validate)
|
IMultiValueOptionValidator? validator = semantics is null
|
||||||
: null;
|
? null
|
||||||
|
: new DelegateMultiValueOptionValidator(v => _semanticValidator.ValidateMultiItem(optionName, v, semantics.Validation));
|
||||||
RegisterMultiDescriptor(sectionId, optionName, behavior, validator);
|
RegisterMultiDescriptor(sectionId, optionName, behavior, validator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||||
@@ -15,6 +16,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
private readonly IDnsmasqConfigService _configService;
|
private readonly IDnsmasqConfigService _configService;
|
||||||
private readonly IConfigSetCache _configSetCache;
|
private readonly IConfigSetCache _configSetCache;
|
||||||
private readonly IConfigValidationService _validationService;
|
private readonly IConfigValidationService _validationService;
|
||||||
|
private readonly IEffectiveConfigSemanticValidationService _semanticValidationService;
|
||||||
private readonly IReloadService _reloadService;
|
private readonly IReloadService _reloadService;
|
||||||
private readonly IDnsmasqVersionService _versionService;
|
private readonly IDnsmasqVersionService _versionService;
|
||||||
private readonly ILogger<EffectiveConfigSaveService> _logger;
|
private readonly ILogger<EffectiveConfigSaveService> _logger;
|
||||||
@@ -24,6 +26,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
IDnsmasqConfigService configService,
|
IDnsmasqConfigService configService,
|
||||||
IConfigSetCache configSetCache,
|
IConfigSetCache configSetCache,
|
||||||
IConfigValidationService validationService,
|
IConfigValidationService validationService,
|
||||||
|
IEffectiveConfigSemanticValidationService semanticValidationService,
|
||||||
IReloadService reloadService,
|
IReloadService reloadService,
|
||||||
IDnsmasqVersionService versionService,
|
IDnsmasqVersionService versionService,
|
||||||
ILogger<EffectiveConfigSaveService> logger)
|
ILogger<EffectiveConfigSaveService> logger)
|
||||||
@@ -32,6 +35,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
_configService = configService;
|
_configService = configService;
|
||||||
_configSetCache = configSetCache;
|
_configSetCache = configSetCache;
|
||||||
_validationService = validationService;
|
_validationService = validationService;
|
||||||
|
_semanticValidationService = semanticValidationService;
|
||||||
_reloadService = reloadService;
|
_reloadService = reloadService;
|
||||||
_versionService = versionService;
|
_versionService = versionService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -127,6 +131,36 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
|||||||
UserMessage: userMessage);
|
UserMessage: userMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var semanticIssues = _semanticValidationService.Validate(changes);
|
||||||
|
if (semanticIssues.Any(i => i.Severity == FieldIssueSeverity.Error))
|
||||||
|
{
|
||||||
|
var errorIssues = semanticIssues.Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
|
||||||
|
var messages = errorIssues.Select(i => i.Message).Distinct().Take(5).ToList();
|
||||||
|
var userMessage = messages.Count == 1
|
||||||
|
? messages[0]
|
||||||
|
: "Some values are invalid. Fix validation errors before saving. " + string.Join("; ", messages);
|
||||||
|
var failedFields = errorIssues.Select(i => i.FieldKey).Distinct().ToList();
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Save blocked: semantic validation failed for {ErrorCount} error(s) on field(s) {FailedFields}. First message: {FirstMessage}",
|
||||||
|
errorIssues.Count,
|
||||||
|
failedFields,
|
||||||
|
messages.FirstOrDefault());
|
||||||
|
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.SemanticValidationFailed,
|
||||||
|
UserMessage: userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
CreateBackupIfSourceExists(managedPath, backupPath);
|
CreateBackupIfSourceExists(managedPath, backupPath);
|
||||||
|
|||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-save semantic validation: runs option semantics validation on pending changes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EffectiveConfigSemanticValidationService : IEffectiveConfigSemanticValidationService
|
||||||
|
{
|
||||||
|
private readonly IOptionSemanticValidator _validator;
|
||||||
|
|
||||||
|
public EffectiveConfigSemanticValidationService(IOptionSemanticValidator validator)
|
||||||
|
{
|
||||||
|
_validator = validator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<FieldIssue> Validate(IReadOnlyList<PendingEffectiveConfigChange> changes)
|
||||||
|
{
|
||||||
|
var issues = new List<FieldIssue>();
|
||||||
|
foreach (var change in changes)
|
||||||
|
{
|
||||||
|
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(change.OptionName);
|
||||||
|
if (semantics is null) continue;
|
||||||
|
|
||||||
|
var fieldKey = $"{change.SectionId}:{change.OptionName}";
|
||||||
|
var severity = semantics.Validation.Severity;
|
||||||
|
|
||||||
|
if (change.NewValue is IReadOnlyList<string> list)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < list.Count; i++)
|
||||||
|
{
|
||||||
|
var err = _validator.ValidateMultiItem(change.OptionName, list[i], semantics.Validation);
|
||||||
|
if (err is not null)
|
||||||
|
issues.Add(new FieldIssue(fieldKey, err, severity, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var err = _validator.ValidateSingle(change.OptionName, change.NewValue, semantics.Validation);
|
||||||
|
if (err is not null)
|
||||||
|
issues.Add(new FieldIssue(fieldKey, err, severity, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates pending effective-config changes against option semantics before write.
|
||||||
|
/// Returns field-level issues (errors block save; warnings can be confirmed).
|
||||||
|
/// Registered as singleton via assembly scanning (<see cref="IApplicationSingleton"/>).
|
||||||
|
/// </summary>
|
||||||
|
public interface IEffectiveConfigSemanticValidationService : IApplicationSingleton
|
||||||
|
{
|
||||||
|
/// <summary>Validates each change using option semantics. Returns all issues (errors and warnings).</summary>
|
||||||
|
IReadOnlyList<FieldIssue> Validate(IReadOnlyList<PendingEffectiveConfigChange> changes);
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specialized semantic behavior for <c>address</c> values.
|
||||||
|
/// Validates the <c>/domain[/domain...]/ip</c> structure and accepts empty or <c>#</c> address forms.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class AddressSemanticHandler : IOptionSemanticHandler
|
||||||
|
{
|
||||||
|
[GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex DomainPattern();
|
||||||
|
|
||||||
|
public bool CanHandle(string optionName) =>
|
||||||
|
optionName == DnsmasqConfKeys.Address;
|
||||||
|
|
||||||
|
public string? ValidateSingle(object? value) => null;
|
||||||
|
|
||||||
|
public string? ValidateMultiItem(string? value)
|
||||||
|
{
|
||||||
|
var s = value?.Trim() ?? "";
|
||||||
|
if (s.Length == 0)
|
||||||
|
return "Value cannot be empty.";
|
||||||
|
|
||||||
|
if (!s.StartsWith("/", StringComparison.Ordinal))
|
||||||
|
return "Address must start with '/'.";
|
||||||
|
|
||||||
|
var parts = s.Split('/');
|
||||||
|
if (parts.Length < 3)
|
||||||
|
return "Address must use /domain[/domain...]/ip syntax.";
|
||||||
|
|
||||||
|
var domainParts = parts.Skip(1).Take(parts.Length - 2).ToArray();
|
||||||
|
if (domainParts.Length == 0 || domainParts.Any(string.IsNullOrWhiteSpace))
|
||||||
|
return "Address must include at least one domain pattern.";
|
||||||
|
|
||||||
|
foreach (var domain in domainParts)
|
||||||
|
{
|
||||||
|
if (!IsValidDomainPattern(domain))
|
||||||
|
return $"Invalid domain pattern '{domain}'.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var addressPart = parts[^1].Trim();
|
||||||
|
if (addressPart.Length == 0 || addressPart == "#")
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return IPAddress.TryParse(addressPart, out _)
|
||||||
|
? null
|
||||||
|
: "Address target must be empty, '#', or a valid IP address.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidDomainPattern(string domain)
|
||||||
|
{
|
||||||
|
if (domain == "#")
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var normalized = domain;
|
||||||
|
if (normalized.StartsWith('*'))
|
||||||
|
normalized = normalized[1..];
|
||||||
|
if (normalized.StartsWith('.'))
|
||||||
|
normalized = normalized[1..];
|
||||||
|
|
||||||
|
return normalized.Length > 0 &&
|
||||||
|
normalized.Length <= 253 &&
|
||||||
|
DomainPattern().IsMatch(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -1,11 +1,10 @@
|
|||||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default behavior for multi-value list editor: generic placeholder, allow duplicates, trim normalization.
|
/// Default behavior for multi-value list editor: allow duplicates and trim normalization.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DefaultMultiValueEditBehavior : IMultiValueEditBehavior
|
public sealed class DefaultMultiValueEditBehavior : IMultiValueEditBehavior
|
||||||
{
|
{
|
||||||
public string Placeholder => "Value";
|
|
||||||
public bool AllowDuplicates => true;
|
public bool AllowDuplicates => true;
|
||||||
public string Normalize(string input) => (input ?? "").Trim();
|
public string Normalize(string input) => (input ?? "").Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -1,11 +1,9 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adapts a semantics multi-item validator delegate to <see cref="IMultiValueOptionValidator"/>.
|
/// Adapts a simple per-item validation delegate to <see cref="IMultiValueOptionValidator"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DelegateMultiValueOptionValidator(EffectiveConfigMultiItemValidator validate)
|
public sealed class DelegateMultiValueOptionValidator(Func<string?, string?> validate)
|
||||||
: IMultiValueOptionValidator
|
: IMultiValueOptionValidator
|
||||||
{
|
{
|
||||||
public string? ValidateItem(string normalized, IReadOnlyList<string> current, int? editIndex = null) =>
|
public string? ValidateItem(string normalized, IReadOnlyList<string> current, int? editIndex = null) =>
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic edit behavior for multi-value options that should reject duplicate items
|
||||||
|
/// while using the default trim-based normalization.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DistinctMultiValueEditBehavior : IMultiValueEditBehavior
|
||||||
|
{
|
||||||
|
public bool AllowDuplicates => false;
|
||||||
|
|
||||||
|
public string Normalize(string input) => (input ?? "").Trim();
|
||||||
|
}
|
||||||
+1
-4
@@ -1,13 +1,10 @@
|
|||||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Option-specific behavior for the multi-value list editor: placeholder, duplicate policy, and normalization.
|
/// Option-specific behavior for the multi-value list editor: duplicate policy and normalization.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMultiValueEditBehavior
|
public interface IMultiValueEditBehavior
|
||||||
{
|
{
|
||||||
/// <summary>Placeholder text for the add/edit input.</summary>
|
|
||||||
string Placeholder { get; }
|
|
||||||
|
|
||||||
/// <summary>Whether duplicate values are allowed in the list.</summary>
|
/// <summary>Whether duplicate values are allowed in the list.</summary>
|
||||||
bool AllowDuplicates { get; }
|
bool AllowDuplicates { get; }
|
||||||
|
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Option-specific semantic handler for cases that generic validation kinds cannot express clearly.
|
||||||
|
/// Handlers centralize specialized validation by option name.
|
||||||
|
/// Registered as singleton collection via assembly scanning (<see cref="IApplicationMultiSingleton"/>).
|
||||||
|
/// </summary>
|
||||||
|
public interface IOptionSemanticHandler : IApplicationMultiSingleton
|
||||||
|
{
|
||||||
|
/// <summary>True when this handler owns semantic behavior for the given option.</summary>
|
||||||
|
bool CanHandle(string optionName);
|
||||||
|
|
||||||
|
/// <summary>Validates a single-value option. Returns an error message or null if valid.</summary>
|
||||||
|
string? ValidateSingle(object? value);
|
||||||
|
|
||||||
|
/// <summary>Validates one item in a multi-value option. Returns an error message or null if valid.</summary>
|
||||||
|
string? ValidateMultiItem(string? value);
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central validator for effective-config option values driven by <see cref="OptionValidationSemantics"/>.
|
||||||
|
/// Used by the registry for field validation and by the pre-save semantic validation pass.
|
||||||
|
/// Registered as singleton via assembly scanning (<see cref="IApplicationSingleton"/>).
|
||||||
|
/// </summary>
|
||||||
|
public interface IOptionSemanticValidator : IApplicationSingleton
|
||||||
|
{
|
||||||
|
/// <summary>Validates a single-value field. Returns error message or null if valid.</summary>
|
||||||
|
string? ValidateSingle(string optionName, object? value, OptionValidationSemantics semantics);
|
||||||
|
|
||||||
|
/// <summary>Validates one item in a multi-value list. Returns error message or null if valid.</summary>
|
||||||
|
string? ValidateMultiItem(string optionName, string? value, OptionValidationSemantics semantics);
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Net;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specialized semantic behavior for <c>leasequery</c> values.
|
||||||
|
/// Each item is either key-only or an IP address with an optional numeric prefix.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LeasequerySemanticHandler : IOptionSemanticHandler
|
||||||
|
{
|
||||||
|
public bool CanHandle(string optionName) =>
|
||||||
|
optionName == DnsmasqConfKeys.Leasequery;
|
||||||
|
|
||||||
|
public string? ValidateSingle(object? value) => null;
|
||||||
|
|
||||||
|
public string? ValidateMultiItem(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var parts = value.Trim().Split('/', 2);
|
||||||
|
if (!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;
|
||||||
|
}
|
||||||
|
}
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates listen-address option values: IPv4 or IPv6 address only.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ListenAddressMultiValidator : IMultiValueOptionValidator
|
|
||||||
{
|
|
||||||
public string? ValidateItem(string normalized, IReadOnlyList<string> current, int? editIndex = null)
|
|
||||||
{
|
|
||||||
var v = (normalized ?? "").Trim();
|
|
||||||
if (string.IsNullOrEmpty(v))
|
|
||||||
return null;
|
|
||||||
if (!IPAddress.TryParse(v, out var ip))
|
|
||||||
return $"Invalid value for listen-address: '{normalized}'.";
|
|
||||||
if (ip.AddressFamily != AddressFamily.InterNetwork &&
|
|
||||||
ip.AddressFamily != AddressFamily.InterNetworkV6)
|
|
||||||
return $"Invalid value for listen-address: '{normalized}'.";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central validator for effective-config values. Specialized handlers are consulted first;
|
||||||
|
/// otherwise validation falls back to generic <see cref="OptionValidationKind"/>-based rules.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OptionSemanticValidator : IOptionSemanticValidator
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<IOptionSemanticHandler> _handlers;
|
||||||
|
|
||||||
|
public OptionSemanticValidator(IEnumerable<IOptionSemanticHandler>? handlers = null)
|
||||||
|
{
|
||||||
|
_handlers = (handlers ?? Array.Empty<IOptionSemanticHandler>()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? ValidateSingle(string optionName, object? value, OptionValidationSemantics semantics)
|
||||||
|
{
|
||||||
|
var handler = _handlers.FirstOrDefault(h => h.CanHandle(optionName));
|
||||||
|
if (handler is not null)
|
||||||
|
return handler.ValidateSingle(value);
|
||||||
|
|
||||||
|
return ValidateSingleByKind(optionName, value, semantics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? ValidateMultiItem(string optionName, string? value, OptionValidationSemantics semantics)
|
||||||
|
{
|
||||||
|
var handler = _handlers.FirstOrDefault(h => h.CanHandle(optionName));
|
||||||
|
if (handler is not null)
|
||||||
|
return handler.ValidateMultiItem(value);
|
||||||
|
|
||||||
|
return ValidateMultiByKind(optionName, value, semantics);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateSingleByKind(string optionName, object? value, OptionValidationSemantics semantics)
|
||||||
|
{
|
||||||
|
return semantics.Kind switch
|
||||||
|
{
|
||||||
|
OptionValidationKind.Flag or OptionValidationKind.InversePair => null,
|
||||||
|
OptionValidationKind.Int => ValidateInt(value),
|
||||||
|
OptionValidationKind.String => ValidateString(value?.ToString(), semantics),
|
||||||
|
OptionValidationKind.PathFile or OptionValidationKind.PathDirectory or OptionValidationKind.PathFileOrDirectory => ValidatePath(value?.ToString(), semantics),
|
||||||
|
OptionValidationKind.IpAddress => ValidateIpAddress(value?.ToString(), semantics),
|
||||||
|
OptionValidationKind.HostOrIp => ValidateHostOrIp(value?.ToString(), semantics),
|
||||||
|
OptionValidationKind.KeyOnlyOrValue => ValidateKeyOnlyOrValue(optionName, value?.ToString()),
|
||||||
|
OptionValidationKind.Complex => null,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateMultiByKind(string optionName, string? value, OptionValidationSemantics semantics) =>
|
||||||
|
semantics.Kind switch
|
||||||
|
{
|
||||||
|
OptionValidationKind.String => ValidateString(value, semantics),
|
||||||
|
OptionValidationKind.PathFile or OptionValidationKind.PathDirectory or OptionValidationKind.PathFileOrDirectory => ValidatePath(value, semantics),
|
||||||
|
OptionValidationKind.IpAddress => ValidateIpAddress(value, semantics),
|
||||||
|
OptionValidationKind.HostOrIp => ValidateHostOrIp(value, semantics),
|
||||||
|
OptionValidationKind.KeyOnlyOrValue => ValidateKeyOnlyOrValue(optionName, value),
|
||||||
|
OptionValidationKind.Complex => null,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? ValidateInt(object? value)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
if (value is int) return null;
|
||||||
|
var s = value.ToString()?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(s)) return null;
|
||||||
|
return int.TryParse(s, out _) ? null : "Must be a valid integer.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateString(string? value, OptionValidationSemantics semantics)
|
||||||
|
{
|
||||||
|
if (!semantics.AllowEmpty && string.IsNullOrWhiteSpace(value))
|
||||||
|
return "Value cannot be empty.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidatePath(string? value, OptionValidationSemantics semantics)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return semantics.AllowEmpty ? null : "Path cannot be empty.";
|
||||||
|
|
||||||
|
var path = value.Trim();
|
||||||
|
return semantics.PathPolicy switch
|
||||||
|
{
|
||||||
|
PathExistencePolicy.MustExist when !PathExistsForKind(path, semantics.Kind)
|
||||||
|
=> GetMissingPathMessage(semantics.Kind),
|
||||||
|
PathExistencePolicy.ParentMustExist when !Directory.Exists(Path.GetDirectoryName(path) ?? "")
|
||||||
|
=> "Parent directory does not exist.",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PathExistsForKind(string path, OptionValidationKind kind) =>
|
||||||
|
kind switch
|
||||||
|
{
|
||||||
|
OptionValidationKind.PathFile => File.Exists(path),
|
||||||
|
OptionValidationKind.PathDirectory => Directory.Exists(path),
|
||||||
|
OptionValidationKind.PathFileOrDirectory => File.Exists(path) || Directory.Exists(path),
|
||||||
|
_ => File.Exists(path) || Directory.Exists(path),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GetMissingPathMessage(OptionValidationKind kind) =>
|
||||||
|
kind switch
|
||||||
|
{
|
||||||
|
OptionValidationKind.PathFile => "File does not exist.",
|
||||||
|
OptionValidationKind.PathDirectory => "Directory does not exist.",
|
||||||
|
_ => "Path does not exist.",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? ValidateIpAddress(string? value, OptionValidationSemantics semantics)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return semantics.AllowEmpty ? null : "Value cannot be empty.";
|
||||||
|
if (!IPAddress.TryParse(value, out var ip)) return "Enter a valid IP address.";
|
||||||
|
if (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6)
|
||||||
|
return "Invalid IP address.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateHostOrIp(string? value, OptionValidationSemantics semantics)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return semantics.AllowEmpty ? null : "Value cannot be empty.";
|
||||||
|
if (IPAddress.TryParse(value, out var ip))
|
||||||
|
{
|
||||||
|
if (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6)
|
||||||
|
return "Invalid IP address.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Simple hostname check
|
||||||
|
if (value.Length <= 253 && !value.Contains("://", StringComparison.Ordinal))
|
||||||
|
return null;
|
||||||
|
return "Enter a valid IP address or hostname.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateKeyOnlyOrValue(string optionName, string? value)
|
||||||
|
{
|
||||||
|
var s = value?.Trim() ?? "";
|
||||||
|
if (s.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return optionName switch
|
||||||
|
{
|
||||||
|
DnsmasqConfKeys.UseStaleCache => int.TryParse(s, out var n) && n >= 0
|
||||||
|
? null
|
||||||
|
: "use-stale-cache must be empty or a non-negative integer.",
|
||||||
|
DnsmasqConfKeys.AddMac => s is "base64" or "text"
|
||||||
|
? null
|
||||||
|
: "add-mac must be empty, 'base64', or 'text'.",
|
||||||
|
// Intentionally permissive for now: dnsmasq accepts richer option-specific syntax here,
|
||||||
|
// and we do not yet model that syntax centrally.
|
||||||
|
DnsmasqConfKeys.AddSubnet => null,
|
||||||
|
DnsmasqConfKeys.Umbrella => null,
|
||||||
|
DnsmasqConfKeys.ConnmarkAllowlistEnable => ValidateConnmarkAllowlistEnableMask(s),
|
||||||
|
DnsmasqConfKeys.DnssecCheckUnsigned => s is "no"
|
||||||
|
? null
|
||||||
|
: "Allowed values: empty (enable check) or 'no'.",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateConnmarkAllowlistEnableMask(string value)
|
||||||
|
{
|
||||||
|
if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return uint.TryParse(value[2..], System.Globalization.NumberStyles.HexNumber, null, out _)
|
||||||
|
? null
|
||||||
|
: "Mask must be valid hex after 0x.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint.TryParse(value, out _)
|
||||||
|
? null
|
||||||
|
: "Mask must be empty, decimal uint, or hex (0x...).";
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Net;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specialized semantic behavior for <c>rev-server</c> values.
|
||||||
|
/// Validates the leading reverse network target and basic presence of an upstream server segment when provided.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RevServerSemanticHandler : IOptionSemanticHandler
|
||||||
|
{
|
||||||
|
public bool CanHandle(string optionName) =>
|
||||||
|
optionName == DnsmasqConfKeys.RevServer;
|
||||||
|
|
||||||
|
public string? ValidateSingle(object? value) => null;
|
||||||
|
|
||||||
|
public string? ValidateMultiItem(string? value)
|
||||||
|
{
|
||||||
|
var s = value?.Trim() ?? "";
|
||||||
|
if (s.Length == 0)
|
||||||
|
return "Value cannot be empty.";
|
||||||
|
|
||||||
|
var parts = s.Split(',', 2);
|
||||||
|
var target = parts[0].Trim();
|
||||||
|
var err = ValidateTarget(target);
|
||||||
|
if (err is not null)
|
||||||
|
return err;
|
||||||
|
|
||||||
|
if (parts.Length == 2 && string.IsNullOrWhiteSpace(parts[1]))
|
||||||
|
return "Upstream server cannot be empty when a comma is present.";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateTarget(string target)
|
||||||
|
{
|
||||||
|
var slash = target.IndexOf('/');
|
||||||
|
var ipText = slash >= 0 ? target[..slash] : target;
|
||||||
|
if (!IPAddress.TryParse(ipText, out var ip))
|
||||||
|
return "Reverse server prefix must start with a valid IP address.";
|
||||||
|
|
||||||
|
if (slash < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var prefixText = target[(slash + 1)..];
|
||||||
|
if (!int.TryParse(prefixText, out var prefix))
|
||||||
|
return "Reverse server prefix length must be numeric.";
|
||||||
|
|
||||||
|
var maxPrefix = ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128;
|
||||||
|
return prefix >= 1 && prefix <= maxPrefix
|
||||||
|
? null
|
||||||
|
: $"Reverse server prefix length must be between 1 and {maxPrefix}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Edit behavior for the server multi-value option: placeholder, no duplicates, normalize (trim).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ServerMultiBehavior : IMultiValueEditBehavior
|
|
||||||
{
|
|
||||||
public string Placeholder => "IP or hostname (e.g. 8.8.8.8 or dns.example.com)";
|
|
||||||
public bool AllowDuplicates => false;
|
|
||||||
|
|
||||||
public string Normalize(string input) => (input ?? "").Trim();
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates server option values: IP address or hostname. Duplicate check is done by the editor when AllowDuplicates is false.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ServerMultiValidator : IMultiValueOptionValidator
|
|
||||||
{
|
|
||||||
private static readonly Regex HostnameRegex = new(
|
|
||||||
@"^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$",
|
|
||||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
||||||
|
|
||||||
public string? ValidateItem(string normalized, IReadOnlyList<string> current, int? editIndex = null)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(normalized))
|
|
||||||
return "Server value cannot be empty.";
|
|
||||||
|
|
||||||
if (IPAddress.TryParse(normalized, out var ip))
|
|
||||||
{
|
|
||||||
if (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6)
|
|
||||||
return "Invalid IP address.";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (HostnameRegex.IsMatch(normalized) && normalized.Length <= 253)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return "Enter a valid IP address or hostname.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specialized semantic behavior for <c>server</c> values.
|
||||||
|
/// Supports plain upstream server values and domain-scoped <c>/domain/.../server</c> forms.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ServerSemanticHandler : IOptionSemanticHandler
|
||||||
|
{
|
||||||
|
[GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex HostnamePattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex DomainPattern();
|
||||||
|
|
||||||
|
public bool CanHandle(string optionName) =>
|
||||||
|
optionName == DnsmasqConfKeys.Server;
|
||||||
|
|
||||||
|
public string? ValidateSingle(object? value) => null;
|
||||||
|
|
||||||
|
public string? ValidateMultiItem(string? value)
|
||||||
|
{
|
||||||
|
var s = value?.Trim() ?? "";
|
||||||
|
if (s.Length == 0)
|
||||||
|
return "Value cannot be empty.";
|
||||||
|
|
||||||
|
return s.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
? ValidateScopedServer(s)
|
||||||
|
: ValidateServerTarget(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateScopedServer(string value)
|
||||||
|
{
|
||||||
|
var lastSlash = value.LastIndexOf('/');
|
||||||
|
if (lastSlash <= 0)
|
||||||
|
return "Scoped server must use /domain/.../server syntax, for example /example.local/192.168.1.1.";
|
||||||
|
|
||||||
|
var domainPart = value[1..lastSlash];
|
||||||
|
var targetPart = value[(lastSlash + 1)..];
|
||||||
|
|
||||||
|
var domains = domainPart.Split('/');
|
||||||
|
if (domains.Length == 0)
|
||||||
|
return "Scoped server must include at least one domain pattern.";
|
||||||
|
|
||||||
|
if (!(domains.Length == 1 && domains[0].Length == 0))
|
||||||
|
{
|
||||||
|
foreach (var domain in domains)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(domain))
|
||||||
|
return "Scoped server contains an empty domain pattern.";
|
||||||
|
if (!IsValidDomainPattern(domain))
|
||||||
|
return $"Invalid domain pattern '{domain}'. Use server=/domain/server and keep domain labels to letters, digits, '-', '.', or a leading '*'.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetPart.Length == 0)
|
||||||
|
return null; // local-only form
|
||||||
|
|
||||||
|
return ValidateServerTarget(targetPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateServerTarget(string value)
|
||||||
|
{
|
||||||
|
var parts = value.Split('@');
|
||||||
|
if (parts.Any(string.IsNullOrWhiteSpace))
|
||||||
|
return "Server target contains an empty '@' segment.";
|
||||||
|
|
||||||
|
var upstream = parts[0];
|
||||||
|
if (upstream != "#" && !IsValidServerHostPort(upstream))
|
||||||
|
return "Enter a valid upstream server: IP, hostname, '#', or host#port.";
|
||||||
|
|
||||||
|
for (var i = 1; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
if (!IsValidSourceOrInterface(parts[i]))
|
||||||
|
return $"Invalid source/interface segment '{parts[i]}'. Use an interface name, source IP, or source IP#port.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidServerHostPort(string value)
|
||||||
|
{
|
||||||
|
var hashIndex = value.LastIndexOf('#');
|
||||||
|
var hostPart = hashIndex >= 0 ? value[..hashIndex] : value;
|
||||||
|
if (!IsValidHostOrIp(hostPart))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (hashIndex < 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var portPart = value[(hashIndex + 1)..];
|
||||||
|
return int.TryParse(portPart, out var port) && port is >= 1 and <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidSourceOrInterface(string value)
|
||||||
|
{
|
||||||
|
var hashIndex = value.LastIndexOf('#');
|
||||||
|
var targetPart = hashIndex >= 0 ? value[..hashIndex] : value;
|
||||||
|
var portPart = hashIndex >= 0 ? value[(hashIndex + 1)..] : null;
|
||||||
|
|
||||||
|
var ok = IsValidHostOrIp(targetPart) || IsValidInterfaceName(targetPart);
|
||||||
|
if (!ok)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return portPart is null || (int.TryParse(portPart, out var port) && port is >= 1 and <= 65535);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidHostOrIp(string value)
|
||||||
|
{
|
||||||
|
if (IPAddress.TryParse(value, out var ip))
|
||||||
|
return ip.AddressFamily is AddressFamily.InterNetwork or AddressFamily.InterNetworkV6;
|
||||||
|
|
||||||
|
return value.Length <= 253 && HostnamePattern().IsMatch(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidDomainPattern(string value)
|
||||||
|
{
|
||||||
|
var normalized = value;
|
||||||
|
if (normalized.StartsWith("*", StringComparison.Ordinal))
|
||||||
|
normalized = normalized[1..];
|
||||||
|
if (normalized.StartsWith(".", StringComparison.Ordinal))
|
||||||
|
normalized = normalized[1..];
|
||||||
|
|
||||||
|
return normalized.Length > 0 &&
|
||||||
|
normalized.Length <= 253 &&
|
||||||
|
DomainPattern().IsMatch(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidInterfaceName(string value) =>
|
||||||
|
value.Length > 0 &&
|
||||||
|
value.Length <= 64 &&
|
||||||
|
value.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.');
|
||||||
|
}
|
||||||
-79
@@ -1,79 +0,0 @@
|
|||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Single-value validators for effective-config options with special semantics (key-only or key=value, allowed values).
|
|
||||||
/// Used by the descriptor factory so field-level validation shows friendly errors in the edit flow.
|
|
||||||
/// </summary>
|
|
||||||
public static class SpecialOptionValidators
|
|
||||||
{
|
|
||||||
/// <summary>use-stale-cache: unset, key-only, or non-negative integer.</summary>
|
|
||||||
public static string? ValidateUseStaleCache(object? value)
|
|
||||||
{
|
|
||||||
if (value is null) return null;
|
|
||||||
var s = value.ToString()?.Trim() ?? "";
|
|
||||||
if (s.Length == 0) return null; // key-only
|
|
||||||
return int.TryParse(s, out var n) && n >= 0
|
|
||||||
? null
|
|
||||||
: "use-stale-cache must be empty or a non-negative integer.";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>add-mac: unset, key-only, or 'base64' or 'text'.</summary>
|
|
||||||
public static string? ValidateAddMac(object? value)
|
|
||||||
{
|
|
||||||
if (value is null) return null;
|
|
||||||
var s = value.ToString()?.Trim() ?? "";
|
|
||||||
if (s.Length == 0) return null; // key-only
|
|
||||||
return s is "base64" or "text"
|
|
||||||
? null
|
|
||||||
: "add-mac must be empty, 'base64', or 'text'.";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>add-subnet: unset, key-only, or value (permissive; format not strictly validated).</summary>
|
|
||||||
public static string? ValidateAddSubnet(object? value)
|
|
||||||
{
|
|
||||||
if (value is null) return null;
|
|
||||||
return null; // accept any string for now
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>umbrella: unset, key-only, or token list (permissive).</summary>
|
|
||||||
public static string? ValidateUmbrella(object? value)
|
|
||||||
{
|
|
||||||
if (value is null) return null;
|
|
||||||
var s = value.ToString()?.Trim() ?? "";
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
namespace DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marker interface for application services that are registered as singleton collections.
|
||||||
|
/// Used by assembly scanning for interfaces that intentionally have multiple implementations.
|
||||||
|
/// </summary>
|
||||||
|
public interface IApplicationMultiSingleton
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ public record EffectiveConfigSaveResult(
|
|||||||
public const string WriteFailed = "write_failed";
|
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>
|
/// <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 UnsupportedCapabilities = "unsupported_capabilities";
|
||||||
|
/// <summary>Save blocked before write because semantic validation failed (invalid values).</summary>
|
||||||
|
public const string SemanticValidationFailed = "semantic_validation_failed";
|
||||||
public const string ValidateFailed = "validate_failed";
|
public const string ValidateFailed = "validate_failed";
|
||||||
public const string RestartFailed = "restart_failed";
|
public const string RestartFailed = "restart_failed";
|
||||||
public const string UnsupportedVersion = "unsupported_version";
|
public const string UnsupportedVersion = "unsupported_version";
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using DnsmasqWebUI.Components;
|
using DnsmasqWebUI.Components;
|
||||||
using DnsmasqWebUI.Infrastructure.Realtime.Hubs;
|
using DnsmasqWebUI.Infrastructure.Realtime.Hubs;
|
||||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||||
using DnsmasqWebUI.Models.Config;
|
using DnsmasqWebUI.Models.Config;
|
||||||
using DnsmasqWebUI.Extensions.DependencyInjection;
|
using DnsmasqWebUI.Extensions.DependencyInjection;
|
||||||
using DnsmasqWebUI.Extensions.Hosting;
|
using DnsmasqWebUI.Extensions.Hosting;
|
||||||
|
|||||||
Reference in New Issue
Block a user