Effective config: no-0x20 precedence, write semantics, validators, strip-mac/strip-subnet

- ConfigSetCache: no-0x20-encode wins per dnsmasq docs; effective state and source attribution updated
- ReadonlyConfigCommandGenerator: use EffectiveConfigWriteSemantics for inverse-pair and key-only-or-value
- SpecialOptionValidators for UseStaleCache, AddMac, AddSubnet, Umbrella; wired via RegisterValidatedSingle
- OptionSemantics + EffectiveConfigWriteSemantics (GetBehavior/GetInversePairKeys); remove EffectiveConfigWriteBehaviorMap
- strip-mac / strip-subnet: full support in EffectiveDnsmasqConfig, sources, cache, sections, field builder, registry, tests
- Multi-value editor: toasts and block commit on validation error; 2s toast dedupe
- Do0x20 view label comment + Do0x20EncodeDisplayLabelTests
- DnsmasqOptionTooltips + extract-option-help.sh for strip-mac/strip-subnet; regenerated option-help HTML
This commit is contained in:
2026-03-09 00:17:29 +10:00
parent d910688742
commit 9e42a4e2df
24 changed files with 788 additions and 106 deletions
+2
View File
@@ -216,7 +216,9 @@ OPTION_KEYS = [
"dumpmask", "dumpmask",
"use-stale-cache", "use-stale-cache",
"add-mac", "add-mac",
"strip-mac",
"add-subnet", "add-subnet",
"strip-subnet",
"umbrella", "umbrella",
"quiet-dhcp", "quiet-dhcp",
"quiet-dhcp6", "quiet-dhcp6",
@@ -0,0 +1,137 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests effective-config resolution in ConfigSetCache (BuildEffectiveConfig / BuildEffectiveConfigSources).
/// Verifies no-0x20-encode precedence and source attribution per dnsmasq docs.
/// </summary>
public class ConfigSetCacheEffectiveConfigTests
{
private static (string Dir, string MainPath, string ManagedPath, ConfigSetCache Cache) CreateCacheWithManaged(string managedContent)
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cache-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, managedContent);
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
return (dir, mainPath, managedPath, cache);
}
[Fact]
public async Task Do0x20_BothPresent_No0x20Wins()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("do-0x20-encode\nno-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_OnlyDo_Enabled()
{
var (dir, _, _, cache) = CreateCacheWithManaged("do-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Enabled, snapshot.Config.Do0x20EncodeState);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_OnlyNo_Disabled()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("no-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_Neither_Default()
{
var (dir, _, _, cache) = CreateCacheWithManaged("port=53\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Default, snapshot.Config.Do0x20EncodeState);
Assert.Null(snapshot.Sources.Do0x20Encode);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_SourceAttribution_WhenBothPresent_IsNo0x20Line()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("no-0x20-encode\ndo-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task StripFlags_ParseState_And_Source()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("strip-mac\nstrip-subnet\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.StripMac);
Assert.True(snapshot.Config.StripSubnet);
Assert.NotNull(snapshot.Sources.StripMac);
Assert.NotNull(snapshot.Sources.StripSubnet);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.StripMac!.FilePath, StringComparer.OrdinalIgnoreCase);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.StripSubnet!.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
}
@@ -86,6 +86,79 @@ public class DnsmasqConfigServiceApplyChangesTests
} }
} }
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_StripMacTrue_WritesBareFlagLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.StripMac, false, true, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.Contains(lines, l => l == DnsmasqConfKeys.StripMac);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_StripSubnetFalse_RemovesFlagLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, "strip-subnet\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.StripSubnet, true, false, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.DoesNotContain(lines, l => l == DnsmasqConfKeys.StripSubnet || l.StartsWith($"{DnsmasqConfKeys.StripSubnet}=", StringComparison.Ordinal));
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact] [Fact]
public async Task ApplyEffectiveConfigChangesAsync_KeyOnlyOrValue_KeyOnly_WritesBareKey() public async Task ApplyEffectiveConfigChangesAsync_KeyOnlyOrValue_KeyOnly_WritesBareKey()
{ {
@@ -157,4 +230,249 @@ public class DnsmasqConfigServiceApplyChangesTests
Directory.Delete(dir, recursive: true); Directory.Delete(dir, recursive: true);
} }
} }
[Fact]
public async Task ApplyChanges_Do0x20_Default_RemovesBothLines()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, "do-0x20-encode\nno-0x20-encode\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Disabled, ExplicitToggleState.Default, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.DoesNotContain(lines, l => l == "do-0x20-encode" || l == "no-0x20-encode");
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Do0x20_Disabled_WritesNo0x20Encode()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Disabled, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("no-0x20-encode", content);
Assert.DoesNotContain("do-0x20-encode", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_UseStaleCache_Value_WritesKeyEqualsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "60", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("use-stale-cache=60", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_AddMac_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddMac, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("add-mac", content);
Assert.DoesNotContain("add-mac=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_AddMac_ValueBase64_WritesKeyEqualsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddMac, null, "base64", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("add-mac=base64", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_AddSubnet_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddSubnet, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("add-subnet", content);
Assert.DoesNotContain("add-subnet=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Umbrella_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.Umbrella, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("umbrella", content);
Assert.DoesNotContain("umbrella=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
} }
@@ -0,0 +1,32 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Documents and guards the Do0x20 display label rule: Default and Disabled both show "Disabled" (product choice).
/// Do not "fix" the display to show "Default" for unset without updating this test and the component comment.
/// </summary>
public class Do0x20EncodeDisplayLabelTests
{
/// <summary>Same logic as Do0x20EncodeDisplay.razor view mode: Enabled → "Enabled", else "Disabled".</summary>
private static string GetDisplayLabel(ExplicitToggleState state) =>
state == ExplicitToggleState.Enabled ? "Enabled" : "Disabled";
[Fact]
public void Do0x20_Default_ShowsDisabled()
{
Assert.Equal("Disabled", GetDisplayLabel(ExplicitToggleState.Default));
}
[Fact]
public void Do0x20_Disabled_ShowsDisabled()
{
Assert.Equal("Disabled", GetDisplayLabel(ExplicitToggleState.Disabled));
}
[Fact]
public void Do0x20_Enabled_ShowsEnabled()
{
Assert.Equal("Enabled", GetDisplayLabel(ExplicitToggleState.Enabled));
}
}
@@ -0,0 +1,22 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Ensures effective-config editable options list includes supported cache flags.
/// </summary>
public class EffectiveConfigEditableOptionsTests
{
/// <summary>
/// strip-mac and strip-subnet are supported in the effective-config UI and should be listed in cache options.
/// </summary>
[Fact]
public void EditableOptions_Include_StripMac_And_StripSubnet()
{
var allOptionNames = EffectiveConfigSections.GetSectionsInOrder()
.SelectMany(t => EffectiveConfigSections.GetOptionsInSection(t.SectionId))
.ToHashSet(StringComparer.Ordinal);
Assert.Contains(DnsmasqConfKeys.StripMac, allOptionNames);
Assert.Contains(DnsmasqConfKeys.StripSubnet, allOptionNames);
}
}
@@ -168,7 +168,9 @@ public class EffectiveDnsmasqConfigTests
DhcpPxeVendor: null, DhcpPxeVendor: null,
UseStaleCache: null, UseStaleCache: null,
AddMac: null, AddMac: null,
StripMac: false,
AddSubnet: null, AddSubnet: null,
StripSubnet: false,
Umbrella: null, Umbrella: null,
Do0x20EncodeState: ExplicitToggleState.Default, Do0x20EncodeState: ExplicitToggleState.Default,
Conntrack: false Conntrack: false
@@ -11,6 +11,7 @@
} }
else else
{ {
@* Intentionally show both Default and Disabled as "Disabled" (product choice: collapse unset and off). Do not "fix" to show "Default" for unset. *@
@(GetState() == ExplicitToggleState.Enabled ? "Enabled" : "Disabled") @(GetState() == ExplicitToggleState.Enabled ? "Enabled" : "Disabled")
} }
@@ -3,7 +3,9 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject INotificationService NotificationService
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true"> <div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
<ol class="ec-field-value-list"> <ol class="ec-field-value-list">
@@ -16,20 +18,15 @@
var itemReadonly = item.Source?.IsReadOnly == true; var itemReadonly = item.Source?.IsReadOnly == true;
var rowIndex = i; var rowIndex = i;
var rowValue = item.Value; var rowValue = item.Value;
var rowInlineError = GetInlineErrorForIndex(rowIndex);
<li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId"> <li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
@if (_editingIndex == rowIndex && !itemReadonly) @if (_editingIndex == rowIndex && !itemReadonly)
{ {
<div class="ec-multi-list-row-controls"> <div class="ec-multi-list-row-controls">
<div class="ec-multi-list-input-group"> <div class="ec-multi-list-input-group">
<input class="form-control form-control-sm ec-multi-list-input @(rowInlineError != null ? "is-invalid" : "")" <input class="form-control form-control-sm ec-multi-list-input"
placeholder="@(Behavior?.Placeholder ?? "")" placeholder="@(Behavior?.Placeholder ?? "")"
@bind="_draftValue" @bind:event="oninput" /> @bind="_draftValue" @bind:event="oninput" />
@if (rowInlineError != null)
{
<div class="ec-multi-list-invalid-feedback invalid-feedback d-block">@rowInlineError</div>
}
</div> </div>
<span class="status-badge-inline"> <span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true" <StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
@@ -81,13 +78,9 @@
{ {
<div class="ec-multi-list-row-controls"> <div class="ec-multi-list-row-controls">
<div class="ec-multi-list-input-group"> <div class="ec-multi-list-input-group">
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input @(AddInlineError != null ? "is-invalid" : "")" <input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input"
placeholder="@(Behavior?.Placeholder ?? "New value")" placeholder="@(Behavior?.Placeholder ?? "New value")"
@bind="_draftValue" @bind:event="oninput" /> @bind="_draftValue" @bind:event="oninput" />
@if (AddInlineError != null)
{
<div class="ec-multi-list-invalid-feedback invalid-feedback d-block">@AddInlineError</div>
}
</div> </div>
<span class="status-badge-inline"> <span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true" <StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
@@ -125,24 +118,20 @@
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; } [Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
private const string AddItemKey = "add-item"; private const string AddItemKey = "add-item";
private static readonly TimeSpan ValidationToastDedupeWindow = TimeSpan.FromSeconds(2);
private int? _editingIndex; private int? _editingIndex;
private bool _addingNew; private bool _addingNew;
private string _draftValue = ""; private string _draftValue = "";
private bool _didAutoOpenAdd; private bool _didAutoOpenAdd;
private bool _shouldFocusAddInput; private bool _shouldFocusAddInput;
private ElementReference _addNewInputRef; private ElementReference _addNewInputRef;
private int? _inlineErrorIndex; private string? _lastValidationErrorMessage;
private string? _inlineErrorMessage; private DateTime _lastValidationErrorUtc;
private List<ValueWithSource> _displayItems = new(); private List<ValueWithSource> _displayItems = new();
private IReadOnlyList<string> _currentValues => _displayItems.Select(x => x.Value).ToList(); private IReadOnlyList<string> _currentValues => _displayItems.Select(x => x.Value).ToList();
private string? AddInlineError => _addingNew && _inlineErrorIndex is null ? _inlineErrorMessage : null;
private string? GetInlineErrorForIndex(int? editIndex)
{
return _editingIndex == editIndex && _inlineErrorIndex == editIndex ? _inlineErrorMessage : null;
}
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
_displayItems = (SourceItems ?? Array.Empty<ValueWithSource>()).ToList(); _displayItems = (SourceItems ?? Array.Empty<ValueWithSource>()).ToList();
@@ -176,7 +165,6 @@
_addingNew = false; _addingNew = false;
_editingIndex = index; _editingIndex = index;
_draftValue = current; _draftValue = current;
ClearInlineError();
} }
private void BeginAdd() private void BeginAdd()
@@ -185,7 +173,6 @@
_addingNew = true; _addingNew = true;
_draftValue = ""; _draftValue = "";
_shouldFocusAddInput = true; _shouldFocusAddInput = true;
ClearInlineError();
} }
private void CancelInline() private void CancelInline()
@@ -193,7 +180,18 @@
_editingIndex = null; _editingIndex = null;
_addingNew = false; _addingNew = false;
_draftValue = ""; _draftValue = "";
ClearInlineError(); _lastValidationErrorMessage = null;
}
private void ShowValidationError(string message)
{
var now = DateTime.UtcNow;
if (string.Equals(_lastValidationErrorMessage, message, StringComparison.Ordinal) &&
(now - _lastValidationErrorUtc) < ValidationToastDedupeWindow)
return;
_lastValidationErrorMessage = message;
_lastValidationErrorUtc = now;
NotificationService.ShowError(message);
} }
private async Task SaveEdit(int index) private async Task SaveEdit(int index)
@@ -206,24 +204,23 @@
var normalized = Behavior?.Normalize(raw) ?? raw.Trim(); var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
if (normalized.Length == 0) if (normalized.Length == 0)
{ {
SetInlineError(index, "Value cannot be empty."); ShowValidationError("Value cannot be empty.");
return; return;
} }
var validationError = Validator?.ValidateItem(normalized, current, index); var validationError = Validator?.ValidateItem(normalized, current, index);
if (validationError != null) if (validationError != null)
{ {
SetInlineError(index, validationError); ShowValidationError(validationError);
return; return;
} }
if (Behavior?.AllowDuplicates == false && next.Where((v, i) => i != index).Any(v => string.Equals(v, normalized, StringComparison.Ordinal))) if (Behavior?.AllowDuplicates == false && next.Where((v, i) => i != index).Any(v => string.Equals(v, normalized, StringComparison.Ordinal)))
{ {
SetInlineError(index, "Duplicate values are not allowed."); ShowValidationError("Duplicate values are not allowed.");
return; return;
} }
ClearInlineError();
next[index] = normalized; next[index] = normalized;
_displayItems[index] = _displayItems[index] with { Value = normalized }; _displayItems[index] = _displayItems[index] with { Value = normalized };
await OnValuesChanged.InvokeAsync(next); await OnValuesChanged.InvokeAsync(next);
@@ -234,23 +231,26 @@
{ {
var raw = _draftValue ?? ""; var raw = _draftValue ?? "";
var normalized = Behavior?.Normalize(raw) ?? raw.Trim(); var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
if (normalized.Length == 0) return; if (normalized.Length == 0)
{
ShowValidationError("Value cannot be empty.");
return;
}
var current = _currentValues; var current = _currentValues;
var validationError = Validator?.ValidateItem(normalized, current, null); var validationError = Validator?.ValidateItem(normalized, current, null);
if (validationError != null) if (validationError != null)
{ {
SetInlineError(null, validationError); ShowValidationError(validationError);
return; return;
} }
if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal)) if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal))
{ {
SetInlineError(null, "Duplicate values are not allowed."); ShowValidationError("Duplicate values are not allowed.");
return; return;
} }
ClearInlineError();
var next = current.ToList(); var next = current.ToList();
next.Add(normalized); next.Add(normalized);
_displayItems.Add(new ValueWithSource(normalized, null)); _displayItems.Add(new ValueWithSource(normalized, null));
@@ -258,19 +258,6 @@
CancelInline(); CancelInline();
} }
private void SetInlineError(int? itemIndex, string message)
{
_inlineErrorIndex = itemIndex;
_inlineErrorMessage = message;
StateHasChanged();
}
private void ClearInlineError()
{
_inlineErrorIndex = null;
_inlineErrorMessage = null;
}
private async Task RemoveAt(int index) private async Task RemoveAt(int index)
{ {
if (index < 0 || index >= _displayItems.Count) return; if (index < 0 || index >= _displayItems.Count) return;
@@ -284,7 +284,7 @@
private static string FormatValue(object? v, string? optionName = null) private static string FormatValue(object? v, string? optionName = null)
{ {
if (optionName != null && EffectiveConfigWriteBehaviorMap.GetBehavior(optionName) == EffectiveConfigWriteBehavior.KeyOnlyOrValue) if (optionName != null && EffectiveConfigWriteSemantics.GetBehavior(optionName) == EffectiveConfigWriteBehavior.KeyOnlyOrValue)
return FormatKeyOnlyOrValue(v); return FormatKeyOnlyOrValue(v);
if (v == null) return "(not set)"; if (v == null) return "(not set)";
if (v is bool b) return b ? "on" : "off"; if (v is bool b) return b ? "on" : "off";
@@ -166,7 +166,9 @@ public static class DnsmasqOptionTooltips
[DnsmasqConfKeys.Dumpmask] = "Mask for cache dump (which data to include).", [DnsmasqConfKeys.Dumpmask] = "Mask for cache dump (which data to include).",
[DnsmasqConfKeys.UseStaleCache] = "Use stale cache when upstream is unavailable. Off, On (key-only), or custom seconds.", [DnsmasqConfKeys.UseStaleCache] = "Use stale cache when upstream is unavailable. Off, On (key-only), or custom seconds.",
[DnsmasqConfKeys.AddMac] = "Add MAC address to DNS queries. Off, On (key-only), or custom value.", [DnsmasqConfKeys.AddMac] = "Add MAC address to DNS queries. Off, On (key-only), or custom value.",
[DnsmasqConfKeys.StripMac] = "Strip MAC address information from forwarded DNS queries.",
[DnsmasqConfKeys.AddSubnet] = "Add subnet to DNS queries. Off, On (key-only), or custom value.", [DnsmasqConfKeys.AddSubnet] = "Add subnet to DNS queries. Off, On (key-only), or custom value.",
[DnsmasqConfKeys.StripSubnet] = "Strip subnet (ECS) information from forwarded DNS queries.",
[DnsmasqConfKeys.Umbrella] = "Cisco Umbrella device identity. Off, On (key-only), or custom value.", [DnsmasqConfKeys.Umbrella] = "Cisco Umbrella device identity. Off, On (key-only), or custom value.",
// --- Process & networking --- // --- Process & networking ---
@@ -194,7 +194,9 @@ public static class EffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.Dumpmask, status, s => Config(s)?.Dumpmask, s => Sources(s)?.Dumpmask, null); list.AddDescriptor(registry, DnsmasqConfKeys.Dumpmask, status, s => Config(s)?.Dumpmask, s => Sources(s)?.Dumpmask, null);
list.AddDescriptor(registry, DnsmasqConfKeys.UseStaleCache, status, s => Config(s)?.UseStaleCache, s => Sources(s)?.UseStaleCache, null); list.AddDescriptor(registry, DnsmasqConfKeys.UseStaleCache, status, s => Config(s)?.UseStaleCache, s => Sources(s)?.UseStaleCache, null);
list.AddDescriptor(registry, DnsmasqConfKeys.AddMac, status, s => Config(s)?.AddMac, s => Sources(s)?.AddMac, null); list.AddDescriptor(registry, DnsmasqConfKeys.AddMac, status, s => Config(s)?.AddMac, s => Sources(s)?.AddMac, null);
list.AddDescriptor(registry, DnsmasqConfKeys.StripMac, status, s => Config(s)?.StripMac, s => Sources(s)?.StripMac, null);
list.AddDescriptor(registry, DnsmasqConfKeys.AddSubnet, status, s => Config(s)?.AddSubnet, s => Sources(s)?.AddSubnet, null); list.AddDescriptor(registry, DnsmasqConfKeys.AddSubnet, status, s => Config(s)?.AddSubnet, s => Sources(s)?.AddSubnet, null);
list.AddDescriptor(registry, DnsmasqConfKeys.StripSubnet, status, s => Config(s)?.StripSubnet, s => Sources(s)?.StripSubnet, null);
list.AddDescriptor(registry, DnsmasqConfKeys.Umbrella, status, s => Config(s)?.Umbrella, s => Sources(s)?.Umbrella, null); list.AddDescriptor(registry, DnsmasqConfKeys.Umbrella, status, s => Config(s)?.Umbrella, s => Sources(s)?.Umbrella, null);
list.AddDescriptor(registry, DnsmasqConfKeys.NegTtl, status, s => Config(s)?.NegTtl, s => Sources(s)?.NegTtl, null); list.AddDescriptor(registry, DnsmasqConfKeys.NegTtl, status, s => Config(s)?.NegTtl, s => Sources(s)?.NegTtl, null);
list.AddDescriptor(registry, DnsmasqConfKeys.MaxTtl, status, s => Config(s)?.MaxTtl, s => Sources(s)?.MaxTtl, null); list.AddDescriptor(registry, DnsmasqConfKeys.MaxTtl, status, s => Config(s)?.MaxTtl, s => Sources(s)?.MaxTtl, null);
@@ -199,7 +199,9 @@ public static class EffectiveConfigOptionKindMap
[DnsmasqConfKeys.DhcpOptionPxe] = EffectiveConfigFieldKind.Multi, [DnsmasqConfKeys.DhcpOptionPxe] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.UseStaleCache] = EffectiveConfigFieldKind.Single, [DnsmasqConfKeys.UseStaleCache] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.AddMac] = EffectiveConfigFieldKind.Single, [DnsmasqConfKeys.AddMac] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.StripMac] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.AddSubnet] = EffectiveConfigFieldKind.Single, [DnsmasqConfKeys.AddSubnet] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.StripSubnet] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.Umbrella] = EffectiveConfigFieldKind.Single, [DnsmasqConfKeys.Umbrella] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.Do0x20Encode] = EffectiveConfigFieldKind.Single, [DnsmasqConfKeys.Do0x20Encode] = EffectiveConfigFieldKind.Single,
}; };
@@ -386,7 +388,9 @@ public static class EffectiveConfigParserBehaviorMap
[DnsmasqConfKeys.DhcpOptionPxe] = EffectiveConfigParserBehavior.Multi, [DnsmasqConfKeys.DhcpOptionPxe] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.UseStaleCache] = EffectiveConfigParserBehavior.LastWins, [DnsmasqConfKeys.UseStaleCache] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.AddMac] = EffectiveConfigParserBehavior.LastWins, [DnsmasqConfKeys.AddMac] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.StripMac] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.AddSubnet] = EffectiveConfigParserBehavior.LastWins, [DnsmasqConfKeys.AddSubnet] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.StripSubnet] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.Umbrella] = EffectiveConfigParserBehavior.LastWins, [DnsmasqConfKeys.Umbrella] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.Do0x20Encode] = EffectiveConfigParserBehavior.Flag, [DnsmasqConfKeys.Do0x20Encode] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.No0x20Encode] = EffectiveConfigParserBehavior.Flag, [DnsmasqConfKeys.No0x20Encode] = EffectiveConfigParserBehavior.Flag,
@@ -397,48 +401,3 @@ public static class EffectiveConfigParserBehaviorMap
BehaviorByOptionName.TryGetValue(optionName, out var b) ? b : EffectiveConfigParserBehavior.LastWins; BehaviorByOptionName.TryGetValue(optionName, out var b) ? b : EffectiveConfigParserBehavior.LastWins;
} }
/// <summary>
/// How to serialize an option when writing the managed config file.
/// Used by DnsmasqConfigService so KeyOnlyOrValue and InversePair options serialize correctly.
/// </summary>
public enum EffectiveConfigWriteBehavior
{
Flag,
SingleValue,
MultiValue,
KeyOnlyOrValue,
InversePair,
}
/// <summary>
/// Maps dnsmasq option names to write behavior. Options not in the map default to SingleValue
/// (or are inferred from parser behavior in the service). Add entries for KeyOnlyOrValue and InversePair options.
/// </summary>
public static class EffectiveConfigWriteBehaviorMap
{
private static readonly IReadOnlyDictionary<string, EffectiveConfigWriteBehavior> BehaviorByOptionName =
new Dictionary<string, EffectiveConfigWriteBehavior>(StringComparer.Ordinal)
{
[DnsmasqConfKeys.Conntrack] = EffectiveConfigWriteBehavior.Flag,
[DnsmasqConfKeys.UseStaleCache] = EffectiveConfigWriteBehavior.KeyOnlyOrValue,
[DnsmasqConfKeys.AddMac] = EffectiveConfigWriteBehavior.KeyOnlyOrValue,
[DnsmasqConfKeys.AddSubnet] = EffectiveConfigWriteBehavior.KeyOnlyOrValue,
[DnsmasqConfKeys.Umbrella] = EffectiveConfigWriteBehavior.KeyOnlyOrValue,
[DnsmasqConfKeys.Do0x20Encode] = EffectiveConfigWriteBehavior.InversePair,
};
/// <summary>For InversePair options: (key for "enabled", key for "disabled"). Null if not an InversePair or unknown.</summary>
private static readonly IReadOnlyDictionary<string, (string KeyA, string KeyB)> InversePairKeysByOptionName =
new Dictionary<string, (string, string)>(StringComparer.Ordinal)
{
[DnsmasqConfKeys.Do0x20Encode] = (DnsmasqConfKeys.Do0x20Encode, DnsmasqConfKeys.No0x20Encode),
};
/// <summary>Returns write behavior for the option; defaults to SingleValue if unknown.</summary>
public static EffectiveConfigWriteBehavior GetBehavior(string optionName) =>
BehaviorByOptionName.TryGetValue(optionName, out var b) ? b : EffectiveConfigWriteBehavior.SingleValue;
/// <summary>Returns the pair of config keys (enabled, disabled) for an InversePair option; null otherwise.</summary>
public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null;
}
@@ -178,7 +178,9 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.Dumpmask, DnsmasqConfKeys.Dumpmask,
DnsmasqConfKeys.UseStaleCache, DnsmasqConfKeys.UseStaleCache,
DnsmasqConfKeys.AddMac, DnsmasqConfKeys.AddMac,
DnsmasqConfKeys.StripMac,
DnsmasqConfKeys.AddSubnet, DnsmasqConfKeys.AddSubnet,
DnsmasqConfKeys.StripSubnet,
DnsmasqConfKeys.Umbrella, DnsmasqConfKeys.Umbrella,
]), ]),
new SectionDef(SectionProcess, "Process & networking", [ new SectionDef(SectionProcess, "Process & networking", [
@@ -0,0 +1,77 @@
using System.Collections.Generic;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// <summary>
/// Single source of truth for options with special parse/write/validation semantics.
/// Prevents drift between parse, write, hint, and validation for UseStaleCache, AddMac, AddSubnet, Umbrella, Do0x20Encode.
/// Inverse-pair key names are not stored here; see <see cref="EffectiveConfigSpecialOptionSemantics.GetInversePairKeys"/>.
/// </summary>
public sealed record OptionSemantics(
string OptionName,
EffectiveConfigParserBehavior ParserBehavior,
EffectiveConfigWriteBehavior WriteBehavior,
EffectiveConfigSingleValueValidator? Validator
);
/// <summary>
/// Lookup for special-option semantics. Used by EffectiveConfigWriteSemantics and registry for validators.
/// Inverse-pair options (e.g. Do0x20Encode) have their (enabled key, disabled key) in a separate table.
/// </summary>
public static class EffectiveConfigSpecialOptionSemantics
{
private static readonly IReadOnlyDictionary<string, OptionSemantics> ByOptionName =
new Dictionary<string, OptionSemantics>(StringComparer.Ordinal)
{
[DnsmasqConfKeys.UseStaleCache] = new OptionSemantics(
DnsmasqConfKeys.UseStaleCache,
EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateUseStaleCache),
[DnsmasqConfKeys.AddMac] = new OptionSemantics(
DnsmasqConfKeys.AddMac,
EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateAddMac),
[DnsmasqConfKeys.AddSubnet] = new OptionSemantics(
DnsmasqConfKeys.AddSubnet,
EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateAddSubnet),
[DnsmasqConfKeys.Umbrella] = new OptionSemantics(
DnsmasqConfKeys.Umbrella,
EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateUmbrella),
[DnsmasqConfKeys.Do0x20Encode] = new OptionSemantics(
DnsmasqConfKeys.Do0x20Encode,
EffectiveConfigParserBehavior.Flag,
EffectiveConfigWriteBehavior.InversePair,
Validator: null),
};
/// <summary>Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints.</summary>
private static readonly IReadOnlyDictionary<string, (string KeyA, string KeyB)> InversePairKeysByOptionName =
new Dictionary<string, (string, string)>(StringComparer.Ordinal)
{
[DnsmasqConfKeys.Do0x20Encode] = (DnsmasqConfKeys.Do0x20Encode, DnsmasqConfKeys.No0x20Encode),
};
/// <summary>Returns semantics for a special option; null if not a special option.</summary>
public static OptionSemantics? TryGetSemantics(string optionName) =>
ByOptionName.TryGetValue(optionName, out var s) ? s : null;
/// <summary>Returns write behavior from semantics for special options; otherwise null (caller uses general map).</summary>
public static EffectiveConfigWriteBehavior? GetWriteBehavior(string optionName) =>
TryGetSemantics(optionName)?.WriteBehavior;
/// <summary>Returns the pair of config keys (enabled, disabled) for an InversePair option; null otherwise.</summary>
public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null;
/// <summary>Returns validator from semantics for special options; otherwise null.</summary>
public static EffectiveConfigSingleValueValidator? GetValidator(string optionName) =>
TryGetSemantics(optionName)?.Validator;
}
@@ -0,0 +1,40 @@
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// <summary>
/// How to serialize an option when writing the managed config file.
/// This models write intent, not parse behavior.
/// </summary>
public enum EffectiveConfigWriteBehavior
{
Flag,
SingleValue,
MultiValue,
KeyOnlyOrValue,
InversePair,
}
/// <summary>
/// Source of truth for write semantics used by managed-config writing and readonly hints.
/// Special options come from <see cref="EffectiveConfigSpecialOptionSemantics"/>; all others
/// derive from parser behavior (Flag/Multi/LastWins).
/// </summary>
public static class EffectiveConfigWriteSemantics
{
/// <summary>Returns write behavior for an option.</summary>
public static EffectiveConfigWriteBehavior GetBehavior(string optionName)
{
if (EffectiveConfigSpecialOptionSemantics.GetWriteBehavior(optionName) is { } special)
return special;
return EffectiveConfigParserBehaviorMap.GetBehavior(optionName) switch
{
EffectiveConfigParserBehavior.Flag => EffectiveConfigWriteBehavior.Flag,
EffectiveConfigParserBehavior.Multi => EffectiveConfigWriteBehavior.MultiValue,
_ => EffectiveConfigWriteBehavior.SingleValue,
};
}
/// <summary>Returns inverse-pair keys (enabled, disabled) for InversePair options; null otherwise.</summary>
public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
EffectiveConfigSpecialOptionSemantics.GetInversePairKeys(optionName);
}
@@ -5,22 +5,31 @@ namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// <summary> /// <summary>
/// Generates copyable commands or hints for readonly effective-config values: /// Generates copyable commands or hints for readonly effective-config values:
/// remove or edit the line in the source file, or override by adding to managed config. /// remove or edit the line in the source file, or override by adding to managed config.
/// Uses EffectiveConfigWriteSemantics so inverse-pair and key-only-or-value options are handled correctly.
/// </summary> /// </summary>
public static class ReadonlyConfigCommandGenerator public static class ReadonlyConfigCommandGenerator
{ {
/// <summary> /// <summary>
/// Suggested command to remove the option line from the readonly file (sed). User can copy and run with appropriate privileges. /// Suggested command to remove the option line from the readonly file (sed). User can copy and run with appropriate privileges.
/// Escapes the option name for a basic sed pattern (^option=\?.*$ or ^option$ for flags). /// For InversePair options, removes both keys (e.g. do-0x20-encode and no-0x20-encode).
/// </summary> /// </summary>
public static string GetRemoveLineCommand(ConfigValueSource source, string optionName) public static string GetRemoveLineCommand(ConfigValueSource source, string optionName)
{ {
if (string.IsNullOrEmpty(source.FilePath)) return ""; if (string.IsNullOrEmpty(source.FilePath)) return "";
var pathEscaped = source.FilePath.Replace("|", "\\|");
var writeBehavior = EffectiveConfigWriteSemantics.GetBehavior(optionName);
if (writeBehavior == EffectiveConfigWriteBehavior.InversePair)
{
var pair = EffectiveConfigWriteSemantics.GetInversePairKeys(optionName);
if (pair is null) return "";
var escapedA = EscapeForSedPattern(pair.Value.KeyA);
var escapedB = EscapeForSedPattern(pair.Value.KeyB);
return $"sed -i -e '|^{escapedA}$|d' -e '|^{escapedB}$|d' {pathEscaped}";
}
var escaped = EscapeForSedPattern(optionName); var escaped = EscapeForSedPattern(optionName);
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionName); var pattern = writeBehavior == EffectiveConfigWriteBehavior.Flag
var pattern = behavior == EffectiveConfigParserBehavior.Flag
? $"^{escaped}$" ? $"^{escaped}$"
: $"^{escaped}=.*$"; : $"^{escaped}=.*$";
var pathEscaped = source.FilePath.Replace("|", "\\|");
return $"sed -i '|{pattern}|d' {pathEscaped}"; return $"sed -i '|{pattern}|d' {pathEscaped}";
} }
@@ -34,12 +43,31 @@ public static class ReadonlyConfigCommandGenerator
/// <summary> /// <summary>
/// The line to add to managed config to override (e.g. "port=53" or "expand-hosts"). Empty if managed path not set. /// The line to add to managed config to override (e.g. "port=53" or "expand-hosts"). Empty if managed path not set.
/// Consults EffectiveConfigWriteSemantics: InversePair uses ExplicitToggleState (Enabled→KeyA, Disabled→KeyB, Default→remove);
/// KeyOnlyOrValue uses key-only or key=value; Flag uses key-only when true; other options use key=value fallback.
/// </summary> /// </summary>
public static string GetOverrideLine(string optionName, object? value, string? managedFilePath) public static string GetOverrideLine(string optionName, object? value, string? managedFilePath)
{ {
if (string.IsNullOrEmpty(managedFilePath)) return ""; if (string.IsNullOrEmpty(managedFilePath)) return "";
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionName); var writeBehavior = EffectiveConfigWriteSemantics.GetBehavior(optionName);
if (behavior == EffectiveConfigParserBehavior.Flag) if (writeBehavior == EffectiveConfigWriteBehavior.InversePair)
{
var pair = EffectiveConfigWriteSemantics.GetInversePairKeys(optionName);
if (pair is null || value is not ExplicitToggleState s) return "";
return s switch
{
ExplicitToggleState.Enabled => pair.Value.KeyA,
ExplicitToggleState.Disabled => pair.Value.KeyB,
_ => "" // Default means remove/unset
};
}
if (writeBehavior == EffectiveConfigWriteBehavior.KeyOnlyOrValue)
{
if (value is null) return "";
var str = value.ToString() ?? "";
return str.Length == 0 ? optionName : $"{optionName}={str}";
}
if (writeBehavior == EffectiveConfigWriteBehavior.Flag)
return value is bool b && b ? optionName : ""; return value is bool b && b ? optionName : "";
var v = ValueToConfString(value); var v = ValueToConfString(value);
return string.IsNullOrEmpty(v) ? optionName : $"{optionName}={v}"; return string.IsNullOrEmpty(v) ? optionName : $"{optionName}={v}";
@@ -452,13 +452,16 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var useStaleCache = useStaleCacheVal == null ? null : (string.IsNullOrWhiteSpace(useStaleCacheVal) ? "" : useStaleCacheVal.Trim()); var useStaleCache = useStaleCacheVal == null ? null : (string.IsNullOrWhiteSpace(useStaleCacheVal) ? "" : useStaleCacheVal.Trim());
var (addMacVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AddMac); var (addMacVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AddMac);
var addMac = addMacVal == null ? null : (string.IsNullOrWhiteSpace(addMacVal) ? "" : addMacVal.Trim()); var addMac = addMacVal == null ? null : (string.IsNullOrWhiteSpace(addMacVal) ? "" : addMacVal.Trim());
var stripMac = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.StripMac);
var (addSubnetVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AddSubnet); var (addSubnetVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AddSubnet);
var addSubnet = addSubnetVal == null ? null : (string.IsNullOrWhiteSpace(addSubnetVal) ? "" : addSubnetVal.Trim()); var addSubnet = addSubnetVal == null ? null : (string.IsNullOrWhiteSpace(addSubnetVal) ? "" : addSubnetVal.Trim());
var stripSubnet = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.StripSubnet);
var (umbrellaVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Umbrella); var (umbrellaVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Umbrella);
var umbrella = umbrellaVal == null ? null : (string.IsNullOrWhiteSpace(umbrellaVal) ? "" : umbrellaVal.Trim()); var umbrella = umbrellaVal == null ? null : (string.IsNullOrWhiteSpace(umbrellaVal) ? "" : umbrellaVal.Trim());
var do0x20 = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Do0x20Encode); var do0x20 = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Do0x20Encode);
var no0x20 = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.No0x20Encode); var no0x20 = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.No0x20Encode);
var do0x20EncodeState = do0x20 ? ExplicitToggleState.Enabled : (no0x20 ? ExplicitToggleState.Disabled : ExplicitToggleState.Default); // dnsmasq docs: no-0x20-encode overrides do-0x20-encode
var do0x20EncodeState = no0x20 ? ExplicitToggleState.Disabled : (do0x20 ? ExplicitToggleState.Enabled : ExplicitToggleState.Default);
var conntrack = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Conntrack); var conntrack = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Conntrack);
return new EffectiveDnsmasqConfig( return new EffectiveDnsmasqConfig(
@@ -480,7 +483,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
authTtl, ednsPacketMax, queryPort, portLimit, minPort, maxPort, logAsync, localService, dhcpLeaseMax, authTtl, ednsPacketMax, queryPort, portLimit, minPort, maxPort, logAsync, localService, dhcpLeaseMax,
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl, tftpRootPath, pxePrompt, enableDbus, enableUbus, fastDnsRetry, negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl, tftpRootPath, pxePrompt, enableDbus, enableUbus, fastDnsRetry,
dhcpScriptPath, mxTarget, dnsForwardMax, dumpfilePath, dumpmask, addCpeId, dnssecTimestamp, dnssecLimits, dhcpAlternatePort, dhcpDuid, dhcpLuascriptPath, dhcpScriptuser, dhcpPxeVendor, dhcpScriptPath, mxTarget, dnsForwardMax, dumpfilePath, dumpmask, addCpeId, dnssecTimestamp, dnssecLimits, dhcpAlternatePort, dhcpDuid, dhcpLuascriptPath, dhcpScriptuser, dhcpPxeVendor,
useStaleCache, addMac, addSubnet, umbrella, do0x20EncodeState, conntrack useStaleCache, addMac, stripMac, addSubnet, stripSubnet, umbrella, do0x20EncodeState, conntrack
); );
} }
@@ -654,11 +657,14 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var (_, dhcpPxeVendorSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpPxeVendor, managedFilePath); var (_, dhcpPxeVendorSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpPxeVendor, managedFilePath);
var (_, useStaleCacheSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.UseStaleCache, managedFilePath); var (_, useStaleCacheSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.UseStaleCache, managedFilePath);
var (_, addMacSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AddMac, managedFilePath); var (_, addMacSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AddMac, managedFilePath);
var (_, stripMacSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.StripMac, managedFilePath);
var (_, addSubnetSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AddSubnet, managedFilePath); var (_, addSubnetSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AddSubnet, managedFilePath);
var (_, stripSubnetSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.StripSubnet, managedFilePath);
var (_, umbrellaSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Umbrella, managedFilePath); var (_, umbrellaSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Umbrella, managedFilePath);
var (do0x20Set, do0x20EncodeSourceA) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Do0x20Encode, managedFilePath); var (do0x20Set, do0x20EncodeSourceA) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Do0x20Encode, managedFilePath);
var (no0x20Set, no0x20EncodeSourceB) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.No0x20Encode, managedFilePath); var (no0x20Set, no0x20EncodeSourceB) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.No0x20Encode, managedFilePath);
var do0x20EncodeSource = do0x20Set ? do0x20EncodeSourceA : (no0x20Set ? no0x20EncodeSourceB : null); // no-0x20-encode overrides do-0x20-encode; source reflects the effective directive
var do0x20EncodeSource = no0x20Set ? no0x20EncodeSourceB : (do0x20Set ? do0x20EncodeSourceA : null);
var (_, conntrackSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Conntrack, managedFilePath); var (_, conntrackSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Conntrack, managedFilePath);
return new EffectiveConfigSources( return new EffectiveConfigSources(
@@ -826,7 +832,9 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
dhcpPxeVendorSource, dhcpPxeVendorSource,
useStaleCacheSource, useStaleCacheSource,
addMacSource, addMacSource,
stripMacSource,
addSubnetSource, addSubnetSource,
stripSubnetSource,
umbrellaSource, umbrellaSource,
do0x20EncodeSource, do0x20EncodeSource,
conntrackSource conntrackSource
@@ -899,7 +907,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null,
TftpRootPath: null, PxePrompt: null, EnableDbus: null, EnableUbus: null, FastDnsRetry: null, TftpRootPath: null, PxePrompt: null, EnableDbus: null, EnableUbus: null, FastDnsRetry: null,
DhcpScriptPath: null, MxTarget: null, DnsForwardMax: null, DumpfilePath: null, Dumpmask: null, AddCpeId: null, DnssecTimestamp: null, DnssecLimits: null, DhcpAlternatePort: null, DhcpDuid: null, DhcpLuascriptPath: null, DhcpScriptuser: null, DhcpPxeVendor: null, DhcpScriptPath: null, MxTarget: null, DnsForwardMax: null, DumpfilePath: null, Dumpmask: null, AddCpeId: null, DnssecTimestamp: null, DnssecLimits: null, DhcpAlternatePort: null, DhcpDuid: null, DhcpLuascriptPath: null, DhcpScriptuser: null, DhcpPxeVendor: null,
UseStaleCache: null, AddMac: null, AddSubnet: null, Umbrella: null, Do0x20EncodeState: ExplicitToggleState.Default, Conntrack: false UseStaleCache: null, AddMac: null, StripMac: false, AddSubnet: null, StripSubnet: false, Umbrella: null, Do0x20EncodeState: ExplicitToggleState.Default, Conntrack: false
); );
private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() => private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() =>
@@ -940,7 +948,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null,
TftpRootPath: null, PxePrompt: null, EnableDbus: null, EnableUbus: null, FastDnsRetry: null, TftpRootPath: null, PxePrompt: null, EnableDbus: null, EnableUbus: null, FastDnsRetry: null,
DhcpScriptPath: null, MxTarget: null, DnsForwardMax: null, DumpfilePath: null, Dumpmask: null, AddCpeId: null, DnssecTimestamp: null, DnssecLimits: null, DhcpAlternatePort: null, DhcpDuid: null, DhcpLuascriptPath: null, DhcpScriptuser: null, DhcpPxeVendor: null, DhcpScriptPath: null, MxTarget: null, DnsForwardMax: null, DumpfilePath: null, Dumpmask: null, AddCpeId: null, DnssecTimestamp: null, DnssecLimits: null, DhcpAlternatePort: null, DhcpDuid: null, DhcpLuascriptPath: null, DhcpScriptuser: null, DhcpPxeVendor: null,
UseStaleCache: null, AddMac: null, AddSubnet: null, Umbrella: null, Do0x20Encode: null, Conntrack: null UseStaleCache: null, AddMac: null, StripMac: null, AddSubnet: null, StripSubnet: null, Umbrella: null, Do0x20Encode: null, Conntrack: null
); );
private static int? TryParseInt(string? value) private static int? TryParseInt(string? value)
@@ -231,7 +231,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var maxLineNumber = list.Count > 0 ? list.Max(l => l.LineNumber) : 0; var maxLineNumber = list.Count > 0 ? list.Max(l => l.LineNumber) : 0;
foreach (var c in changes) foreach (var c in changes)
{ {
var writeBehavior = EffectiveConfigWriteBehaviorMap.GetBehavior(c.OptionName); var writeBehavior = EffectiveConfigWriteSemantics.GetBehavior(c.OptionName);
var confKey = c.OptionName; var confKey = c.OptionName;
bool MatchesOption(DnsmasqConfLine line) bool MatchesOption(DnsmasqConfLine line)
@@ -255,7 +255,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
if (writeBehavior == EffectiveConfigWriteBehavior.InversePair) if (writeBehavior == EffectiveConfigWriteBehavior.InversePair)
{ {
var pair = EffectiveConfigWriteBehaviorMap.GetInversePairKeys(confKey); var pair = EffectiveConfigWriteSemantics.GetInversePairKeys(confKey);
if (pair is null) if (pair is null)
continue; continue;
var (pairKeyA, pairKeyB) = pair.Value; var (pairKeyA, pairKeyB) = pair.Value;
@@ -275,10 +275,9 @@ public class DnsmasqConfigService : IDnsmasqConfigService
continue; continue;
} }
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(c.OptionName); var isFlag = writeBehavior == EffectiveConfigWriteBehavior.Flag;
var isFlag = behavior == EffectiveConfigParserBehavior.Flag;
if (behavior == EffectiveConfigParserBehavior.Multi && TryGetMultiValues(c.NewValue, out var values)) if (writeBehavior == EffectiveConfigWriteBehavior.MultiValue && TryGetMultiValues(c.NewValue, out var values))
{ {
IReadOnlyList<string> readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty<string>(); IReadOnlyList<string> readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty<string>();
var valuesToWrite = FilterManagedOnly(values, readonlyValues); var valuesToWrite = FilterManagedOnly(values, readonlyValues);
@@ -101,15 +101,21 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietDhcp6); RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietDhcp6);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietRa); RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietRa);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietTftp); RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.QuietTftp);
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.StripMac);
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.StripSubnet);
// do-0x20-encode / no-0x20-encode: tri-state dropdown (Default / Enabled / Disabled). // do-0x20-encode / no-0x20-encode: tri-state dropdown (Default / Enabled / Disabled).
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay)); RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay));
// Key-only or key=value options: checkbox (On) + optional value input. // Key-only or key=value options: checkbox (On) + optional value input; with semantic validators.
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache, typeof(KeyOnlyOrValueDisplay)); RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache, typeof(KeyOnlyOrValueDisplay));
RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache, SpecialOptionValidators.ValidateUseStaleCache);
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac, typeof(KeyOnlyOrValueDisplay)); RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac, typeof(KeyOnlyOrValueDisplay));
RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac, SpecialOptionValidators.ValidateAddMac);
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet, typeof(KeyOnlyOrValueDisplay)); RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet, typeof(KeyOnlyOrValueDisplay));
RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet, SpecialOptionValidators.ValidateAddSubnet);
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, typeof(KeyOnlyOrValueDisplay)); RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, typeof(KeyOnlyOrValueDisplay));
RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, SpecialOptionValidators.ValidateUmbrella);
// log-queries: dropdown (Off / On / extra / proto / auth). // log-queries: dropdown (Off / On / extra / proto / auth).
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay)); RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay));
@@ -0,0 +1,48 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Single-value validators for effective-config options with special semantics (key-only or key=value, allowed values).
/// Used by the descriptor factory so field-level validation shows friendly errors in the edit flow.
/// </summary>
public static class SpecialOptionValidators
{
/// <summary>use-stale-cache: unset, key-only, or non-negative integer.</summary>
public static string? ValidateUseStaleCache(object? value)
{
if (value is null) return null;
var s = value.ToString()?.Trim() ?? "";
if (s.Length == 0) return null; // key-only
return int.TryParse(s, out var n) && n >= 0
? null
: "use-stale-cache must be empty or a non-negative integer.";
}
/// <summary>add-mac: unset, key-only, or 'base64' or 'text'.</summary>
public static string? ValidateAddMac(object? value)
{
if (value is null) return null;
var s = value.ToString()?.Trim() ?? "";
if (s.Length == 0) return null; // key-only
return s is "base64" or "text"
? null
: "add-mac must be empty, 'base64', or 'text'.";
}
/// <summary>add-subnet: unset, key-only, or value (permissive; format not strictly validated).</summary>
public static string? ValidateAddSubnet(object? value)
{
if (value is null) return null;
return null; // accept any string for now
}
/// <summary>umbrella: unset, key-only, or token list (permissive).</summary>
public static string? ValidateUmbrella(object? value)
{
if (value is null) return null;
var s = value.ToString()?.Trim() ?? "";
if (s.Length == 0) return null; // key-only
return null; // token parsing can be tightened later
}
}
@@ -183,7 +183,9 @@ public record EffectiveConfigSources(
ConfigValueSource? DhcpPxeVendor, ConfigValueSource? DhcpPxeVendor,
ConfigValueSource? UseStaleCache, ConfigValueSource? UseStaleCache,
ConfigValueSource? AddMac, ConfigValueSource? AddMac,
ConfigValueSource? StripMac,
ConfigValueSource? AddSubnet, ConfigValueSource? AddSubnet,
ConfigValueSource? StripSubnet,
ConfigValueSource? Umbrella, ConfigValueSource? Umbrella,
ConfigValueSource? Do0x20Encode, ConfigValueSource? Do0x20Encode,
ConfigValueSource? Conntrack ConfigValueSource? Conntrack
@@ -187,7 +187,9 @@ public record EffectiveDnsmasqConfig(
string? DhcpPxeVendor, string? DhcpPxeVendor,
string? UseStaleCache, string? UseStaleCache,
string? AddMac, string? AddMac,
bool StripMac,
string? AddSubnet, string? AddSubnet,
bool StripSubnet,
string? Umbrella, string? Umbrella,
ExplicitToggleState Do0x20EncodeState, ExplicitToggleState Do0x20EncodeState,
bool Conntrack bool Conntrack
@@ -0,0 +1,2 @@
<dt><b>--strip-mac</b></dt>
<dd>Remove any MAC address information already in downstream queries before forwarding upstream.</dd>
@@ -0,0 +1,4 @@
<dt><b>--strip-subnet</b></dt>
<dd>Remove any subnet address already present in a downstream query before forwarding it upstream. If --add-subnet is set this also
ensures that any downstream-provided subnet is replaced by the one added by dnsmasq. Otherwise, dnsmasq will NOT replace an
existing subnet in the query.</dd>