diff --git a/src/DnsmasqWebUI.Tests/EffectiveConfigSemanticsWiringTests.cs b/src/DnsmasqWebUI.Tests/EffectiveConfigSemanticsWiringTests.cs index 1a6c3d2..9ef8338 100644 --- a/src/DnsmasqWebUI.Tests/EffectiveConfigSemanticsWiringTests.cs +++ b/src/DnsmasqWebUI.Tests/EffectiveConfigSemanticsWiringTests.cs @@ -1,5 +1,7 @@ using DnsmasqWebUI.Infrastructure.Helpers.Config; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; namespace DnsmasqWebUI.Tests; @@ -9,35 +11,19 @@ namespace DnsmasqWebUI.Tests; public class EffectiveConfigSemanticsWiringTests { [Fact] - public void ParserBehaviorMap_UsesSpecialSemantics_ForSpecialOptions() + public void ParserBehaviorMap_UsesSpecialSemantics_ForAllSpecialOptions() { - var specialOptions = new[] + foreach (var option in EffectiveConfigSpecialOptionSemantics.GetAllOptionNames()) { - DnsmasqConfKeys.UseStaleCache, - DnsmasqConfKeys.AddMac, - DnsmasqConfKeys.AddSubnet, - DnsmasqConfKeys.Umbrella, - DnsmasqConfKeys.Do0x20Encode, - DnsmasqConfKeys.ConnmarkAllowlistEnable, - DnsmasqConfKeys.DnssecCheckUnsigned, - DnsmasqConfKeys.Leasequery, - DnsmasqConfKeys.DhcpGenerateNames, - DnsmasqConfKeys.DhcpBroadcast, - DnsmasqConfKeys.BootpDynamic, - }; - - foreach (var option in specialOptions) - { - var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(option); - Assert.NotNull(semantics); - Assert.Equal(semantics!.ParserBehavior, EffectiveConfigParserBehaviorMap.GetBehavior(option)); + var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(option)!; + Assert.Equal(semantics.ParserBehavior, EffectiveConfigParserBehaviorMap.GetBehavior(option)); } } [Fact] public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics() { - var registry = new EffectiveConfigRenderFragmentRegistry(); + var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new LeasequerySemanticHandler()])); var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery); Assert.NotNull(factory); @@ -51,4 +37,20 @@ public class EffectiveConfigSemanticsWiringTests Assert.NotNull(descriptor.Validator!.ValidateItem("not-an-ip", Array.Empty())); Assert.Null(descriptor.Validator.ValidateItem("10.0.0.0/24", Array.Empty())); } + + /// Pre-save semantic blocking: invalid leasequery value produces errors; SaveService uses this and returns SemanticValidationFailed before write. + [Fact] + public void SemanticValidationService_InvalidLeasequery_ReturnsError() + { + var validator = new OptionSemanticValidator([new LeasequerySemanticHandler()]); + var service = new EffectiveConfigSemanticValidationService(validator); + var changes = new List + { + new(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery, null, + new List { "not-an-ip" }, null) + }; + var issues = service.Validate(changes); + Assert.NotEmpty(issues); + Assert.Contains(issues, i => i.Severity == FieldIssueSeverity.Error); + } } diff --git a/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs b/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs new file mode 100644 index 0000000..76f0d09 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/OptionSemanticValidatorTests.cs @@ -0,0 +1,275 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for the central semantic validator (handler dispatch and generic kind validation). +/// +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(() => + 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); + } + } +} diff --git a/src/DnsmasqWebUI.Tests/SpecialOptionValidatorsTests.cs b/src/DnsmasqWebUI.Tests/SpecialOptionValidatorsTests.cs deleted file mode 100644 index b502b49..0000000 --- a/src/DnsmasqWebUI.Tests/SpecialOptionValidatorsTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; -using Xunit; - -namespace DnsmasqWebUI.Tests; - -/// -/// Validation tests for SpecialOptionValidators, including malformed numeric and structured values. -/// -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); - } -} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/ConfigFileList.razor b/src/DnsmasqWebUI/Components/Dnsmasq/ConfigFileList.razor index a391cf1..1ff171b 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/ConfigFileList.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/ConfigFileList.razor @@ -11,7 +11,20 @@ var fileCount = hasList ? configFileList!.Count : 2; var showToggle = hasMoreThanOne || (!hasList && Status.ManagedFilePath != null); } -

Config files (load order)

+
+

Config files (load order)

