mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +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.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
@@ -9,35 +11,19 @@ namespace DnsmasqWebUI.Tests;
|
||||
public class EffectiveConfigSemanticsWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParserBehaviorMap_UsesSpecialSemantics_ForSpecialOptions()
|
||||
public void ParserBehaviorMap_UsesSpecialSemantics_ForAllSpecialOptions()
|
||||
{
|
||||
var specialOptions = new[]
|
||||
foreach (var option in EffectiveConfigSpecialOptionSemantics.GetAllOptionNames())
|
||||
{
|
||||
DnsmasqConfKeys.UseStaleCache,
|
||||
DnsmasqConfKeys.AddMac,
|
||||
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));
|
||||
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(option)!;
|
||||
Assert.Equal(semantics.ParserBehavior, EffectiveConfigParserBehaviorMap.GetBehavior(option));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
Assert.NotNull(factory);
|
||||
|
||||
@@ -51,4 +37,20 @@ public class EffectiveConfigSemanticsWiringTests
|
||||
Assert.NotNull(descriptor.Validator!.ValidateItem("not-an-ip", 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 showToggle = hasMoreThanOne || (!hasList && Status.ManagedFilePath != null);
|
||||
}
|
||||
<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-list">
|
||||
<ul class="list-unstyled config-file-list-main">
|
||||
@@ -45,17 +58,6 @@
|
||||
</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>
|
||||
|
||||
|
||||
@@ -15,11 +15,19 @@
|
||||
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 {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-file-list ul {
|
||||
@@ -34,10 +42,10 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@@ -20,7 +21,7 @@
|
||||
{
|
||||
<input type="text"
|
||||
class="form-control form-control-sm ec-key-only-or-value-input"
|
||||
placeholder="Optional value"
|
||||
placeholder="@(DnsmasqOptionPlaceholders.Get(Descriptor.OptionName) ?? "Optional value")"
|
||||
value="@valuePart"
|
||||
@oninput="OnValueInput" />
|
||||
}
|
||||
|
||||
+15
-1
@@ -77,6 +77,7 @@ else
|
||||
{
|
||||
var value = _effectiveValue;
|
||||
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 showReadonlyHint = IsEditMode && isReadonly;
|
||||
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
|
||||
@@ -92,6 +93,7 @@ else
|
||||
{
|
||||
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
||||
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
EditValue="@editValue"
|
||||
ShowEditableBadge="@showEditableForDefault" FieldKey="@FieldKey" IsActive="@IsActive"
|
||||
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange"
|
||||
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
||||
@@ -214,6 +216,8 @@ else
|
||||
private async Task<bool> CommitIfChangedAsync()
|
||||
{
|
||||
var newValue = _hasDraftValue ? _draftValue : _effectiveValue;
|
||||
if (IsDefaultSingleValueField)
|
||||
newValue = NormalizeDefaultSingleCommitValue(newValue);
|
||||
if (ValuesEqual(_valueAtActivation, newValue))
|
||||
{
|
||||
_draftValue = null;
|
||||
@@ -244,6 +248,9 @@ else
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsDefaultSingleValueField =>
|
||||
!Descriptor.IsMultiValue && _customFragment is null;
|
||||
|
||||
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
|
||||
|
||||
private void HandleDefaultValueChanged(string value)
|
||||
@@ -255,12 +262,19 @@ else
|
||||
private async Task HandleDefaultBlur(string? value)
|
||||
{
|
||||
if (!IsActive) return;
|
||||
_draftValue = value;
|
||||
_draftValue = NormalizeDefaultSingleCommitValue(value);
|
||||
_hasDraftValue = true;
|
||||
if (!await CommitIfChangedAsync() && Ui != null)
|
||||
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)
|
||||
{
|
||||
_draftValue = values.ToList();
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@@ -25,7 +26,7 @@
|
||||
<div class="ec-multi-list-row-controls">
|
||||
<div class="ec-multi-list-input-group">
|
||||
<input class="form-control form-control-sm ec-multi-list-input"
|
||||
placeholder="@(Behavior?.Placeholder ?? "")"
|
||||
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
</div>
|
||||
<span class="status-badge-inline">
|
||||
@@ -79,7 +80,7 @@
|
||||
<div class="ec-multi-list-row-controls">
|
||||
<div class="ec-multi-list-input-group">
|
||||
<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" />
|
||||
</div>
|
||||
<span class="status-badge-inline">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
@@ -6,7 +7,12 @@
|
||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true && !IsCapabilityDisabled)
|
||||
{
|
||||
<span class="ec-field-value" @onclick:stopPropagation="true">
|
||||
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
|
||||
<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" />
|
||||
</span>
|
||||
}
|
||||
@@ -21,6 +27,7 @@ else
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public string DisplayValue { get; set; } = "";
|
||||
[Parameter] public string EditValue { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
|
||||
[Parameter] public string? LabelTooltip { get; set; }
|
||||
@@ -72,6 +79,6 @@ else
|
||||
private async Task OnInputBlur(FocusEventArgs _)
|
||||
{
|
||||
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);
|
||||
|
||||
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(IApplicationScopedConcrete), (s, i, impl) => s.AddScoped(impl, impl)),
|
||||
(typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)),
|
||||
(typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl)),
|
||||
(typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl), false),
|
||||
(typeof(IApplicationScopedConcrete), (s, i, impl) => s.AddScoped(impl, impl), false),
|
||||
(typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl), false),
|
||||
(typeof(IApplicationMultiSingleton), (s, i, impl) => s.AddSingleton(i, impl), true),
|
||||
(typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl), true),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
@@ -65,8 +66,8 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
foreach (var (markerInterface, register) in ApplicationRegistrations)
|
||||
ScanAndRegister(services, markerInterface, register);
|
||||
foreach (var (markerInterface, register, allowMultipleImplementations) in ApplicationRegistrations)
|
||||
ScanAndRegister(services, markerInterface, register, allowMultipleImplementations);
|
||||
AddOptionsValidatorsFromAssembly(services, Assembly.GetExecutingAssembly());
|
||||
return services;
|
||||
}
|
||||
@@ -98,7 +99,8 @@ public static class ServiceCollectionExtensions
|
||||
static void ScanAndRegister(
|
||||
IServiceCollection services,
|
||||
Type markerInterface,
|
||||
RegisterService register)
|
||||
RegisterService register,
|
||||
bool allowMultipleImplementations)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
@@ -116,14 +118,22 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
if (implementations.Count == 0)
|
||||
continue;
|
||||
if (implementations.Count > 1)
|
||||
if (!allowMultipleImplementations && implementations.Count > 1)
|
||||
throw new InvalidOperationException(
|
||||
$"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " +
|
||||
"Register one explicitly or exclude the others from the scan.");
|
||||
|
||||
if (allowMultipleImplementations)
|
||||
{
|
||||
foreach (var impl in implementations)
|
||||
register(services, iface, impl);
|
||||
}
|
||||
else
|
||||
{
|
||||
register(services, iface, implementations[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Marker has no derived interfaces (e.g. IApplicationHostedService): register each class that implements the marker.
|
||||
|
||||
@@ -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 DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using System.Linq;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
@@ -13,21 +12,42 @@ public sealed record OptionSemantics(
|
||||
string OptionName,
|
||||
EffectiveConfigParserBehavior ParserBehavior,
|
||||
EffectiveConfigWriteBehavior WriteBehavior,
|
||||
EffectiveConfigSingleValueValidator? SingleValueValidator,
|
||||
EffectiveConfigMultiItemValidator? MultiItemValidator
|
||||
OptionValidationSemantics Validation
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Delegate for validating one item in a multi-value option editor.
|
||||
/// </summary>
|
||||
public delegate string? EffectiveConfigMultiItemValidator(string? value);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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 =
|
||||
new Dictionary<string, OptionSemantics>(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -35,68 +55,147 @@ public static class EffectiveConfigSpecialOptionSemantics
|
||||
DnsmasqConfKeys.UseStaleCache,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
SpecialOptionValidators.ValidateUseStaleCache,
|
||||
MultiItemValidator: null),
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.AddMac] = new OptionSemantics(
|
||||
DnsmasqConfKeys.AddMac,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
SpecialOptionValidators.ValidateAddMac,
|
||||
MultiItemValidator: null),
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.AddSubnet] = new OptionSemantics(
|
||||
DnsmasqConfKeys.AddSubnet,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
SpecialOptionValidators.ValidateAddSubnet,
|
||||
MultiItemValidator: null),
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.Umbrella] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Umbrella,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
SpecialOptionValidators.ValidateUmbrella,
|
||||
MultiItemValidator: null),
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.Do0x20Encode] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Do0x20Encode,
|
||||
EffectiveConfigParserBehavior.Flag,
|
||||
EffectiveConfigWriteBehavior.InversePair,
|
||||
SingleValueValidator: null,
|
||||
MultiItemValidator: null),
|
||||
InversePair),
|
||||
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = new OptionSemantics(
|
||||
DnsmasqConfKeys.ConnmarkAllowlistEnable,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
SpecialOptionValidators.ValidateConnmarkAllowlistEnable,
|
||||
MultiItemValidator: null),
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DnssecCheckUnsigned,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
SpecialOptionValidators.ValidateDnssecCheckUnsigned,
|
||||
MultiItemValidator: null),
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.Leasequery] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Leasequery,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue,
|
||||
SingleValueValidator: null,
|
||||
SpecialOptionValidators.ValidateLeasequeryValue),
|
||||
ComplexMulti),
|
||||
[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,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
SingleValueValidator: null,
|
||||
MultiItemValidator: null),
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.DhcpBroadcast] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpBroadcast,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
SingleValueValidator: null,
|
||||
MultiItemValidator: null),
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.BootpDynamic] = new OptionSemantics(
|
||||
DnsmasqConfKeys.BootpDynamic,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
SingleValueValidator: null,
|
||||
MultiItemValidator: null),
|
||||
KeyOnlyOrValue),
|
||||
[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>
|
||||
@@ -118,11 +217,6 @@ public static class EffectiveConfigSpecialOptionSemantics
|
||||
public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
|
||||
InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null;
|
||||
|
||||
/// <summary>Returns validator from semantics for special options; otherwise null.</summary>
|
||||
public static EffectiveConfigSingleValueValidator? GetValidator(string optionName) =>
|
||||
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;
|
||||
/// <summary>Returns all option names that have special semantics. Used by wiring tests to avoid hardcoded lists.</summary>
|
||||
public static IReadOnlyCollection<string> GetAllOptionNames() => ByOptionName.Keys.ToList();
|
||||
}
|
||||
|
||||
@@ -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 readonly IOptionSemanticValidator _semanticValidator;
|
||||
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), Type> _multiDisplayComponents = 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)").
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay));
|
||||
_semanticValidator = semanticValidator;
|
||||
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.EdnsPacketMax, max: 65535);
|
||||
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.SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, min: 0);
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpTtl);
|
||||
}
|
||||
|
||||
// Boolean flags: shared display ("Enabled" / "Disabled") for all flag options.
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ExpandHosts);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusPriv);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StrictOrder);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AllServers);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoResolv);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DomainNeeded);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.NoNegcache);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoPoll);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindInterfaces);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindDynamic);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDebug);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DnsLoopDetect);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StopDnsRebind);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RebindLocalhostOk);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ClearOnReload);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Filterwin2k);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterA);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterAaaa);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LocaliseQueries);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.ReadEthers);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRapidCommit);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.LeasefileRo);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.EnableTftp);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpSecure);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoFail);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoBlocksize);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Selfmx);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.Dnssec);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.EnableRa);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDhcp);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.KeepInForeground);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDaemon);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.Conntrack);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.ProxyDnssec);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoRoundRobin);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecNoTimecheck);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecDebug);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpSequentialIp);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpIgnoreClid);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.NoPing);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.ScriptArp);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.ScriptOnRenewal);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpNoOverride);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietDhcp);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietDhcp6);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietRa);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietTftp);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.StripMac);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.StripSubnet);
|
||||
private void RegisterFlagDisplays()
|
||||
{
|
||||
RegisterFlags(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ReadEthers);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionResolver,
|
||||
DnsmasqConfKeys.ExpandHosts,
|
||||
DnsmasqConfKeys.BogusPriv,
|
||||
DnsmasqConfKeys.StrictOrder,
|
||||
DnsmasqConfKeys.AllServers,
|
||||
DnsmasqConfKeys.NoResolv,
|
||||
DnsmasqConfKeys.DomainNeeded,
|
||||
DnsmasqConfKeys.DnsLoopDetect,
|
||||
DnsmasqConfKeys.StopDnsRebind,
|
||||
DnsmasqConfKeys.RebindLocalhostOk,
|
||||
DnsmasqConfKeys.ClearOnReload,
|
||||
DnsmasqConfKeys.Filterwin2k,
|
||||
DnsmasqConfKeys.FilterA,
|
||||
DnsmasqConfKeys.FilterAaaa,
|
||||
DnsmasqConfKeys.LocaliseQueries,
|
||||
DnsmasqConfKeys.NoRoundRobin);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||
DnsmasqConfKeys.DhcpAuthoritative,
|
||||
DnsmasqConfKeys.DhcpRapidCommit,
|
||||
DnsmasqConfKeys.LeasefileRo,
|
||||
DnsmasqConfKeys.DhcpSequentialIp,
|
||||
DnsmasqConfKeys.DhcpIgnoreClid,
|
||||
DnsmasqConfKeys.NoPing,
|
||||
DnsmasqConfKeys.ScriptArp,
|
||||
DnsmasqConfKeys.ScriptOnRenewal,
|
||||
DnsmasqConfKeys.DhcpNoOverride);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
||||
DnsmasqConfKeys.EnableTftp,
|
||||
DnsmasqConfKeys.TftpSecure,
|
||||
DnsmasqConfKeys.TftpNoFail,
|
||||
DnsmasqConfKeys.TftpNoBlocksize);
|
||||
RegisterFlags(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx, DnsmasqConfKeys.Selfmx);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionDnssec,
|
||||
DnsmasqConfKeys.Dnssec,
|
||||
DnsmasqConfKeys.ProxyDnssec,
|
||||
DnsmasqConfKeys.DnssecNoTimecheck,
|
||||
DnsmasqConfKeys.DnssecDebug);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionCache,
|
||||
DnsmasqConfKeys.NoNegcache,
|
||||
DnsmasqConfKeys.StripMac,
|
||||
DnsmasqConfKeys.StripSubnet);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionProcess,
|
||||
DnsmasqConfKeys.NoPoll,
|
||||
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).
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay));
|
||||
private void RegisterKeyOnlyOrValueDisplays()
|
||||
{
|
||||
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.
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache, typeof(KeyOnlyOrValueDisplay));
|
||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache);
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac, typeof(KeyOnlyOrValueDisplay));
|
||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac);
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet, typeof(KeyOnlyOrValueDisplay));
|
||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet);
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, typeof(KeyOnlyOrValueDisplay));
|
||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella);
|
||||
private void RegisterPathValidatedSingles()
|
||||
{
|
||||
RegisterSemanticSingles(
|
||||
EffectiveConfigFieldBuilder.SectionHosts,
|
||||
DnsmasqConfKeys.Hostsdir);
|
||||
RegisterSemanticSingles(
|
||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||
DnsmasqConfKeys.DhcpLeasefile,
|
||||
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.
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable, typeof(KeyOnlyOrValueDisplay));
|
||||
RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable);
|
||||
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(
|
||||
private void RegisterMultiValueDescriptors()
|
||||
{
|
||||
RegisterSemanticMultiDescriptor(
|
||||
EffectiveConfigFieldBuilder.SectionResolver,
|
||||
DnsmasqConfKeys.Server,
|
||||
behavior: new ServerMultiBehavior(),
|
||||
validator: new ServerMultiValidator());
|
||||
behavior: new DistinctMultiValueEditBehavior());
|
||||
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,
|
||||
DnsmasqConfKeys.ListenAddress,
|
||||
validator: new ListenAddressMultiValidator());
|
||||
DnsmasqConfKeys.Interface,
|
||||
DnsmasqConfKeys.ExceptInterface,
|
||||
DnsmasqConfKeys.AuthServer,
|
||||
DnsmasqConfKeys.NoDhcpInterface,
|
||||
DnsmasqConfKeys.NoDhcpv4Interface,
|
||||
DnsmasqConfKeys.NoDhcpv6Interface);
|
||||
}
|
||||
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Local);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RevServer);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Address);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ResolvFile);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RebindDomainOk);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusNxdomain);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.IgnoreAddress);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Alias);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterRr);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Ipset);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Nftset);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlist);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Domain);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Cname);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.MxHost);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Srv);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.PtrRecord);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.TxtRecord);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.NaptrRecord);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.HostRecord);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DynamicHost);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.InterfaceName);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.CaaRecord);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DnsRr);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.SynthDomain);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthZone);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthSoa);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthSecServers);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthPeer);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRange);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHost);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOption);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptionForce);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpMatch);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpMac);
|
||||
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpNameMatch);
|
||||
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 RegisterComponents(string sectionId, Type componentType, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
RegisterComponent(sectionId, optionName, componentType);
|
||||
}
|
||||
|
||||
private void RegisterFlags(string sectionId, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
RegisterFlag(sectionId, optionName);
|
||||
}
|
||||
|
||||
private void RegisterSemanticSingles(string sectionId, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
RegisterSemanticSingleValidator(sectionId, optionName);
|
||||
}
|
||||
|
||||
private void RegisterSemanticSingleComponent(string sectionId, Type componentType, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
{
|
||||
RegisterComponent(sectionId, optionName, componentType);
|
||||
RegisterSemanticSingleValidator(sectionId, optionName);
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterMultis(string sectionId, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
RegisterMultiDescriptor(sectionId, optionName);
|
||||
}
|
||||
|
||||
private void RegisterSemanticMultis(string sectionId, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
RegisterSemanticMultiDescriptor(sectionId, optionName);
|
||||
}
|
||||
|
||||
private void RegisterMultiDescriptor(
|
||||
@@ -275,16 +378,22 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
||||
|
||||
private void RegisterSemanticSingleValidator(string sectionId, string optionName)
|
||||
{
|
||||
var validator = EffectiveConfigSpecialOptionSemantics.GetValidator(optionName);
|
||||
if (validator is not null)
|
||||
RegisterValidatedSingle(sectionId, optionName, validator);
|
||||
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName);
|
||||
if (semantics is null)
|
||||
return;
|
||||
|
||||
RegisterValidatedSingle(
|
||||
sectionId,
|
||||
optionName,
|
||||
value => _semanticValidator.ValidateSingle(optionName, value, semantics.Validation));
|
||||
}
|
||||
|
||||
private void RegisterSemanticMultiDescriptor(string sectionId, string optionName, IMultiValueEditBehavior? behavior = null)
|
||||
{
|
||||
var validator = EffectiveConfigSpecialOptionSemantics.GetMultiItemValidator(optionName) is { } validate
|
||||
? new DelegateMultiValueOptionValidator(validate)
|
||||
: null;
|
||||
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName);
|
||||
IMultiValueOptionValidator? validator = semantics is null
|
||||
? null
|
||||
: new DelegateMultiValueOptionValidator(v => _semanticValidator.ValidateMultiItem(optionName, v, semantics.Validation));
|
||||
RegisterMultiDescriptor(sectionId, optionName, behavior, validator);
|
||||
}
|
||||
|
||||
|
||||
+34
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
@@ -15,6 +16,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
||||
private readonly IDnsmasqConfigService _configService;
|
||||
private readonly IConfigSetCache _configSetCache;
|
||||
private readonly IConfigValidationService _validationService;
|
||||
private readonly IEffectiveConfigSemanticValidationService _semanticValidationService;
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly IDnsmasqVersionService _versionService;
|
||||
private readonly ILogger<EffectiveConfigSaveService> _logger;
|
||||
@@ -24,6 +26,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
||||
IDnsmasqConfigService configService,
|
||||
IConfigSetCache configSetCache,
|
||||
IConfigValidationService validationService,
|
||||
IEffectiveConfigSemanticValidationService semanticValidationService,
|
||||
IReloadService reloadService,
|
||||
IDnsmasqVersionService versionService,
|
||||
ILogger<EffectiveConfigSaveService> logger)
|
||||
@@ -32,6 +35,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
||||
_configService = configService;
|
||||
_configSetCache = configSetCache;
|
||||
_validationService = validationService;
|
||||
_semanticValidationService = semanticValidationService;
|
||||
_reloadService = reloadService;
|
||||
_versionService = versionService;
|
||||
_logger = logger;
|
||||
@@ -127,6 +131,36 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
||||
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
|
||||
{
|
||||
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;
|
||||
|
||||
/// <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>
|
||||
public sealed class DefaultMultiValueEditBehavior : IMultiValueEditBehavior
|
||||
{
|
||||
public string Placeholder => "Value";
|
||||
public bool AllowDuplicates => true;
|
||||
public string Normalize(string input) => (input ?? "").Trim();
|
||||
}
|
||||
|
||||
+2
-4
@@ -1,11 +1,9 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts a semantics multi-item validator delegate to <see cref="IMultiValueOptionValidator"/>.
|
||||
/// Adapts a simple per-item validation delegate to <see cref="IMultiValueOptionValidator"/>.
|
||||
/// </summary>
|
||||
public sealed class DelegateMultiValueOptionValidator(EffectiveConfigMultiItemValidator validate)
|
||||
public sealed class DelegateMultiValueOptionValidator(Func<string?, string?> validate)
|
||||
: IMultiValueOptionValidator
|
||||
{
|
||||
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;
|
||||
|
||||
/// <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>
|
||||
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>
|
||||
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";
|
||||
/// <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";
|
||||
/// <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 RestartFailed = "restart_failed";
|
||||
public const string UnsupportedVersion = "unsupported_version";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using DnsmasqWebUI.Components;
|
||||
using DnsmasqWebUI.Infrastructure.Realtime.Hubs;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Extensions.DependencyInjection;
|
||||
using DnsmasqWebUI.Extensions.Hosting;
|
||||
|
||||
Reference in New Issue
Block a user