What dnsmasq uses after reading all config files (main + conf-file + conf-dir). Values from a non-managed file are readonly (edit that file to change).
Each hosts file below is rendered with the same component; only the managed file is editable. System hosts is excluded when no-hosts is set (dnsmasq does not read it then).
-
-
@_status.ManagedHostsFilePath @(!_status.ManagedHostsPathExists ? "(not created yet; will be created on first save)" : "")
@if (_readOnlyHosts?.Count > 0)
diff --git a/src/DnsmasqWebUI/Components/_Imports.razor b/src/DnsmasqWebUI/Components/_Imports.razor
index 5bdbf7a..e9fa274 100644
--- a/src/DnsmasqWebUI/Components/_Imports.razor
+++ b/src/DnsmasqWebUI/Components/_Imports.razor
@@ -11,10 +11,14 @@
@using DnsmasqWebUI.Client.Services.Abstractions
@using DnsmasqWebUI.Components
@using DnsmasqWebUI.Components.Modals
-@using DnsmasqWebUI.Components.EffectiveConfig
-@using DnsmasqWebUI.Components.Hosts
@using DnsmasqWebUI.Components.Dnsmasq
+@using DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
+@using DnsmasqWebUI.Components.Hosts
@using DnsmasqWebUI.Models
+@using DnsmasqWebUI.Models.EffectiveConfig
+@using DnsmasqWebUI.Models.Hosts
+@using DnsmasqWebUI.Models.Dhcp
+@using DnsmasqWebUI.Models.Status
@using DnsmasqWebUI.Configuration
@using DnsmasqWebUI.Client.Http.Abstractions
@using DnsmasqWebUI.Services.Abstractions
diff --git a/src/DnsmasqWebUI/Controllers/ConfigController.cs b/src/DnsmasqWebUI/Controllers/ConfigController.cs
index d33aed6..6532090 100644
--- a/src/DnsmasqWebUI/Controllers/ConfigController.cs
+++ b/src/DnsmasqWebUI/Controllers/ConfigController.cs
@@ -1,5 +1,5 @@
-using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
+using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
diff --git a/src/DnsmasqWebUI/Controllers/DhcpController.cs b/src/DnsmasqWebUI/Controllers/DhcpController.cs
index c88e563..3b72b58 100644
--- a/src/DnsmasqWebUI/Controllers/DhcpController.cs
+++ b/src/DnsmasqWebUI/Controllers/DhcpController.cs
@@ -1,4 +1,5 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Dhcp;
+using DnsmasqWebUI.Models.Status;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
diff --git a/src/DnsmasqWebUI/Controllers/HostsController.cs b/src/DnsmasqWebUI/Controllers/HostsController.cs
index a2d6232..36b471e 100644
--- a/src/DnsmasqWebUI/Controllers/HostsController.cs
+++ b/src/DnsmasqWebUI/Controllers/HostsController.cs
@@ -1,6 +1,8 @@
using System.Text;
using DnsmasqWebUI.Configuration;
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.EffectiveConfig;
+using DnsmasqWebUI.Models.Hosts;
+using DnsmasqWebUI.Models.Status;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
diff --git a/src/DnsmasqWebUI/Controllers/LeasesController.cs b/src/DnsmasqWebUI/Controllers/LeasesController.cs
index 965c0a0..d86b03c 100644
--- a/src/DnsmasqWebUI/Controllers/LeasesController.cs
+++ b/src/DnsmasqWebUI/Controllers/LeasesController.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
diff --git a/src/DnsmasqWebUI/Controllers/StatusController.cs b/src/DnsmasqWebUI/Controllers/StatusController.cs
index 9fff292..5e3e7e1 100644
--- a/src/DnsmasqWebUI/Controllers/StatusController.cs
+++ b/src/DnsmasqWebUI/Controllers/StatusController.cs
@@ -1,4 +1,5 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.EffectiveConfig;
+using DnsmasqWebUI.Models.Status;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqConfLine.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqConfLine.cs
index b9fa2cf..3276208 100644
--- a/src/DnsmasqWebUI/Models/Config/DnsmasqConfLine.cs
+++ b/src/DnsmasqWebUI/Models/Config/DnsmasqConfLine.cs
@@ -1,5 +1,5 @@
using System.Text.Json.Serialization;
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Models.Config;
diff --git a/src/DnsmasqWebUI/Models/DhcpHostEntry.cs b/src/DnsmasqWebUI/Models/Dhcp/DhcpHostEntry.cs
similarity index 97%
rename from src/DnsmasqWebUI/Models/DhcpHostEntry.cs
rename to src/DnsmasqWebUI/Models/Dhcp/DhcpHostEntry.cs
index 82ce104..cec86db 100644
--- a/src/DnsmasqWebUI/Models/DhcpHostEntry.cs
+++ b/src/DnsmasqWebUI/Models/Dhcp/DhcpHostEntry.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Dhcp;
public class DhcpHostEntry
{
diff --git a/src/DnsmasqWebUI/Models/LeaseEntry.cs b/src/DnsmasqWebUI/Models/Dhcp/LeaseEntry.cs
similarity index 90%
rename from src/DnsmasqWebUI/Models/LeaseEntry.cs
rename to src/DnsmasqWebUI/Models/Dhcp/LeaseEntry.cs
index cfd0cf9..bb06479 100644
--- a/src/DnsmasqWebUI/Models/LeaseEntry.cs
+++ b/src/DnsmasqWebUI/Models/Dhcp/LeaseEntry.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Dhcp;
public class LeaseEntry
{
diff --git a/src/DnsmasqWebUI/Models/LeasesResult.cs b/src/DnsmasqWebUI/Models/Dhcp/LeasesResult.cs
similarity index 84%
rename from src/DnsmasqWebUI/Models/LeasesResult.cs
rename to src/DnsmasqWebUI/Models/Dhcp/LeasesResult.cs
index 41f5ca6..39c6c99 100644
--- a/src/DnsmasqWebUI/Models/LeasesResult.cs
+++ b/src/DnsmasqWebUI/Models/Dhcp/LeasesResult.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Dhcp;
/// Result of GET api/leases: whether leases are available and the list of entries.
public record LeasesResult(bool Available, IReadOnlyList? Entries, string? Message);
diff --git a/src/DnsmasqWebUI/Models/ConfigValueSource.cs b/src/DnsmasqWebUI/Models/EffectiveConfig/ConfigValueSource.cs
similarity index 97%
rename from src/DnsmasqWebUI/Models/ConfigValueSource.cs
rename to src/DnsmasqWebUI/Models/EffectiveConfig/ConfigValueSource.cs
index 05db0c0..b8ddaba 100644
--- a/src/DnsmasqWebUI/Models/ConfigValueSource.cs
+++ b/src/DnsmasqWebUI/Models/EffectiveConfig/ConfigValueSource.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.EffectiveConfig;
///
/// Where an effective config value came from (which file). Used so the UI can show exactly which file
diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfFileSource.cs b/src/DnsmasqWebUI/Models/EffectiveConfig/DnsmasqConfFileSource.cs
similarity index 79%
rename from src/DnsmasqWebUI/Models/DnsmasqConfFileSource.cs
rename to src/DnsmasqWebUI/Models/EffectiveConfig/DnsmasqConfFileSource.cs
index da8cdae..13921bd 100644
--- a/src/DnsmasqWebUI/Models/DnsmasqConfFileSource.cs
+++ b/src/DnsmasqWebUI/Models/EffectiveConfig/DnsmasqConfFileSource.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.EffectiveConfig;
/// Source of a file in the dnsmasq config set: main config, conf-file=, or conf-dir=.
public enum DnsmasqConfFileSource
diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs b/src/DnsmasqWebUI/Models/EffectiveConfig/DnsmasqConfigSet.cs
similarity index 88%
rename from src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs
rename to src/DnsmasqWebUI/Models/EffectiveConfig/DnsmasqConfigSet.cs
index 97776ff..80babae 100644
--- a/src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs
+++ b/src/DnsmasqWebUI/Models/EffectiveConfig/DnsmasqConfigSet.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.EffectiveConfig;
/// Ordered set of dnsmasq config files (main + conf-file + conf-dir). ManagedFilePath is the single config file we read/write; ManagedHostsFilePath is the single hosts file we read/write.
public record DnsmasqConfigSet(
diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfigSetEntry.cs b/src/DnsmasqWebUI/Models/EffectiveConfig/DnsmasqConfigSetEntry.cs
similarity index 83%
rename from src/DnsmasqWebUI/Models/DnsmasqConfigSetEntry.cs
rename to src/DnsmasqWebUI/Models/EffectiveConfig/DnsmasqConfigSetEntry.cs
index 926a186..d198ccd 100644
--- a/src/DnsmasqWebUI/Models/DnsmasqConfigSetEntry.cs
+++ b/src/DnsmasqWebUI/Models/EffectiveConfig/DnsmasqConfigSetEntry.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.EffectiveConfig;
/// One file in the dnsmasq config set (main or included). IsManaged is true only for the app-managed file.
public record DnsmasqConfigSetEntry(string Path, string FileName, DnsmasqConfFileSource Source, bool IsManaged);
diff --git a/src/DnsmasqWebUI/Models/EffectiveConfigSources.cs b/src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveConfigSources.cs
similarity index 98%
rename from src/DnsmasqWebUI/Models/EffectiveConfigSources.cs
rename to src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveConfigSources.cs
index f2b3de0..8ee3392 100644
--- a/src/DnsmasqWebUI/Models/EffectiveConfigSources.cs
+++ b/src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveConfigSources.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.EffectiveConfig;
///
/// Source file for each field in .
diff --git a/src/DnsmasqWebUI/Models/EffectiveDnsmasqConfig.cs b/src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveDnsmasqConfig.cs
similarity index 98%
rename from src/DnsmasqWebUI/Models/EffectiveDnsmasqConfig.cs
rename to src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveDnsmasqConfig.cs
index 4a5c2a3..c369f05 100644
--- a/src/DnsmasqWebUI/Models/EffectiveDnsmasqConfig.cs
+++ b/src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveDnsmasqConfig.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.EffectiveConfig;
///
/// Effective dnsmasq config after reading all config files (main + conf-file + conf-dir)
diff --git a/src/DnsmasqWebUI/Models/HostEntry.cs b/src/DnsmasqWebUI/Models/Hosts/HostEntry.cs
similarity index 94%
rename from src/DnsmasqWebUI/Models/HostEntry.cs
rename to src/DnsmasqWebUI/Models/Hosts/HostEntry.cs
index 4dda033..faeea3e 100644
--- a/src/DnsmasqWebUI/Models/HostEntry.cs
+++ b/src/DnsmasqWebUI/Models/Hosts/HostEntry.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Hosts;
public class HostEntry
{
diff --git a/src/DnsmasqWebUI/Models/ReadOnlyHostsFile.cs b/src/DnsmasqWebUI/Models/Hosts/ReadOnlyHostsFile.cs
similarity index 83%
rename from src/DnsmasqWebUI/Models/ReadOnlyHostsFile.cs
rename to src/DnsmasqWebUI/Models/Hosts/ReadOnlyHostsFile.cs
index 3f065d5..a1bfc00 100644
--- a/src/DnsmasqWebUI/Models/ReadOnlyHostsFile.cs
+++ b/src/DnsmasqWebUI/Models/Hosts/ReadOnlyHostsFile.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Hosts;
/// Path and parsed entries for a read-only addn-hosts file (not the managed hosts file).
public record ReadOnlyHostsFile(string Path, IReadOnlyList Entries);
diff --git a/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs b/src/DnsmasqWebUI/Models/Status/DnsmasqServiceStatus.cs
similarity index 98%
rename from src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs
rename to src/DnsmasqWebUI/Models/Status/DnsmasqServiceStatus.cs
index 0700129..6bbd4b2 100644
--- a/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs
+++ b/src/DnsmasqWebUI/Models/Status/DnsmasqServiceStatus.cs
@@ -1,4 +1,6 @@
-namespace DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.EffectiveConfig;
+
+namespace DnsmasqWebUI.Models.Status;
///
/// Dnsmasq service and config status returned by GET api/status.
diff --git a/src/DnsmasqWebUI/Models/SaveWithReloadResult.cs b/src/DnsmasqWebUI/Models/Status/SaveWithReloadResult.cs
similarity index 86%
rename from src/DnsmasqWebUI/Models/SaveWithReloadResult.cs
rename to src/DnsmasqWebUI/Models/Status/SaveWithReloadResult.cs
index 3307a4a..39b14e3 100644
--- a/src/DnsmasqWebUI/Models/SaveWithReloadResult.cs
+++ b/src/DnsmasqWebUI/Models/Status/SaveWithReloadResult.cs
@@ -1,6 +1,6 @@
using DnsmasqWebUI.Services.Abstractions;
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Status;
/// Result of a save operation that triggers a dnsmasq reload (e.g. PUT api/hosts, PUT api/dhcp/hosts).
public record SaveWithReloadResult(bool Saved, ReloadResult Reload);
diff --git a/src/DnsmasqWebUI/Parsers/ConfParserHelpers.cs b/src/DnsmasqWebUI/Parsers/ConfParserHelpers.cs
index 2171b87..bde42ef 100644
--- a/src/DnsmasqWebUI/Parsers/ConfParserHelpers.cs
+++ b/src/DnsmasqWebUI/Parsers/ConfParserHelpers.cs
@@ -27,4 +27,46 @@ public static class ConfParserHelpers
.Or(Character.EqualTo('#').Value(Unit.Value))
.OptionalOrDefault(Unit.Value)
.Select(_ => Unit.Value);
+
+ ///
+ /// Dnsmasq-style: content before first '#' that is at word start (after whitespace). Consumes to EOL.
+ /// Returns the trimmed content (no comment). Use for directive lines that allow inline # comment.
+ ///
+ public static readonly TextParser StripCommentContent = span =>
+ {
+ var source = span.Source ?? "";
+ var i = span.Position.Absolute;
+ var end = Math.Min(i + span.Length, source.Length);
+ var white = true;
+ var len = 0;
+ while (i + len < end)
+ {
+ var c = source[i + len];
+ if (char.IsWhiteSpace(c))
+ white = true;
+ else if (white && c == '#')
+ break;
+ else
+ white = false;
+ len++;
+ }
+ var consumed = span.First(len);
+ var content = consumed.ToStringValue().TrimEnd();
+ var remainder = span.Skip(len);
+ return Result.Value(content, consumed, remainder);
+ };
+
+ ///
+ /// Key (no '='), optional spaces, optional '=value'. Full line. Reusable for key=value config lines.
+ ///
+ public static readonly TextParser<(string key, string value)> KeyValueLine =
+ Character.Matching(c => c != '=' && c != '\r' && c != '\n', "key character")
+ .AtLeastOnce()
+ .Text()
+ .Then(k => Character.WhiteSpace.Many()
+ .IgnoreThen(Character.EqualTo('=')
+ .IgnoreThen(Character.AnyChar.Many().Text().Select(s => s.Trim()))
+ .OptionalOrDefault(""))
+ .AtEnd()
+ .Select(v => (k.TrimEnd().Trim(), v)));
}
diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfDhcpHostLineParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfDhcpHostLineParser.cs
index ad69b0d..47c76c5 100644
--- a/src/DnsmasqWebUI/Parsers/DnsmasqConfDhcpHostLineParser.cs
+++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfDhcpHostLineParser.cs
@@ -1,5 +1,4 @@
-using System.Text.RegularExpressions;
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Dhcp;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
@@ -13,7 +12,32 @@ namespace DnsmasqWebUI.Parsers;
///
public static class DnsmasqConfDhcpHostLineParser
{
- private static readonly Regex HostnameRegex = new(@"^[a-zA-Z][-_a-zA-Z0-9]*$", RegexOptions.Compiled);
+ // Hostname: letter then (letter/digit/_/-)*. Full string.
+ private static readonly TextParser HostnameParser =
+ Character.Letter.Then(first => (Character.LetterOrDigit.Or(Character.In('_', '-')).Many().Text())
+ .OptionalOrDefault("").Select(rest => first + rest)).AtEnd().Named("hostname");
+
+ // IPv4: four octets 0-255 with '.'. Full string. Range checked after parse.
+ private static readonly TextParser<(int a, int b, int c, int d)> Ipv4OctetsParser =
+ Numerics.IntegerInt32.Then(a => Character.EqualTo('.').IgnoreThen(Numerics.IntegerInt32)
+ .Then(b => Character.EqualTo('.').IgnoreThen(Numerics.IntegerInt32)
+ .Then(c => Character.EqualTo('.').IgnoreThen(Numerics.IntegerInt32).Select(d => (a, b, c, d))))).AtEnd();
+
+ // MAC: six hex pairs xx:xx:xx:xx:xx:xx. Full string.
+ private static readonly TextParser MacParser =
+ Span.MatchedBy(Character.HexDigit.Repeat(2))
+ .IgnoreThen(Character.EqualTo(':'))
+ .IgnoreThen(Span.MatchedBy(Character.HexDigit.Repeat(2)))
+ .IgnoreThen(Character.EqualTo(':'))
+ .IgnoreThen(Span.MatchedBy(Character.HexDigit.Repeat(2)))
+ .IgnoreThen(Character.EqualTo(':'))
+ .IgnoreThen(Span.MatchedBy(Character.HexDigit.Repeat(2)))
+ .IgnoreThen(Character.EqualTo(':'))
+ .IgnoreThen(Span.MatchedBy(Character.HexDigit.Repeat(2)))
+ .IgnoreThen(Character.EqualTo(':'))
+ .IgnoreThen(Span.MatchedBy(Character.HexDigit.Repeat(2)))
+ .AtEnd()
+ .Value(Unit.Value);
// Optional ## or # at start (Try so that single # backtracks and we can match one #)
private static readonly TextParser<(bool isComment, bool isDeleted)> Prefix =
@@ -85,7 +109,7 @@ public static class DnsmasqConfDhcpHostLineParser
extraList.Add(field);
else if (field.StartsWith("set:", StringComparison.OrdinalIgnoreCase))
extraList.Add(field);
- else if (HostnameRegex.IsMatch(field))
+ else if (TryParseHostname(field))
host.Name = field;
else
extraList.Add(field);
@@ -129,15 +153,15 @@ public static class DnsmasqConfDhcpHostLineParser
h.MacAddresses.Count > 0 || !string.IsNullOrEmpty(h.Name) || !string.IsNullOrEmpty(h.Address) ||
h.Extra.Count > 0 || !string.IsNullOrEmpty(h.Lease) || h.Ignore;
- private static bool IsMac(string s)
- {
- var parts = s.Split(':');
- return parts.Length == 6 && parts.All(p => p.Length == 2 && p.All(c => char.IsAsciiHexDigit(c)));
- }
+ private static bool IsMac(string s) => !string.IsNullOrEmpty(s) && MacParser.TryParse(s).HasValue;
private static bool IsIpv4(string s)
{
- var parts = s.Split('.');
- return parts.Length == 4 && parts.All(p => p.Length > 0 && p.All(char.IsDigit) && int.TryParse(p, out var n) && n >= 0 && n <= 255);
+ var r = Ipv4OctetsParser.TryParse(s);
+ if (!r.HasValue) return false;
+ var (a, b, c, d) = r.Value;
+ return a >= 0 && a <= 255 && b >= 0 && b <= 255 && c >= 0 && c <= 255 && d >= 0 && d <= 255;
}
+
+ private static bool TryParseHostname(string s) => !string.IsNullOrEmpty(s) && HostnameParser.TryParse(s).HasValue;
}
diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs
index cbab4f6..1753091 100644
--- a/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs
+++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs
@@ -1,5 +1,5 @@
-using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
+using DnsmasqWebUI.Models.EffectiveConfig;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
@@ -13,40 +13,15 @@ namespace DnsmasqWebUI.Parsers;
///
public static class DnsmasqConfDirectiveParser
{
- // Key: non-= chars, trimmed
- private static readonly TextParser Key =
- Character.Matching(c => c != '=' && c != '\r' && c != '\n', "key character")
- .AtLeastOnce().Text()
- .Select(s => s.TrimEnd());
-
- // Key, optional spaces, optional '=value' (dnsmasq: key-only lines are valid for flags).
- private static readonly TextParser<(string key, string value)> KeyValue =
- from k in Key
- from _ in Character.WhiteSpace.Many()
- from v in Character.EqualTo('=')
- .IgnoreThen(Character.AnyChar.Many().Text())
- .Select(s => s.Trim())
- .OptionalOrDefault("")
- select (k.Trim(), v);
-
- // Full line: key=value or key only
+ // Key=value line using shared helper (Then/IgnoreThen for hot path). Full line.
private static readonly TextParser<(string key, string value)> DirectiveLine =
- KeyValue.AtEnd();
+ ConfParserHelpers.KeyValueLine.Named("key=value directive");
/// Strip dnsmasq-style comment: from first '#' that is at word start (after whitespace) to end of line.
public static string StripComment(string line)
{
- bool white = true;
- for (int i = 0; i < line.Length; i++)
- {
- if (char.IsWhiteSpace(line[i]))
- white = true;
- else if (white && line[i] == '#')
- return line[..i].TrimEnd();
- else
- white = false;
- }
- return line;
+ var result = ConfParserHelpers.StripCommentContent.TryParse(line);
+ return result.HasValue ? result.Value : line.TrimEnd();
}
/// Parse a non-comment line into key and value. Returns null for empty or comment-only lines.
@@ -67,6 +42,29 @@ public static class DnsmasqConfDirectiveParser
return (key, value);
}
+ /// Like but when parsing fails returns the Superpower error message and position (line/column).
+ public static bool TryParseKeyValue(string line, out (string key, string value)? kv, out string? error, out Position errorPosition)
+ {
+ kv = null;
+ error = null;
+ errorPosition = Position.Empty;
+ var t = StripComment(line).TrimStart();
+ if (string.IsNullOrEmpty(t) || t.StartsWith("#", StringComparison.Ordinal))
+ return true; // not a directive line, no error
+ var result = DirectiveLine.TryParse(t);
+ if (result.HasValue)
+ {
+ var (key, value) = result.Value;
+ if (string.IsNullOrWhiteSpace(key))
+ return true;
+ kv = (key, value);
+ return true;
+ }
+ error = result.ToString();
+ errorPosition = result.ErrorPosition;
+ return false;
+ }
+
/// Parse one non-blank, non-comment .conf line into a typed directive. Returns null if line is empty or comment.
public static DnsmasqConfDirective? ParseLine(string line, int lineNumber, string sourceFilePath)
{
diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs
index c073511..383fc71 100644
--- a/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs
+++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs
@@ -1,5 +1,5 @@
-using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
+using DnsmasqWebUI.Models.EffectiveConfig;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
@@ -16,24 +16,24 @@ public static class DnsmasqConfFileLineParser
private enum ConfLineParseKind { Blank, Comment, AddnHosts, DhcpHostCandidate, Other }
private static readonly TextParser<(ConfLineParseKind kind, string content)> Blank =
- Character.WhiteSpace.Many().AtEnd().Select(_ => (ConfLineParseKind.Blank, ""));
+ Character.WhiteSpace.Many().AtEnd().Select(_ => (ConfLineParseKind.Blank, "")).Named("blank line");
private static readonly TextParser<(ConfLineParseKind kind, string content)> Comment =
Character.EqualTo('#').IgnoreThen(Character.AnyChar.Many().Text())
- .Select(_ => (ConfLineParseKind.Comment, ""));
+ .Select(_ => (ConfLineParseKind.Comment, "")).Named("comment");
private static readonly TextParser<(ConfLineParseKind kind, string content)> AddnHosts =
ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo("addn-hosts="))
.IgnoreThen(Character.AnyChar.Many().Text())
- .Select(s => (ConfLineParseKind.AddnHosts, s.Trim()));
+ .Select(s => (ConfLineParseKind.AddnHosts, s.Trim())).Named("addn-hosts line");
private static readonly TextParser<(ConfLineParseKind kind, string content)> DhcpHostCandidate =
ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo("dhcp-host="))
.IgnoreThen(Character.AnyChar.Many())
- .Select(_ => (ConfLineParseKind.DhcpHostCandidate, ""));
+ .Select(_ => (ConfLineParseKind.DhcpHostCandidate, "")).Named("dhcp-host line");
private static readonly TextParser<(ConfLineParseKind kind, string content)> Other =
- Character.AnyChar.Many().Text().Select(s => (ConfLineParseKind.Other, s));
+ Character.AnyChar.Many().Text().Select(s => (ConfLineParseKind.Other, s)).Named("directive or other");
private static readonly TextParser<(ConfLineParseKind kind, string content)> LineParser =
Blank.Try().Or(Comment.Try()).Or(AddnHosts.Try()).Or(DhcpHostCandidate.Try()).Or(Other).AtEnd();
diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs
index 1b89d97..9567183 100644
--- a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs
+++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs
@@ -1,4 +1,7 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.EffectiveConfig;
+using Superpower;
+using Superpower.Model;
+using Superpower.Parsers;
namespace DnsmasqWebUI.Parsers;
@@ -106,19 +109,33 @@ public static class DnsmasqConfIncludeParser
}
}
+ // conf-dir value: comma-delimited fields (dir, optional *suffix, optional ignore suffixes). Superpower grammar.
+ private static readonly TextParser ConfDirField =
+ Character.Matching(c => c != ',' && c != '\r' && c != '\n', "field character").AtLeastOnce().Text()
+ .Then(s => Character.WhiteSpace.Many().IgnoreThen(Parse.Return(s.Trim())));
+
+ private static readonly TextParser> ConfDirValueParser =
+ ConfDirField.AtLeastOnceDelimitedBy(ConfParserHelpers.Token(Character.EqualTo(',')))
+ .AtEnd()
+ .Select(list => list.ToList())
+ .Named("conf-dir value");
+
private static (string? directory, List? matchSuffix, List? ignoreSuffix) ParseConfDirValue(string value)
{
- var parts = value.Split(',').Select(p => p.Trim()).Where(p => p.Length > 0).ToList();
- if (parts.Count == 0) return (null, null, null);
+ var parsed = ConfDirValueParser.TryParse(value.Trim());
+ if (!parsed.HasValue || parsed.Value.Count == 0)
+ return (null, null, null);
+ var parts = parsed.Value;
var dir = parts[0];
var matchSuffix = new List();
var ignoreSuffix = new List();
for (var i = 1; i < parts.Count; i++)
{
- if (parts[i].StartsWith('*'))
- matchSuffix.Add(parts[i].Length > 1 ? parts[i][1..] : "");
+ var p = parts[i];
+ if (p.StartsWith('*'))
+ matchSuffix.Add(p.Length > 1 ? p[1..] : "");
else
- ignoreSuffix.Add(parts[i]);
+ ignoreSuffix.Add(p);
}
return (dir, matchSuffix.Count > 0 ? matchSuffix : null, ignoreSuffix.Count > 0 ? ignoreSuffix : null);
}
diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqLeasesFileLineParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqLeasesFileLineParser.cs
index 767c28c..ce7ae85 100644
--- a/src/DnsmasqWebUI/Parsers/DnsmasqLeasesFileLineParser.cs
+++ b/src/DnsmasqWebUI/Parsers/DnsmasqLeasesFileLineParser.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Dhcp;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
diff --git a/src/DnsmasqWebUI/Parsers/HostsFileLineParser.cs b/src/DnsmasqWebUI/Parsers/HostsFileLineParser.cs
index 8539ec4..ebdba59 100644
--- a/src/DnsmasqWebUI/Parsers/HostsFileLineParser.cs
+++ b/src/DnsmasqWebUI/Parsers/HostsFileLineParser.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Hosts;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
diff --git a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs
index b767323..1e20303 100644
--- a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs
+++ b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs
@@ -1,5 +1,6 @@
-using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
+using DnsmasqWebUI.Models.Dhcp;
+using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs
index e1ae33b..0c321e8 100644
--- a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs
+++ b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/Abstractions/IHostsFileService.cs b/src/DnsmasqWebUI/Services/Abstractions/IHostsFileService.cs
index 880181d..5417bcf 100644
--- a/src/DnsmasqWebUI/Services/Abstractions/IHostsFileService.cs
+++ b/src/DnsmasqWebUI/Services/Abstractions/IHostsFileService.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs b/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs
index 0075315..d39dabd 100644
--- a/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs
+++ b/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/Abstractions/ILeasesFileService.cs b/src/DnsmasqWebUI/Services/Abstractions/ILeasesFileService.cs
index 8777110..0d2ed9e 100644
--- a/src/DnsmasqWebUI/Services/Abstractions/ILeasesFileService.cs
+++ b/src/DnsmasqWebUI/Services/Abstractions/ILeasesFileService.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs
index c710b11..93c3ec8 100644
--- a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs
+++ b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs
@@ -1,6 +1,7 @@
using System.Text;
-using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
+using DnsmasqWebUI.Models.Dhcp;
+using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs
index 3419394..bdb19f5 100644
--- a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs
+++ b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs
index 57c4659..36e6307 100644
--- a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs
+++ b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs
@@ -1,6 +1,6 @@
using System.Text;
-using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
+using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/HostsFileService.cs b/src/DnsmasqWebUI/Services/HostsFileService.cs
index 7ad69fe..aefce6b 100644
--- a/src/DnsmasqWebUI/Services/HostsFileService.cs
+++ b/src/DnsmasqWebUI/Services/HostsFileService.cs
@@ -1,5 +1,6 @@
using System.Text;
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.EffectiveConfig;
+using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/LeasesCache.cs b/src/DnsmasqWebUI/Services/LeasesCache.cs
index 91d0e82..85fa603 100644
--- a/src/DnsmasqWebUI/Services/LeasesCache.cs
+++ b/src/DnsmasqWebUI/Services/LeasesCache.cs
@@ -1,5 +1,5 @@
using System.Text;
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/LeasesFileService.cs b/src/DnsmasqWebUI/Services/LeasesFileService.cs
index d36e1aa..f8538a4 100644
--- a/src/DnsmasqWebUI/Services/LeasesFileService.cs
+++ b/src/DnsmasqWebUI/Services/LeasesFileService.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Services;
diff --git a/testdata/README b/testdata/README
index dda1ae9..18f3567 100644
--- a/testdata/README
+++ b/testdata/README
@@ -17,6 +17,6 @@ Source for the Docker test harness: `scripts/prepare-test-mount.sh` syncs this d
| **hosts** | First addn-hosts file; system hosts when `Dnsmasq:SystemHostsPath` points here. |
| **hosts.extra** | Second addn-hosts file. |
| **resolv.dnsmasq** | Minimal file for resolv-file= in 03-more.conf; must exist so dnsmasq does not fail/warn. |
-| **leases** | Sample DHCP leases for harness leases table. |
+| **leases** | Sample DHCP leases for unit tests only; not synced to the harness. The harness uses the path from config (`dhcp-leasefile=` in dnsmasq-test.conf); the app discovers and monitors that file; dnsmasq creates it when DHCP clients obtain addresses. |
Harness usage (start/stop/tidy): see **agents.md** in the repo root.