+ @if (showToggle) + { + + } +
    @@ -45,17 +58,6 @@
}
- @if (showToggle) - { - - } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/ConfigFileList.razor.css b/src/DnsmasqWebUI/Components/Dnsmasq/ConfigFileList.razor.css index c4c0efd..ccb6193 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/ConfigFileList.razor.css +++ b/src/DnsmasqWebUI/Components/Dnsmasq/ConfigFileList.razor.css @@ -15,11 +15,19 @@ margin-top: 0.5rem; } +.config-file-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + .config-file-list .config-file-list-title { font-size: 0.9375rem; font-weight: 600; color: #212529; - margin-bottom: 0.5rem; + margin-bottom: 0; } .config-file-list ul { @@ -34,10 +42,10 @@ padding: 0.2rem 0; } -/* Content and toggle on one row: list grows, toggle at bottom-right */ +/* List content row (toggle is in header now) */ .config-file-list-content-row { display: flex; - align-items: flex-end; + align-items: flex-start; gap: 0.75rem; } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/KeyOnlyOrValueDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/KeyOnlyOrValueDisplay.razor index 011cb2c..c5088fa 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/KeyOnlyOrValueDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/KeyOnlyOrValueDisplay.razor @@ -1,4 +1,5 @@ @namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays +@using DnsmasqWebUI.Infrastructure.Helpers.Config @using DnsmasqWebUI.Models.Dnsmasq @inherits EffectiveConfigValueDisplayBase @@ -20,7 +21,7 @@ { } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor index 109515f..f1d3614 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor @@ -77,6 +77,7 @@ else { var value = _effectiveValue; var displayValue = value == null ? "(not set)" : (value is IReadOnlyList list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString()); + var editValue = value?.ToString() ?? ""; var isReadonly = Descriptor.GetSource()?.IsReadOnly == true; var showReadonlyHint = IsEditMode && isReadonly; var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled; @@ -92,6 +93,7 @@ else {
  • CommitIfChangedAsync() { var newValue = _hasDraftValue ? _draftValue : _effectiveValue; + if (IsDefaultSingleValueField) + newValue = NormalizeDefaultSingleCommitValue(newValue); if (ValuesEqual(_valueAtActivation, newValue)) { _draftValue = null; @@ -244,6 +248,9 @@ else return true; } + private bool IsDefaultSingleValueField => + !Descriptor.IsMultiValue && _customFragment is null; + [CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; } private void HandleDefaultValueChanged(string value) @@ -255,12 +262,19 @@ else private async Task HandleDefaultBlur(string? value) { if (!IsActive) return; - _draftValue = value; + _draftValue = NormalizeDefaultSingleCommitValue(value); _hasDraftValue = true; if (!await CommitIfChangedAsync() && Ui != null) await Ui.ActivateFieldAsync(FieldKey); } + private static object? NormalizeDefaultSingleCommitValue(object? value) + { + if (value is string s && string.IsNullOrWhiteSpace(s)) + return null; + return value; + } + private void HandleMultiValueChanged(IReadOnlyList values) { _draftValue = values.ToList(); diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor index 0955f41..eee82fa 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor @@ -1,4 +1,5 @@ @namespace DnsmasqWebUI.Components.EffectiveConfig +@using DnsmasqWebUI.Infrastructure.Helpers.Config @using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using Microsoft.AspNetCore.Components.Web @@ -25,7 +26,7 @@
    @@ -79,7 +80,7 @@
    diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigOptionItem.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigOptionItem.razor index 3d19ba8..7cac9cf 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigOptionItem.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigOptionItem.razor @@ -1,4 +1,5 @@ @namespace DnsmasqWebUI.Components.EffectiveConfig +@using DnsmasqWebUI.Infrastructure.Helpers.Config @using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @@ -6,7 +7,12 @@ @if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true && !IsCapabilityDisabled) { - + } @@ -21,6 +27,7 @@ else @code { [Parameter] public string Label { get; set; } = ""; [Parameter] public string DisplayValue { get; set; } = ""; + [Parameter] public string EditValue { get; set; } = ""; [Parameter] public ConfigValueSource? Source { get; set; } /// Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips). [Parameter] public string? LabelTooltip { get; set; } @@ -72,6 +79,6 @@ else private async Task OnInputBlur(FocusEventArgs _) { if (OnBlur.HasDelegate) - await OnBlur.InvokeAsync(_editValue ?? DisplayValue); + await OnBlur.InvokeAsync(_editValue ?? EditValue); } } diff --git a/src/DnsmasqWebUI/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/DnsmasqWebUI/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index 66bf379..8fae555 100644 --- a/src/DnsmasqWebUI/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/DnsmasqWebUI/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -48,12 +48,13 @@ public static class ServiceCollectionExtensions delegate void RegisterService(IServiceCollection s, Type iface, Type impl); - static readonly (Type MarkerInterface, RegisterService Register)[] ApplicationRegistrations = + static readonly (Type MarkerInterface, RegisterService Register, bool AllowMultipleImplementations)[] ApplicationRegistrations = [ - (typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)), - (typeof(IApplicationScopedConcrete), (s, i, impl) => s.AddScoped(impl, impl)), - (typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)), - (typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl)), + (typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl), false), + (typeof(IApplicationScopedConcrete), (s, i, impl) => s.AddScoped(impl, impl), false), + (typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl), false), + (typeof(IApplicationMultiSingleton), (s, i, impl) => s.AddSingleton(i, impl), true), + (typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl), true), ]; /// @@ -65,8 +66,8 @@ public static class ServiceCollectionExtensions /// public static IServiceCollection AddApplicationServices(this IServiceCollection services) { - foreach (var (markerInterface, register) in ApplicationRegistrations) - ScanAndRegister(services, markerInterface, register); + foreach (var (markerInterface, register, allowMultipleImplementations) in ApplicationRegistrations) + ScanAndRegister(services, markerInterface, register, allowMultipleImplementations); AddOptionsValidatorsFromAssembly(services, Assembly.GetExecutingAssembly()); return services; } @@ -98,7 +99,8 @@ public static class ServiceCollectionExtensions static void ScanAndRegister( IServiceCollection services, Type markerInterface, - RegisterService register) + RegisterService register, + bool allowMultipleImplementations) { var assembly = Assembly.GetExecutingAssembly(); @@ -116,12 +118,20 @@ public static class ServiceCollectionExtensions if (implementations.Count == 0) continue; - if (implementations.Count > 1) + if (!allowMultipleImplementations && implementations.Count > 1) throw new InvalidOperationException( $"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " + "Register one explicitly or exclude the others from the scan."); - register(services, iface, implementations[0]); + if (allowMultipleImplementations) + { + foreach (var impl in implementations) + register(services, iface, impl); + } + else + { + register(services, iface, implementations[0]); + } } } else diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionPlaceholders.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionPlaceholders.cs new file mode 100644 index 0000000..772c419 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionPlaceholders.cs @@ -0,0 +1,135 @@ +using System.Collections.Frozen; + +namespace DnsmasqWebUI.Infrastructure.Helpers.Config; + +/// +/// 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. +/// +public static class DnsmasqOptionPlaceholders +{ + private static readonly FrozenDictionary Values = new Dictionary(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(); + + /// Returns the placeholder value for an option, or null when none is defined. + public static string? Get(string optionName) => + Values.TryGetValue(optionName, out var value) ? value : null; +} diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs index 85bfb3e..d551b0f 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; -using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; -using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using System.Linq; namespace DnsmasqWebUI.Infrastructure.Helpers.Config; @@ -13,21 +12,42 @@ public sealed record OptionSemantics( string OptionName, EffectiveConfigParserBehavior ParserBehavior, EffectiveConfigWriteBehavior WriteBehavior, - EffectiveConfigSingleValueValidator? SingleValueValidator, - EffectiveConfigMultiItemValidator? MultiItemValidator + OptionValidationSemantics Validation ); -/// -/// Delegate for validating one item in a multi-value option editor. -/// -public delegate string? EffectiveConfigMultiItemValidator(string? value); - /// /// 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. /// 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 ByOptionName = new Dictionary(StringComparer.Ordinal) { @@ -35,68 +55,147 @@ public static class EffectiveConfigSpecialOptionSemantics DnsmasqConfKeys.UseStaleCache, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateUseStaleCache, - MultiItemValidator: null), + KeyOnlyOrValue), [DnsmasqConfKeys.AddMac] = new OptionSemantics( DnsmasqConfKeys.AddMac, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateAddMac, - MultiItemValidator: null), + KeyOnlyOrValue), [DnsmasqConfKeys.AddSubnet] = new OptionSemantics( DnsmasqConfKeys.AddSubnet, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateAddSubnet, - MultiItemValidator: null), + KeyOnlyOrValue), [DnsmasqConfKeys.Umbrella] = new OptionSemantics( DnsmasqConfKeys.Umbrella, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateUmbrella, - MultiItemValidator: null), + KeyOnlyOrValue), [DnsmasqConfKeys.Do0x20Encode] = new OptionSemantics( DnsmasqConfKeys.Do0x20Encode, EffectiveConfigParserBehavior.Flag, EffectiveConfigWriteBehavior.InversePair, - SingleValueValidator: null, - MultiItemValidator: null), + InversePair), [DnsmasqConfKeys.ConnmarkAllowlistEnable] = new OptionSemantics( DnsmasqConfKeys.ConnmarkAllowlistEnable, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateConnmarkAllowlistEnable, - MultiItemValidator: null), + KeyOnlyOrValue), [DnsmasqConfKeys.DnssecCheckUnsigned] = new OptionSemantics( DnsmasqConfKeys.DnssecCheckUnsigned, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateDnssecCheckUnsigned, - MultiItemValidator: null), + KeyOnlyOrValue), [DnsmasqConfKeys.Leasequery] = new OptionSemantics( DnsmasqConfKeys.Leasequery, EffectiveConfigParserBehavior.Multi, EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue, - SingleValueValidator: null, - SpecialOptionValidators.ValidateLeasequeryValue), + ComplexMulti), + [DnsmasqConfKeys.Server] = new OptionSemantics( + DnsmasqConfKeys.Server, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.RevServer] = new OptionSemantics( + DnsmasqConfKeys.RevServer, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.Address] = new OptionSemantics( + DnsmasqConfKeys.Address, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + ComplexMulti), + [DnsmasqConfKeys.ListenAddress] = new OptionSemantics( + DnsmasqConfKeys.ListenAddress, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + IpAddressMulti), [DnsmasqConfKeys.DhcpGenerateNames] = new OptionSemantics( DnsmasqConfKeys.DhcpGenerateNames, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SingleValueValidator: null, - MultiItemValidator: null), + KeyOnlyOrValue), [DnsmasqConfKeys.DhcpBroadcast] = new OptionSemantics( DnsmasqConfKeys.DhcpBroadcast, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SingleValueValidator: null, - MultiItemValidator: null), + KeyOnlyOrValue), [DnsmasqConfKeys.BootpDynamic] = new OptionSemantics( DnsmasqConfKeys.BootpDynamic, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SingleValueValidator: null, - MultiItemValidator: null), + KeyOnlyOrValue), + [DnsmasqConfKeys.AddnHosts] = new OptionSemantics( + DnsmasqConfKeys.AddnHosts, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + PathFileMulti), + [DnsmasqConfKeys.ResolvFile] = new OptionSemantics( + DnsmasqConfKeys.ResolvFile, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + PathFileMulti), + [DnsmasqConfKeys.Hostsdir] = new OptionSemantics( + DnsmasqConfKeys.Hostsdir, + EffectiveConfigParserBehavior.LastWins, + EffectiveConfigWriteBehavior.SingleValue, + PathDirectorySingleMustExist), + [DnsmasqConfKeys.DhcpLeasefile] = new OptionSemantics( + DnsmasqConfKeys.DhcpLeasefile, + EffectiveConfigParserBehavior.LastWins, + EffectiveConfigWriteBehavior.SingleValue, + PathFileSingleParentMustExist), + [DnsmasqConfKeys.TftpRoot] = new OptionSemantics( + DnsmasqConfKeys.TftpRoot, + EffectiveConfigParserBehavior.LastWins, + EffectiveConfigWriteBehavior.SingleValue, + PathDirectorySingleMustExist), + [DnsmasqConfKeys.PidFile] = new OptionSemantics( + DnsmasqConfKeys.PidFile, + EffectiveConfigParserBehavior.LastWins, + EffectiveConfigWriteBehavior.SingleValue, + PathFileSingleParentMustExist), + [DnsmasqConfKeys.Dumpfile] = new OptionSemantics( + DnsmasqConfKeys.Dumpfile, + EffectiveConfigParserBehavior.LastWins, + EffectiveConfigWriteBehavior.SingleValue, + PathFileSingleParentMustExist), + [DnsmasqConfKeys.DnssecTimestamp] = new OptionSemantics( + DnsmasqConfKeys.DnssecTimestamp, + EffectiveConfigParserBehavior.LastWins, + EffectiveConfigWriteBehavior.SingleValue, + PathFileSingleParentMustExist), + [DnsmasqConfKeys.DhcpScript] = new OptionSemantics( + DnsmasqConfKeys.DhcpScript, + EffectiveConfigParserBehavior.LastWins, + EffectiveConfigWriteBehavior.SingleValue, + PathFileSingleMustExist), + [DnsmasqConfKeys.DhcpLuascript] = new OptionSemantics( + DnsmasqConfKeys.DhcpLuascript, + EffectiveConfigParserBehavior.LastWins, + EffectiveConfigWriteBehavior.SingleValue, + PathFileSingleMustExist), + [DnsmasqConfKeys.DhcpHostsfile] = new OptionSemantics( + DnsmasqConfKeys.DhcpHostsfile, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + PathFileMulti), + [DnsmasqConfKeys.DhcpOptsfile] = new OptionSemantics( + DnsmasqConfKeys.DhcpOptsfile, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + PathFileMulti), + [DnsmasqConfKeys.DhcpHostsdir] = new OptionSemantics( + DnsmasqConfKeys.DhcpHostsdir, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + PathDirectoryMulti), + [DnsmasqConfKeys.DhcpOptsdir] = new OptionSemantics( + DnsmasqConfKeys.DhcpOptsdir, + EffectiveConfigParserBehavior.Multi, + EffectiveConfigWriteBehavior.MultiValue, + PathDirectoryMulti), }; /// Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints. @@ -118,11 +217,6 @@ public static class EffectiveConfigSpecialOptionSemantics public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) => InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null; - /// Returns validator from semantics for special options; otherwise null. - public static EffectiveConfigSingleValueValidator? GetValidator(string optionName) => - TryGetSemantics(optionName)?.SingleValueValidator; - - /// Returns per-item multi validator from semantics for special options; otherwise null. - public static EffectiveConfigMultiItemValidator? GetMultiItemValidator(string optionName) => - TryGetSemantics(optionName)?.MultiItemValidator; + /// Returns all option names that have special semantics. Used by wiring tests to avoid hardcoded lists. + public static IReadOnlyCollection GetAllOptionNames() => ByOptionName.Keys.ToList(); } diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/OptionValidationSemantics.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/OptionValidationSemantics.cs new file mode 100644 index 0000000..449b606 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/OptionValidationSemantics.cs @@ -0,0 +1,63 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Helpers.Config; + +/// +/// Shared validation kinds for effective-config options. +/// Describes the generic value shape; option-specific rules live in the semantic validator or handlers. +/// +public enum OptionValidationKind +{ + Flag, + Int, + String, + PathFile, + PathDirectory, + PathFileOrDirectory, + IpAddress, + HostOrIp, + KeyOnlyOrValue, + InversePair, + Complex +} + +/// +/// Whether a path value must exist (file/dir) or only its parent, for validation. +/// +public enum PathExistencePolicy +{ + None, + MustExist, + ParentMustExist +} + +/// +/// Reusable validation metadata for an option: validation kind, path policy, severity, and empty handling. +/// +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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs index 81662bd..1b2361a 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs @@ -17,17 +17,38 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm { private static readonly IMultiValueEditBehavior DefaultMultiBehavior = new DefaultMultiValueEditBehavior(); + private readonly IOptionSemanticValidator _semanticValidator; private readonly Dictionary<(string SectionId, string OptionName), Type> _displayComponents = new(); private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigDescriptorFactory> _descriptorFactories = new(); private readonly Dictionary<(string SectionId, string OptionName), Type> _multiDisplayComponents = new(); private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigMultiDescriptorFactory> _multiDescriptorFactories = new(); - public EffectiveConfigRenderFragmentRegistry() + public EffectiveConfigRenderFragmentRegistry(IOptionSemanticValidator semanticValidator) { - // Port: single-field display (e.g. "53 (default DNS port)"). - RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay)); + _semanticValidator = semanticValidator; + RegisterAll(); + } - // Integer single-value options: component type + factory that creates EffectiveIntegerConfigFieldDescriptor. + private void RegisterAll() + { + RegisterCustomSingles(); + RegisterIntegerDescriptors(); + RegisterFlagDisplays(); + RegisterKeyOnlyOrValueDisplays(); + RegisterPathValidatedSingles(); + RegisterMultiValueDescriptors(); + } + + private void RegisterCustomSingles() + { + RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay)); + RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay)); + RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay)); + RegisterComponent(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay)); + } + + private void RegisterIntegerDescriptors() + { RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AuthTtl, unit: "seconds"); RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.EdnsPacketMax, max: 65535); RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.QueryPort, max: 65535, defaultValue: 0); @@ -43,170 +64,252 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.MinCacheTtl, unit: "seconds"); RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, min: 0); RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpTtl); + } - // Boolean flags: shared display ("Enabled" / "Disabled") for all flag options. - RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ExpandHosts); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusPriv); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StrictOrder); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AllServers); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoResolv); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DomainNeeded); - RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.NoNegcache); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoPoll); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindInterfaces); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindDynamic); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDebug); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DnsLoopDetect); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StopDnsRebind); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RebindLocalhostOk); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ClearOnReload); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Filterwin2k); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterA); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterAaaa); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LocaliseQueries); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative); - RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.ReadEthers); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRapidCommit); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.LeasefileRo); - RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.EnableTftp); - RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpSecure); - RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoFail); - RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoBlocksize); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Selfmx); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.Dnssec); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.EnableRa); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDhcp); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.KeepInForeground); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDaemon); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.Conntrack); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.ProxyDnssec); - RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoRoundRobin); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecNoTimecheck); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecDebug); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpSequentialIp); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpIgnoreClid); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.NoPing); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.ScriptArp); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.ScriptOnRenewal); - RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpNoOverride); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietDhcp); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietDhcp6); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietRa); - RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietTftp); - RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.StripMac); - RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.StripSubnet); + private void RegisterFlagDisplays() + { + RegisterFlags(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ReadEthers); + RegisterFlags( + EffectiveConfigFieldBuilder.SectionResolver, + DnsmasqConfKeys.ExpandHosts, + DnsmasqConfKeys.BogusPriv, + DnsmasqConfKeys.StrictOrder, + DnsmasqConfKeys.AllServers, + DnsmasqConfKeys.NoResolv, + DnsmasqConfKeys.DomainNeeded, + DnsmasqConfKeys.DnsLoopDetect, + DnsmasqConfKeys.StopDnsRebind, + DnsmasqConfKeys.RebindLocalhostOk, + DnsmasqConfKeys.ClearOnReload, + DnsmasqConfKeys.Filterwin2k, + DnsmasqConfKeys.FilterA, + DnsmasqConfKeys.FilterAaaa, + DnsmasqConfKeys.LocaliseQueries, + DnsmasqConfKeys.NoRoundRobin); + RegisterFlags( + EffectiveConfigFieldBuilder.SectionDhcp, + DnsmasqConfKeys.DhcpAuthoritative, + DnsmasqConfKeys.DhcpRapidCommit, + DnsmasqConfKeys.LeasefileRo, + DnsmasqConfKeys.DhcpSequentialIp, + DnsmasqConfKeys.DhcpIgnoreClid, + DnsmasqConfKeys.NoPing, + DnsmasqConfKeys.ScriptArp, + DnsmasqConfKeys.ScriptOnRenewal, + DnsmasqConfKeys.DhcpNoOverride); + RegisterFlags( + EffectiveConfigFieldBuilder.SectionTftpPxe, + DnsmasqConfKeys.EnableTftp, + DnsmasqConfKeys.TftpSecure, + DnsmasqConfKeys.TftpNoFail, + DnsmasqConfKeys.TftpNoBlocksize); + RegisterFlags(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx, DnsmasqConfKeys.Selfmx); + RegisterFlags( + EffectiveConfigFieldBuilder.SectionDnssec, + DnsmasqConfKeys.Dnssec, + DnsmasqConfKeys.ProxyDnssec, + DnsmasqConfKeys.DnssecNoTimecheck, + DnsmasqConfKeys.DnssecDebug); + RegisterFlags( + EffectiveConfigFieldBuilder.SectionCache, + DnsmasqConfKeys.NoNegcache, + DnsmasqConfKeys.StripMac, + DnsmasqConfKeys.StripSubnet); + RegisterFlags( + EffectiveConfigFieldBuilder.SectionProcess, + DnsmasqConfKeys.NoPoll, + DnsmasqConfKeys.BindInterfaces, + DnsmasqConfKeys.BindDynamic, + DnsmasqConfKeys.LogDebug, + DnsmasqConfKeys.EnableRa, + DnsmasqConfKeys.LogDhcp, + DnsmasqConfKeys.KeepInForeground, + DnsmasqConfKeys.NoDaemon, + DnsmasqConfKeys.Conntrack, + DnsmasqConfKeys.QuietDhcp, + DnsmasqConfKeys.QuietDhcp6, + DnsmasqConfKeys.QuietRa, + DnsmasqConfKeys.QuietTftp); + } - // do-0x20-encode / no-0x20-encode: tri-state dropdown (Default / Enabled / Disabled). - RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay)); + private void RegisterKeyOnlyOrValueDisplays() + { + RegisterSemanticSingleComponent( + EffectiveConfigFieldBuilder.SectionCache, + typeof(KeyOnlyOrValueDisplay), + DnsmasqConfKeys.UseStaleCache, + DnsmasqConfKeys.AddMac, + DnsmasqConfKeys.AddSubnet, + DnsmasqConfKeys.Umbrella); + RegisterSemanticSingleComponent( + EffectiveConfigFieldBuilder.SectionResolver, + typeof(KeyOnlyOrValueDisplay), + DnsmasqConfKeys.ConnmarkAllowlistEnable); + RegisterSemanticSingleComponent( + EffectiveConfigFieldBuilder.SectionDnssec, + typeof(KeyOnlyOrValueDisplay), + DnsmasqConfKeys.DnssecCheckUnsigned); + RegisterComponents( + EffectiveConfigFieldBuilder.SectionDhcp, + typeof(KeyOnlyOrValueDisplay), + DnsmasqConfKeys.DhcpGenerateNames, + DnsmasqConfKeys.DhcpBroadcast, + DnsmasqConfKeys.BootpDynamic); + } - // Key-only or key=value options: checkbox (On) + optional value input; with semantic validators. - RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache, typeof(KeyOnlyOrValueDisplay)); - RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache); - RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac, typeof(KeyOnlyOrValueDisplay)); - RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac); - RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet, typeof(KeyOnlyOrValueDisplay)); - RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet); - RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, typeof(KeyOnlyOrValueDisplay)); - RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella); + private void RegisterPathValidatedSingles() + { + RegisterSemanticSingles( + EffectiveConfigFieldBuilder.SectionHosts, + DnsmasqConfKeys.Hostsdir); + RegisterSemanticSingles( + EffectiveConfigFieldBuilder.SectionDhcp, + DnsmasqConfKeys.DhcpLeasefile, + DnsmasqConfKeys.DhcpScript, + DnsmasqConfKeys.DhcpLuascript); + RegisterSemanticSingles( + EffectiveConfigFieldBuilder.SectionTftpPxe, + DnsmasqConfKeys.TftpRoot); + RegisterSemanticSingles( + EffectiveConfigFieldBuilder.SectionDnssec, + DnsmasqConfKeys.DnssecTimestamp); + RegisterSemanticSingles( + EffectiveConfigFieldBuilder.SectionCache, + DnsmasqConfKeys.Dumpfile); + RegisterSemanticSingles( + EffectiveConfigFieldBuilder.SectionProcess, + DnsmasqConfKeys.PidFile); + } - // connmark-allowlist-enable, dnssec-check-unsigned: key-only or key=value with validation. - RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable, typeof(KeyOnlyOrValueDisplay)); - RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable); - RegisterComponent(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, typeof(KeyOnlyOrValueDisplay)); - RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned); - - // DHCP key-only-or-value options (optional value; no custom validator). - RegisterComponent(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpGenerateNames, typeof(KeyOnlyOrValueDisplay)); - RegisterComponent(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpBroadcast, typeof(KeyOnlyOrValueDisplay)); - RegisterComponent(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.BootpDynamic, typeof(KeyOnlyOrValueDisplay)); - - // log-queries: dropdown (Off / On / extra / proto / auth). - RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay)); - - // local-service: dropdown (not set / net / host). - RegisterComponent(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay)); - - // Multi-value: server — descriptor only (behavior + validator); no custom display. - RegisterMultiDescriptor( + private void RegisterMultiValueDescriptors() + { + RegisterSemanticMultiDescriptor( EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Server, - behavior: new ServerMultiBehavior(), - validator: new ServerMultiValidator()); + behavior: new DistinctMultiValueEditBehavior()); + RegisterSemanticMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.ListenAddress); - RegisterMultiDescriptor( + RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts); + RegisterSemanticMultis( + EffectiveConfigFieldBuilder.SectionResolver, + DnsmasqConfKeys.ResolvFile, + DnsmasqConfKeys.RevServer, + DnsmasqConfKeys.Address); + RegisterSemanticMultis( + EffectiveConfigFieldBuilder.SectionDhcp, + DnsmasqConfKeys.DhcpHostsfile, + DnsmasqConfKeys.DhcpOptsfile, + DnsmasqConfKeys.DhcpHostsdir, + DnsmasqConfKeys.DhcpOptsdir, + DnsmasqConfKeys.Leasequery); + + RegisterMultis( + EffectiveConfigFieldBuilder.SectionResolver, + DnsmasqConfKeys.Local, + DnsmasqConfKeys.RebindDomainOk, + DnsmasqConfKeys.BogusNxdomain, + DnsmasqConfKeys.IgnoreAddress, + DnsmasqConfKeys.Alias, + DnsmasqConfKeys.FilterRr, + DnsmasqConfKeys.Ipset, + DnsmasqConfKeys.Nftset, + DnsmasqConfKeys.ConnmarkAllowlist); + RegisterMultis( + EffectiveConfigFieldBuilder.SectionDnsRecords, + DnsmasqConfKeys.Domain, + DnsmasqConfKeys.Cname, + DnsmasqConfKeys.MxHost, + DnsmasqConfKeys.Srv, + DnsmasqConfKeys.PtrRecord, + DnsmasqConfKeys.TxtRecord, + DnsmasqConfKeys.NaptrRecord, + DnsmasqConfKeys.HostRecord, + DnsmasqConfKeys.DynamicHost, + DnsmasqConfKeys.InterfaceName, + DnsmasqConfKeys.CaaRecord, + DnsmasqConfKeys.DnsRr, + DnsmasqConfKeys.SynthDomain, + DnsmasqConfKeys.AuthZone, + DnsmasqConfKeys.AuthSoa, + DnsmasqConfKeys.AuthSecServers, + DnsmasqConfKeys.AuthPeer); + RegisterMultis( + EffectiveConfigFieldBuilder.SectionDhcp, + DnsmasqConfKeys.DhcpRange, + DnsmasqConfKeys.DhcpHost, + DnsmasqConfKeys.DhcpOption, + DnsmasqConfKeys.DhcpOptionForce, + DnsmasqConfKeys.DhcpMatch, + DnsmasqConfKeys.DhcpMac, + DnsmasqConfKeys.DhcpNameMatch, + DnsmasqConfKeys.DhcpIgnoreNames, + DnsmasqConfKeys.DhcpRelay, + DnsmasqConfKeys.DhcpCircuitid, + DnsmasqConfKeys.DhcpRemoteid, + DnsmasqConfKeys.DhcpSubscrid, + DnsmasqConfKeys.DhcpProxy, + DnsmasqConfKeys.TagIf, + DnsmasqConfKeys.BridgeInterface, + DnsmasqConfKeys.SharedNetwork, + DnsmasqConfKeys.DhcpBoot, + DnsmasqConfKeys.DhcpIgnore, + DnsmasqConfKeys.DhcpVendorclass, + DnsmasqConfKeys.DhcpUserclass, + DnsmasqConfKeys.RaParam, + DnsmasqConfKeys.Slaac); + RegisterMultis( + EffectiveConfigFieldBuilder.SectionTftpPxe, + DnsmasqConfKeys.PxeService, + DnsmasqConfKeys.DhcpOptionPxe); + RegisterMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor); + RegisterMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr); + RegisterMultis( EffectiveConfigFieldBuilder.SectionProcess, - DnsmasqConfKeys.ListenAddress, - validator: new ListenAddressMultiValidator()); + DnsmasqConfKeys.Interface, + DnsmasqConfKeys.ExceptInterface, + DnsmasqConfKeys.AuthServer, + DnsmasqConfKeys.NoDhcpInterface, + DnsmasqConfKeys.NoDhcpv4Interface, + DnsmasqConfKeys.NoDhcpv6Interface); + } - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Local); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RevServer); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Address); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ResolvFile); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RebindDomainOk); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusNxdomain); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.IgnoreAddress); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Alias); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterRr); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Ipset); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Nftset); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlist); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Domain); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Cname); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.MxHost); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Srv); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.PtrRecord); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.TxtRecord); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.NaptrRecord); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.HostRecord); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DynamicHost); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.InterfaceName); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.CaaRecord); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DnsRr); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.SynthDomain); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthZone); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthSoa); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthSecServers); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.AuthPeer); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRange); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHost); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOption); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptionForce); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpMatch); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpMac); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpNameMatch); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpIgnoreNames); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsfile); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsfile); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsdir); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsdir); - RegisterSemanticMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRelay); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpCircuitid); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRemoteid); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpSubscrid); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpProxy); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.TagIf); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.BridgeInterface); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.SharedNetwork); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpBoot); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpIgnore); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpVendorclass); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpUserclass); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.RaParam); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Slaac); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.PxeService); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.DhcpOptionPxe); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.Interface); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.ExceptInterface); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.AuthServer); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDhcpInterface); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDhcpv4Interface); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDhcpv6Interface); + private void RegisterComponents(string sectionId, Type componentType, params string[] optionNames) + { + foreach (var optionName in optionNames) + RegisterComponent(sectionId, optionName, componentType); + } + + private void RegisterFlags(string sectionId, params string[] optionNames) + { + foreach (var optionName in optionNames) + RegisterFlag(sectionId, optionName); + } + + private void RegisterSemanticSingles(string sectionId, params string[] optionNames) + { + foreach (var optionName in optionNames) + RegisterSemanticSingleValidator(sectionId, optionName); + } + + private void RegisterSemanticSingleComponent(string sectionId, Type componentType, params string[] optionNames) + { + foreach (var optionName in optionNames) + { + RegisterComponent(sectionId, optionName, componentType); + RegisterSemanticSingleValidator(sectionId, optionName); + } + } + + private void RegisterMultis(string sectionId, params string[] optionNames) + { + foreach (var optionName in optionNames) + RegisterMultiDescriptor(sectionId, optionName); + } + + private void RegisterSemanticMultis(string sectionId, params string[] optionNames) + { + foreach (var optionName in optionNames) + RegisterSemanticMultiDescriptor(sectionId, optionName); } private void RegisterMultiDescriptor( @@ -275,16 +378,22 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm private void RegisterSemanticSingleValidator(string sectionId, string optionName) { - var validator = EffectiveConfigSpecialOptionSemantics.GetValidator(optionName); - if (validator is not null) - RegisterValidatedSingle(sectionId, optionName, validator); + var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName); + if (semantics is null) + return; + + RegisterValidatedSingle( + sectionId, + optionName, + value => _semanticValidator.ValidateSingle(optionName, value, semantics.Validation)); } private void RegisterSemanticMultiDescriptor(string sectionId, string optionName, IMultiValueEditBehavior? behavior = null) { - var validator = EffectiveConfigSpecialOptionSemantics.GetMultiItemValidator(optionName) is { } validate - ? new DelegateMultiValueOptionValidator(validate) - : null; + var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName); + IMultiValueOptionValidator? validator = semantics is null + ? null + : new DelegateMultiValueOptionValidator(v => _semanticValidator.ValidateMultiItem(optionName, v, semantics.Validation)); RegisterMultiDescriptor(sectionId, optionName, behavior, validator); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs index 66da3e9..19599fb 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs @@ -1,3 +1,4 @@ +using System.Linq; using DnsmasqWebUI.Infrastructure.Helpers.Config; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions; @@ -15,6 +16,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService private readonly IDnsmasqConfigService _configService; private readonly IConfigSetCache _configSetCache; private readonly IConfigValidationService _validationService; + private readonly IEffectiveConfigSemanticValidationService _semanticValidationService; private readonly IReloadService _reloadService; private readonly IDnsmasqVersionService _versionService; private readonly ILogger _logger; @@ -24,6 +26,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService IDnsmasqConfigService configService, IConfigSetCache configSetCache, IConfigValidationService validationService, + IEffectiveConfigSemanticValidationService semanticValidationService, IReloadService reloadService, IDnsmasqVersionService versionService, ILogger logger) @@ -32,6 +35,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService _configService = configService; _configSetCache = configSetCache; _validationService = validationService; + _semanticValidationService = semanticValidationService; _reloadService = reloadService; _versionService = versionService; _logger = logger; @@ -127,6 +131,36 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService UserMessage: userMessage); } + var semanticIssues = _semanticValidationService.Validate(changes); + if (semanticIssues.Any(i => i.Severity == FieldIssueSeverity.Error)) + { + var errorIssues = semanticIssues.Where(i => i.Severity == FieldIssueSeverity.Error).ToList(); + var messages = errorIssues.Select(i => i.Message).Distinct().Take(5).ToList(); + var userMessage = messages.Count == 1 + ? messages[0] + : "Some values are invalid. Fix validation errors before saving. " + string.Join("; ", messages); + var failedFields = errorIssues.Select(i => i.FieldKey).Distinct().ToList(); + _logger.LogWarning( + "Save blocked: semantic validation failed for {ErrorCount} error(s) on field(s) {FailedFields}. First message: {FirstMessage}", + errorIssues.Count, + failedFields, + messages.FirstOrDefault()); + return new EffectiveConfigSaveResult( + BackupCreated: false, + BackupPath: null, + Saved: false, + Validated: false, + ValidationExitCode: -1, + ValidationStdOut: null, + ValidationStdErr: null, + Restarted: false, + RestartExitCode: -1, + RestartStdOut: null, + RestartStdErr: null, + ErrorCode: EffectiveConfigSaveResult.ErrorCodes.SemanticValidationFailed, + UserMessage: userMessage); + } + try { CreateBackupIfSourceExists(managedPath, backupPath); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSemanticValidationService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSemanticValidationService.cs new file mode 100644 index 0000000..94abc75 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSemanticValidationService.cs @@ -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; + +/// +/// Pre-save semantic validation: runs option semantics validation on pending changes. +/// +public sealed class EffectiveConfigSemanticValidationService : IEffectiveConfigSemanticValidationService +{ + private readonly IOptionSemanticValidator _validator; + + public EffectiveConfigSemanticValidationService(IOptionSemanticValidator validator) + { + _validator = validator; + } + + /// + public IReadOnlyList Validate(IReadOnlyList changes) + { + var issues = new List(); + 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 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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/IEffectiveConfigSemanticValidationService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/IEffectiveConfigSemanticValidationService.cs new file mode 100644 index 0000000..1e674e1 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/IEffectiveConfigSemanticValidationService.cs @@ -0,0 +1,15 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; + +/// +/// 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 (). +/// +public interface IEffectiveConfigSemanticValidationService : IApplicationSingleton +{ + /// Validates each change using option semantics. Returns all issues (errors and warnings). + IReadOnlyList Validate(IReadOnlyList changes); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AddressSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AddressSemanticHandler.cs new file mode 100644 index 0000000..ea18145 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/AddressSemanticHandler.cs @@ -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; + +/// +/// Specialized semantic behavior for address values. +/// Validates the /domain[/domain...]/ip structure and accepts empty or # address forms. +/// +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); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DefaultMultiValueEditBehavior.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DefaultMultiValueEditBehavior.cs index 45e1f98..75e3b75 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DefaultMultiValueEditBehavior.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DefaultMultiValueEditBehavior.cs @@ -1,11 +1,10 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; /// -/// 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. /// public sealed class DefaultMultiValueEditBehavior : IMultiValueEditBehavior { - public string Placeholder => "Value"; public bool AllowDuplicates => true; public string Normalize(string input) => (input ?? "").Trim(); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DelegateMultiValueOptionValidator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DelegateMultiValueOptionValidator.cs index 96cfe9b..64675ad 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DelegateMultiValueOptionValidator.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DelegateMultiValueOptionValidator.cs @@ -1,11 +1,9 @@ -using DnsmasqWebUI.Infrastructure.Helpers.Config; - namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; /// -/// Adapts a semantics multi-item validator delegate to . +/// Adapts a simple per-item validation delegate to . /// -public sealed class DelegateMultiValueOptionValidator(EffectiveConfigMultiItemValidator validate) +public sealed class DelegateMultiValueOptionValidator(Func validate) : IMultiValueOptionValidator { public string? ValidateItem(string normalized, IReadOnlyList current, int? editIndex = null) => diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DistinctMultiValueEditBehavior.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DistinctMultiValueEditBehavior.cs new file mode 100644 index 0000000..69531d1 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DistinctMultiValueEditBehavior.cs @@ -0,0 +1,12 @@ +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Generic edit behavior for multi-value options that should reject duplicate items +/// while using the default trim-based normalization. +/// +public sealed class DistinctMultiValueEditBehavior : IMultiValueEditBehavior +{ + public bool AllowDuplicates => false; + + public string Normalize(string input) => (input ?? "").Trim(); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueEditBehavior.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueEditBehavior.cs index ab69081..8c910c3 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueEditBehavior.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueEditBehavior.cs @@ -1,13 +1,10 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; /// -/// 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. /// public interface IMultiValueEditBehavior { - /// Placeholder text for the add/edit input. - string Placeholder { get; } - /// Whether duplicate values are allowed in the list. bool AllowDuplicates { get; } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IOptionSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IOptionSemanticHandler.cs new file mode 100644 index 0000000..c3e3ea9 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IOptionSemanticHandler.cs @@ -0,0 +1,20 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// 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 (). +/// +public interface IOptionSemanticHandler : IApplicationMultiSingleton +{ + /// True when this handler owns semantic behavior for the given option. + bool CanHandle(string optionName); + + /// Validates a single-value option. Returns an error message or null if valid. + string? ValidateSingle(object? value); + + /// Validates one item in a multi-value option. Returns an error message or null if valid. + string? ValidateMultiItem(string? value); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IOptionSemanticValidator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IOptionSemanticValidator.cs new file mode 100644 index 0000000..cb14616 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IOptionSemanticValidator.cs @@ -0,0 +1,18 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Central validator for effective-config option values driven by . +/// Used by the registry for field validation and by the pre-save semantic validation pass. +/// Registered as singleton via assembly scanning (). +/// +public interface IOptionSemanticValidator : IApplicationSingleton +{ + /// Validates a single-value field. Returns error message or null if valid. + string? ValidateSingle(string optionName, object? value, OptionValidationSemantics semantics); + + /// Validates one item in a multi-value list. Returns error message or null if valid. + string? ValidateMultiItem(string optionName, string? value, OptionValidationSemantics semantics); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/LeasequerySemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/LeasequerySemanticHandler.cs new file mode 100644 index 0000000..ea61507 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/LeasequerySemanticHandler.cs @@ -0,0 +1,31 @@ +using System.Net; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for leasequery values. +/// Each item is either key-only or an IP address with an optional numeric prefix. +/// +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; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ListenAddressMultiValidator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ListenAddressMultiValidator.cs deleted file mode 100644 index 6c8ee84..0000000 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ListenAddressMultiValidator.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Net; -using System.Net.Sockets; - -namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; - -/// -/// Validates listen-address option values: IPv4 or IPv6 address only. -/// -public sealed class ListenAddressMultiValidator : IMultiValueOptionValidator -{ - public string? ValidateItem(string normalized, IReadOnlyList 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; - } -} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/OptionSemanticValidator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/OptionSemanticValidator.cs new file mode 100644 index 0000000..6d9c253 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/OptionSemanticValidator.cs @@ -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; + +/// +/// Central validator for effective-config values. Specialized handlers are consulted first; +/// otherwise validation falls back to generic -based rules. +/// +public sealed class OptionSemanticValidator : IOptionSemanticValidator +{ + private readonly IReadOnlyList _handlers; + + public OptionSemanticValidator(IEnumerable? handlers = null) + { + _handlers = (handlers ?? Array.Empty()).ToList(); + } + + /// + 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); + } + + /// + 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...)."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RevServerSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RevServerSemanticHandler.cs new file mode 100644 index 0000000..e690414 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/RevServerSemanticHandler.cs @@ -0,0 +1,54 @@ +using System.Net; +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Specialized semantic behavior for rev-server values. +/// Validates the leading reverse network target and basic presence of an upstream server segment when provided. +/// +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}."; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiBehavior.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiBehavior.cs deleted file mode 100644 index ff1ae3c..0000000 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiBehavior.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; - -/// -/// Edit behavior for the server multi-value option: placeholder, no duplicates, normalize (trim). -/// -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(); -} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiValidator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiValidator.cs deleted file mode 100644 index 95a735c..0000000 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiValidator.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using System.Text.RegularExpressions; - -namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; - -/// -/// Validates server option values: IP address or hostname. Duplicate check is done by the editor when AllowDuplicates is false. -/// -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 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."; - } -} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerSemanticHandler.cs new file mode 100644 index 0000000..a02c84d --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerSemanticHandler.cs @@ -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; + +/// +/// Specialized semantic behavior for server values. +/// Supports plain upstream server values and domain-scoped /domain/.../server forms. +/// +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 '.'); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SpecialOptionValidators.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SpecialOptionValidators.cs deleted file mode 100644 index 7a89a80..0000000 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SpecialOptionValidators.cs +++ /dev/null @@ -1,79 +0,0 @@ -using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; - -namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; - -/// -/// 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. -/// -public static class SpecialOptionValidators -{ - /// use-stale-cache: unset, key-only, or non-negative integer. - 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."; - } - - /// add-mac: unset, key-only, or 'base64' or 'text'. - 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'."; - } - - /// add-subnet: unset, key-only, or value (permissive; format not strictly validated). - public static string? ValidateAddSubnet(object? value) - { - if (value is null) return null; - return null; // accept any string for now - } - - /// umbrella: unset, key-only, or token list (permissive). - 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 - } - - /// connmark-allowlist-enable: key-only or optional mask (decimal uint or 0x hex). - 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...)."; - } - - /// dnssec-check-unsigned: key-only (enable check) or 'no' (disable). - 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'."; - } - - /// Per-item validation for leasequery: key-only or IP[/prefix]. - 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; - } -} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Registration/Abstractions/IApplicationMultiSingleton.cs b/src/DnsmasqWebUI/Infrastructure/Services/Registration/Abstractions/IApplicationMultiSingleton.cs new file mode 100644 index 0000000..a9db881 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Registration/Abstractions/IApplicationMultiSingleton.cs @@ -0,0 +1,9 @@ +namespace DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + +/// +/// Marker interface for application services that are registered as singleton collections. +/// Used by assembly scanning for interfaces that intentionally have multiple implementations. +/// +public interface IApplicationMultiSingleton +{ +} diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs index f32c7f7..021ecfa 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs @@ -37,6 +37,8 @@ public record EffectiveConfigSaveResult( public const string WriteFailed = "write_failed"; /// Save blocked before write because one or more changed options require dnsmasq capabilities not present in this build. public const string UnsupportedCapabilities = "unsupported_capabilities"; + /// Save blocked before write because semantic validation failed (invalid values). + public const string SemanticValidationFailed = "semantic_validation_failed"; public const string ValidateFailed = "validate_failed"; public const string RestartFailed = "restart_failed"; public const string UnsupportedVersion = "unsupported_version"; diff --git a/src/DnsmasqWebUI/Program.cs b/src/DnsmasqWebUI/Program.cs index cf1b18c..1a7b4d4 100644 --- a/src/DnsmasqWebUI/Program.cs +++ b/src/DnsmasqWebUI/Program.cs @@ -1,6 +1,8 @@ using DnsmasqWebUI.Components; using DnsmasqWebUI.Infrastructure.Realtime.Hubs; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Extensions.DependencyInjection; using DnsmasqWebUI.Extensions.Hosting;