> OnValuesChanged { get; set; }
private const string AddItemKey = "add-item";
+ private static readonly TimeSpan ValidationToastDedupeWindow = TimeSpan.FromSeconds(2);
+
private int? _editingIndex;
private bool _addingNew;
private string _draftValue = "";
private bool _didAutoOpenAdd;
private bool _shouldFocusAddInput;
private ElementReference _addNewInputRef;
- private int? _inlineErrorIndex;
- private string? _inlineErrorMessage;
+ private string? _lastValidationErrorMessage;
+ private DateTime _lastValidationErrorUtc;
private List _displayItems = new();
private IReadOnlyList _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()
{
_displayItems = (SourceItems ?? Array.Empty()).ToList();
@@ -176,7 +165,6 @@
_addingNew = false;
_editingIndex = index;
_draftValue = current;
- ClearInlineError();
}
private void BeginAdd()
@@ -185,7 +173,6 @@
_addingNew = true;
_draftValue = "";
_shouldFocusAddInput = true;
- ClearInlineError();
}
private void CancelInline()
@@ -193,7 +180,18 @@
_editingIndex = null;
_addingNew = false;
_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)
@@ -206,24 +204,23 @@
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
if (normalized.Length == 0)
{
- SetInlineError(index, "Value cannot be empty.");
+ ShowValidationError("Value cannot be empty.");
return;
}
var validationError = Validator?.ValidateItem(normalized, current, index);
if (validationError != null)
{
- SetInlineError(index, validationError);
+ ShowValidationError(validationError);
return;
}
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;
}
- ClearInlineError();
next[index] = normalized;
_displayItems[index] = _displayItems[index] with { Value = normalized };
await OnValuesChanged.InvokeAsync(next);
@@ -234,23 +231,26 @@
{
var raw = _draftValue ?? "";
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 validationError = Validator?.ValidateItem(normalized, current, null);
if (validationError != null)
{
- SetInlineError(null, validationError);
+ ShowValidationError(validationError);
return;
}
if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal))
{
- SetInlineError(null, "Duplicate values are not allowed.");
+ ShowValidationError("Duplicate values are not allowed.");
return;
}
- ClearInlineError();
var next = current.ToList();
next.Add(normalized);
_displayItems.Add(new ValueWithSource(normalized, null));
@@ -258,19 +258,6 @@
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)
{
if (index < 0 || index >= _displayItems.Count) return;
diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor
index e017558..344320e 100644
--- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor
+++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor
@@ -284,7 +284,7 @@
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);
if (v == null) return "(not set)";
if (v is bool b) return b ? "on" : "off";
diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionTooltips.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionTooltips.cs
index b09ed56..dae5437 100644
--- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionTooltips.cs
+++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionTooltips.cs
@@ -166,7 +166,9 @@ public static class DnsmasqOptionTooltips
[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.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.StripSubnet] = "Strip subnet (ECS) information from forwarded DNS queries.",
[DnsmasqConfKeys.Umbrella] = "Cisco Umbrella device identity. Off, On (key-only), or custom value.",
// --- Process & networking ---
diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldBuilder.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldBuilder.cs
index 82fd737..f68c24d 100644
--- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldBuilder.cs
+++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldBuilder.cs
@@ -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.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.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.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.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);
diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs
index a57e90e..f25bfd6 100644
--- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs
+++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs
@@ -199,7 +199,9 @@ public static class EffectiveConfigOptionKindMap
[DnsmasqConfKeys.DhcpOptionPxe] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.UseStaleCache] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.AddMac] = EffectiveConfigFieldKind.Single,
+ [DnsmasqConfKeys.StripMac] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.AddSubnet] = EffectiveConfigFieldKind.Single,
+ [DnsmasqConfKeys.StripSubnet] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.Umbrella] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.Do0x20Encode] = EffectiveConfigFieldKind.Single,
};
@@ -386,7 +388,9 @@ public static class EffectiveConfigParserBehaviorMap
[DnsmasqConfKeys.DhcpOptionPxe] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.UseStaleCache] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.AddMac] = EffectiveConfigParserBehavior.LastWins,
+ [DnsmasqConfKeys.StripMac] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.AddSubnet] = EffectiveConfigParserBehavior.LastWins,
+ [DnsmasqConfKeys.StripSubnet] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.Umbrella] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.Do0x20Encode] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.No0x20Encode] = EffectiveConfigParserBehavior.Flag,
@@ -397,48 +401,3 @@ public static class EffectiveConfigParserBehaviorMap
BehaviorByOptionName.TryGetValue(optionName, out var b) ? b : EffectiveConfigParserBehavior.LastWins;
}
-///
-/// How to serialize an option when writing the managed config file.
-/// Used by DnsmasqConfigService so KeyOnlyOrValue and InversePair options serialize correctly.
-///
-public enum EffectiveConfigWriteBehavior
-{
- Flag,
- SingleValue,
- MultiValue,
- KeyOnlyOrValue,
- InversePair,
-}
-
-///
-/// 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.
-///
-public static class EffectiveConfigWriteBehaviorMap
-{
- private static readonly IReadOnlyDictionary BehaviorByOptionName =
- new Dictionary(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,
- };
-
- /// For InversePair options: (key for "enabled", key for "disabled"). Null if not an InversePair or unknown.
- private static readonly IReadOnlyDictionary InversePairKeysByOptionName =
- new Dictionary(StringComparer.Ordinal)
- {
- [DnsmasqConfKeys.Do0x20Encode] = (DnsmasqConfKeys.Do0x20Encode, DnsmasqConfKeys.No0x20Encode),
- };
-
- /// Returns write behavior for the option; defaults to SingleValue if unknown.
- public static EffectiveConfigWriteBehavior GetBehavior(string optionName) =>
- BehaviorByOptionName.TryGetValue(optionName, out var b) ? b : EffectiveConfigWriteBehavior.SingleValue;
-
- /// Returns the pair of config keys (enabled, disabled) for an InversePair option; null otherwise.
- public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
- InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null;
-}
diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSections.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSections.cs
index a6a8358..b064c55 100644
--- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSections.cs
+++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSections.cs
@@ -178,7 +178,9 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.Dumpmask,
DnsmasqConfKeys.UseStaleCache,
DnsmasqConfKeys.AddMac,
+ DnsmasqConfKeys.StripMac,
DnsmasqConfKeys.AddSubnet,
+ DnsmasqConfKeys.StripSubnet,
DnsmasqConfKeys.Umbrella,
]),
new SectionDef(SectionProcess, "Process & networking", [
diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs
new file mode 100644
index 0000000..7ae86ba
--- /dev/null
+++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs
@@ -0,0 +1,77 @@
+using System.Collections.Generic;
+using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
+using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
+
+namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
+
+///
+/// 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 .
+///
+public sealed record OptionSemantics(
+ string OptionName,
+ EffectiveConfigParserBehavior ParserBehavior,
+ EffectiveConfigWriteBehavior WriteBehavior,
+ EffectiveConfigSingleValueValidator? Validator
+);
+
+///
+/// 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 IReadOnlyDictionary ByOptionName =
+ new Dictionary(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),
+ };
+
+ /// Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints.
+ private static readonly IReadOnlyDictionary InversePairKeysByOptionName =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [DnsmasqConfKeys.Do0x20Encode] = (DnsmasqConfKeys.Do0x20Encode, DnsmasqConfKeys.No0x20Encode),
+ };
+
+ /// Returns semantics for a special option; null if not a special option.
+ public static OptionSemantics? TryGetSemantics(string optionName) =>
+ ByOptionName.TryGetValue(optionName, out var s) ? s : null;
+
+ /// Returns write behavior from semantics for special options; otherwise null (caller uses general map).
+ public static EffectiveConfigWriteBehavior? GetWriteBehavior(string optionName) =>
+ TryGetSemantics(optionName)?.WriteBehavior;
+
+ /// Returns the pair of config keys (enabled, disabled) for an InversePair option; null otherwise.
+ 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)?.Validator;
+}
diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigWriteSemantics.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigWriteSemantics.cs
new file mode 100644
index 0000000..3c5172b
--- /dev/null
+++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigWriteSemantics.cs
@@ -0,0 +1,40 @@
+namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
+
+///
+/// How to serialize an option when writing the managed config file.
+/// This models write intent, not parse behavior.
+///
+public enum EffectiveConfigWriteBehavior
+{
+ Flag,
+ SingleValue,
+ MultiValue,
+ KeyOnlyOrValue,
+ InversePair,
+}
+
+///
+/// Source of truth for write semantics used by managed-config writing and readonly hints.
+/// Special options come from ; all others
+/// derive from parser behavior (Flag/Multi/LastWins).
+///
+public static class EffectiveConfigWriteSemantics
+{
+ /// Returns write behavior for an option.
+ 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,
+ };
+ }
+
+ /// Returns inverse-pair keys (enabled, disabled) for InversePair options; null otherwise.
+ public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
+ EffectiveConfigSpecialOptionSemantics.GetInversePairKeys(optionName);
+}
+
diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/ReadonlyConfigCommandGenerator.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/ReadonlyConfigCommandGenerator.cs
index fa5ac71..8264398 100644
--- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/ReadonlyConfigCommandGenerator.cs
+++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/ReadonlyConfigCommandGenerator.cs
@@ -5,22 +5,31 @@ namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
///
/// 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.
+/// Uses EffectiveConfigWriteSemantics so inverse-pair and key-only-or-value options are handled correctly.
///
public static class ReadonlyConfigCommandGenerator
{
///
/// 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).
///
public static string GetRemoveLineCommand(ConfigValueSource source, string optionName)
{
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 behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionName);
- var pattern = behavior == EffectiveConfigParserBehavior.Flag
+ var pattern = writeBehavior == EffectiveConfigWriteBehavior.Flag
? $"^{escaped}$"
: $"^{escaped}=.*$";
- var pathEscaped = source.FilePath.Replace("|", "\\|");
return $"sed -i '|{pattern}|d' {pathEscaped}";
}
@@ -34,12 +43,31 @@ public static class ReadonlyConfigCommandGenerator
///
/// 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.
///
public static string GetOverrideLine(string optionName, object? value, string? managedFilePath)
{
if (string.IsNullOrEmpty(managedFilePath)) return "";
- var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionName);
- if (behavior == EffectiveConfigParserBehavior.Flag)
+ var writeBehavior = EffectiveConfigWriteSemantics.GetBehavior(optionName);
+ 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 : "";
var v = ValueToConfString(value);
return string.IsNullOrEmpty(v) ? optionName : $"{optionName}={v}";
diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs
index 8b681ec..ea6c51c 100644
--- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs
+++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs
@@ -452,13 +452,16 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var useStaleCache = useStaleCacheVal == null ? null : (string.IsNullOrWhiteSpace(useStaleCacheVal) ? "" : useStaleCacheVal.Trim());
var (addMacVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AddMac);
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 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 umbrella = umbrellaVal == null ? null : (string.IsNullOrWhiteSpace(umbrellaVal) ? "" : umbrellaVal.Trim());
var do0x20 = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Do0x20Encode);
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);
return new EffectiveDnsmasqConfig(
@@ -480,7 +483,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
authTtl, ednsPacketMax, queryPort, portLimit, minPort, maxPort, logAsync, localService, dhcpLeaseMax,
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl, tftpRootPath, pxePrompt, enableDbus, enableUbus, fastDnsRetry,
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 (_, useStaleCacheSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.UseStaleCache, 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 (_, stripSubnetSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.StripSubnet, managedFilePath);
var (_, umbrellaSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Umbrella, managedFilePath);
var (do0x20Set, do0x20EncodeSourceA) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Do0x20Encode, 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);
return new EffectiveConfigSources(
@@ -826,7 +832,9 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
dhcpPxeVendorSource,
useStaleCacheSource,
addMacSource,
+ stripMacSource,
addSubnetSource,
+ stripSubnetSource,
umbrellaSource,
do0x20EncodeSource,
conntrackSource
@@ -899,7 +907,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: 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,
- 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() =>
@@ -940,7 +948,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: 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,
- 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)
diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs
index ffa1040..279c55f 100644
--- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs
+++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs
@@ -231,7 +231,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var maxLineNumber = list.Count > 0 ? list.Max(l => l.LineNumber) : 0;
foreach (var c in changes)
{
- var writeBehavior = EffectiveConfigWriteBehaviorMap.GetBehavior(c.OptionName);
+ var writeBehavior = EffectiveConfigWriteSemantics.GetBehavior(c.OptionName);
var confKey = c.OptionName;
bool MatchesOption(DnsmasqConfLine line)
@@ -255,7 +255,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
if (writeBehavior == EffectiveConfigWriteBehavior.InversePair)
{
- var pair = EffectiveConfigWriteBehaviorMap.GetInversePairKeys(confKey);
+ var pair = EffectiveConfigWriteSemantics.GetInversePairKeys(confKey);
if (pair is null)
continue;
var (pairKeyA, pairKeyB) = pair.Value;
@@ -275,10 +275,9 @@ public class DnsmasqConfigService : IDnsmasqConfigService
continue;
}
- var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(c.OptionName);
- var isFlag = behavior == EffectiveConfigParserBehavior.Flag;
+ var isFlag = writeBehavior == EffectiveConfigWriteBehavior.Flag;
- if (behavior == EffectiveConfigParserBehavior.Multi && TryGetMultiValues(c.NewValue, out var values))
+ if (writeBehavior == EffectiveConfigWriteBehavior.MultiValue && TryGetMultiValues(c.NewValue, out var values))
{
IReadOnlyList readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty();
var valuesToWrite = FilterManagedOnly(values, readonlyValues);
diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs
index a835547..b9c119b 100644
--- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs
+++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs
@@ -101,15 +101,21 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
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);
// do-0x20-encode / no-0x20-encode: tri-state dropdown (Default / Enabled / Disabled).
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));
+ RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache, SpecialOptionValidators.ValidateUseStaleCache);
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac, typeof(KeyOnlyOrValueDisplay));
+ RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac, SpecialOptionValidators.ValidateAddMac);
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet, typeof(KeyOnlyOrValueDisplay));
+ RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet, SpecialOptionValidators.ValidateAddSubnet);
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, typeof(KeyOnlyOrValueDisplay));
+ RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, SpecialOptionValidators.ValidateUmbrella);
// log-queries: dropdown (Off / On / extra / proto / auth).
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay));
diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SpecialOptionValidators.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SpecialOptionValidators.cs
new file mode 100644
index 0000000..93d143f
--- /dev/null
+++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/SpecialOptionValidators.cs
@@ -0,0 +1,48 @@
+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
+ }
+}
diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSources.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSources.cs
index 4d726d8..d99b252 100644
--- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSources.cs
+++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSources.cs
@@ -183,7 +183,9 @@ public record EffectiveConfigSources(
ConfigValueSource? DhcpPxeVendor,
ConfigValueSource? UseStaleCache,
ConfigValueSource? AddMac,
+ ConfigValueSource? StripMac,
ConfigValueSource? AddSubnet,
+ ConfigValueSource? StripSubnet,
ConfigValueSource? Umbrella,
ConfigValueSource? Do0x20Encode,
ConfigValueSource? Conntrack
diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveDnsmasqConfig.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveDnsmasqConfig.cs
index 5d968eb..3367f23 100644
--- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveDnsmasqConfig.cs
+++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveDnsmasqConfig.cs
@@ -187,7 +187,9 @@ public record EffectiveDnsmasqConfig(
string? DhcpPxeVendor,
string? UseStaleCache,
string? AddMac,
+ bool StripMac,
string? AddSubnet,
+ bool StripSubnet,
string? Umbrella,
ExplicitToggleState Do0x20EncodeState,
bool Conntrack
diff --git a/src/DnsmasqWebUI/wwwroot/option-help/strip-mac.html b/src/DnsmasqWebUI/wwwroot/option-help/strip-mac.html
new file mode 100644
index 0000000..64ebf31
--- /dev/null
+++ b/src/DnsmasqWebUI/wwwroot/option-help/strip-mac.html
@@ -0,0 +1,2 @@
+--strip-mac
+Remove any MAC address information already in downstream queries before forwarding upstream.
\ No newline at end of file
diff --git a/src/DnsmasqWebUI/wwwroot/option-help/strip-subnet.html b/src/DnsmasqWebUI/wwwroot/option-help/strip-subnet.html
new file mode 100644
index 0000000..10987b5
--- /dev/null
+++ b/src/DnsmasqWebUI/wwwroot/option-help/strip-subnet.html
@@ -0,0 +1,4 @@
+--strip-subnet
+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.
\ No newline at end of file