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