Dnsmasq/Hosts UI tweaks, test harness: down before redeploy, exclude leases from mount

- Dnsmasq page: Config (Main config, Managed file, Config set) under Service configuration; remove Paths/Leases (in Effective config DHCP); EffectiveConfigSection collapsible groups; StatusSection flex-wrap
- Hosts: HostsFileSection always shows table; 'No host entries' in tbody when empty
- Models: reorganize into sub-namespaces (Config, EffectiveConfig, Hosts, Dhcp, Status); move EffectiveConfig/Status/Logs components under Dnsmasq
- Test harness: prepare-test-mount.sh runs 'docker compose down' before mount+up so all containers (including DHCP clients) restart; exclude testdata/leases from sync (unit tests only); testdata/README
This commit is contained in:
2026-02-03 01:35:26 +10:00
parent a0863a663e
commit a8ddcdec4e
70 changed files with 542 additions and 303 deletions
-1
View File
@@ -21,7 +21,6 @@ services:
DNSMASQ_CONF: /data/dnsmasq-test.conf
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf
# System hosts: app default /etc/hosts shows the container's hosts file (read-only).
Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
+6 -1
View File
@@ -168,6 +168,10 @@ if [ ! -d "$SOURCE_DIR" ]; then
exit 1
fi
# Take the harness down so all containers (including one-shot DHCP clients) are recreated on up.
echo "Stopping test harness: docker compose -f $COMPOSE_FILE down"
docker compose -f "$COMPOSE_FILE" down
mkdir -p "$MOUNT_DIR"
if [ "$NO_CLEAR" = false ]; then
@@ -176,9 +180,10 @@ if [ "$NO_CLEAR" = false ]; then
fi
if command -v rsync >/dev/null 2>&1; then
rsync -a "$SOURCE_DIR/" "$MOUNT_DIR/"
rsync -a --exclude='leases' "$SOURCE_DIR/" "$MOUNT_DIR/"
else
cp -r "$SOURCE_DIR/." "$MOUNT_DIR/"
rm -f "$MOUNT_DIR/leases"
fi
# Remove any leftover managed config from previous runs so dnsmasq starts clean (app will create zz-dnsmasq-webui.conf on startup).
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
@@ -1,5 +1,6 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Services;
using Microsoft.Extensions.Options;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Client.Http.Abstractions;
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Status;
namespace DnsmasqWebUI.Client.Http.Abstractions;
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Models.Status;
namespace DnsmasqWebUI.Client.Http.Abstractions;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Client.Http.Abstractions;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Status;
namespace DnsmasqWebUI.Client.Http.Abstractions;
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.EffectiveConfig;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
@@ -1,7 +1,8 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Status;
namespace DnsmasqWebUI.Client.Http;
+2 -1
View File
@@ -1,5 +1,6 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Models.Status;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
+1 -1
View File
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
+1 -1
View File
@@ -1,6 +1,6 @@
using System.Text.Json;
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Status;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
@@ -1,4 +1,4 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
<li>
<strong>@Label:</strong> @Content
@@ -1,4 +1,4 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
<li title="@(Source?.GetReadOnlyTooltip())">
<strong>@Label:</strong> @DisplayValue
@@ -0,0 +1,178 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Status
@using DnsmasqWebUI.Models.EffectiveConfig
@if (Status != null)
{
<div class="ec-sections small">
@* Hosts *@
<div class="ec-section">
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("hosts")' aria-expanded="@IsOpen("hosts")">
<span class="ec-section-title fw-bold">Hosts</span>
<i class="bi @(IsOpen("hosts") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen("hosts") ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
<EffectiveConfigOptionItem Label="no-hosts" DisplayValue="@(Status.NoHosts ? "Yes (hosts files disabled)" : "No")" Source="@(Status.EffectiveConfigSources?.NoHosts)" />
<li>
<strong>addn-hosts:</strong>
@if (Status.AddnHostsPaths?.Count > 0)
{
var srcList = Status.EffectiveConfigSources?.AddnHostsPaths;
if (srcList != null && srcList.Count == Status.AddnHostsPaths.Count)
{
@foreach (var entry in srcList)
{
<span class="d-inline-block me-2" title="@(entry.Source?.GetReadOnlyTooltip())">@entry.Path @(entry.Source?.IsReadOnly == true ? " (readonly)" : "")</span>
}
}
else
{
var deduped = (Status.AddnHostsPaths ?? Array.Empty<string>()).Distinct().ToList();
@(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped))
}
}
else { <span class="text-muted">(none)</span> }
</li>
@if (Status.ManagedHostsFilePath != null) { <li><strong>Managed hosts file (editable):</strong> @Status.ManagedHostsFilePath @(Status.ManagedHostsPathExists ? "✓" : "(not created yet)")</li> }
@if (Status.SystemHostsPath != null) { <li><strong>System hosts (read-only):</strong> @Status.SystemHostsPath @(Status.SystemHostsPathExists ? "✓" : "(missing)")</li> }
</ul>
</div>
</div>
</div>
@if (Status.EffectiveConfig != null)
{
var ec = Status.EffectiveConfig;
var src = Status.EffectiveConfigSources;
@* Resolver / DNS *@
<div class="ec-section">
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("resolver")' aria-expanded="@IsOpen("resolver")">
<span class="ec-section-title fw-bold">Resolver / DNS</span>
<i class="bi @(IsOpen("resolver") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen("resolver") ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
<EffectiveConfigOptionItem Label="expand-hosts" DisplayValue="@(ec.ExpandHosts ? "Yes" : "No")" Source="@(src?.ExpandHosts)" />
<EffectiveConfigOptionItem Label="bogus-priv" DisplayValue="@(ec.BogusPriv ? "Yes" : "No")" Source="@(src?.BogusPriv)" />
<EffectiveConfigOptionItem Label="strict-order" DisplayValue="@(ec.StrictOrder ? "Yes" : "No")" Source="@(src?.StrictOrder)" />
<EffectiveConfigOptionItem Label="no-resolv" DisplayValue="@(ec.NoResolv ? "Yes" : "No")" Source="@(src?.NoResolv)" />
<EffectiveConfigOptionItem Label="domain-needed" DisplayValue="@(ec.DomainNeeded ? "Yes" : "No")" Source="@(src?.DomainNeeded)" />
<EffectiveConfigOptionItem Label="port" DisplayValue="@(ec.Port switch { null => "53 (default)", 0 => "0 (no DNS listener)", _ => ec.Port.Value.ToString() })" Source="@(src?.Port)" />
@if (ec.ServerLocalValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="server / local" Content="@((MarkupString)RenderMultiValueList(ec.ServerLocalValues, src?.ServerLocalValues))" /> }
@if (ec.AddressValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="address" Content="@((MarkupString)RenderMultiValueList(ec.AddressValues, src?.AddressValues))" /> }
@if (ec.ResolvFiles?.Count > 0) { <EffectiveConfigMultiValueRow Label="resolv-file" Content="@((MarkupString)RenderMultiValueList(ec.ResolvFiles, src?.ResolvFiles))" /> }
</ul>
</div>
</div>
</div>
@* DHCP *@
<div class="ec-section">
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("dhcp")' aria-expanded="@IsOpen("dhcp")">
<span class="ec-section-title fw-bold">DHCP</span>
<i class="bi @(IsOpen("dhcp") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen("dhcp") ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
<EffectiveConfigOptionItem Label="dhcp-authoritative" DisplayValue="@(ec.DhcpAuthoritative ? "Yes" : "No")" Source="@(src?.DhcpAuthoritative)" />
<EffectiveConfigOptionItem Label="leasefile-ro" DisplayValue="@(ec.LeasefileRo ? "Yes" : "No")" Source="@(src?.LeasefileRo)" />
<EffectiveConfigOptionItem Label="dhcp-leasefile" DisplayValue="@(ec.DhcpLeaseFilePath ?? "(default)")" Source="@(src?.DhcpLeaseFilePath)" />
@if (ec.DhcpLeaseMax.HasValue) { <EffectiveConfigOptionItem Label="dhcp-lease-max" DisplayValue="@ec.DhcpLeaseMax.Value.ToString()" Source="@(src?.DhcpLeaseMax)" /> }
@if (ec.DhcpTtl.HasValue) { <EffectiveConfigOptionItem Label="dhcp-ttl" DisplayValue="@ec.DhcpTtl.Value.ToString()" Source="@(src?.DhcpTtl)" /> }
@if (ec.DhcpRanges?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-range" Content="@((MarkupString)RenderMultiValueList(ec.DhcpRanges, src?.DhcpRanges))" /> }
@if (ec.DhcpHostLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-host" Content="@((MarkupString)RenderMultiValueList(ec.DhcpHostLines, src?.DhcpHostLines))" /> }
@if (ec.DhcpOptionLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-option" Content="@((MarkupString)RenderMultiValueList(ec.DhcpOptionLines, src?.DhcpOptionLines))" /> }
</ul>
</div>
</div>
</div>
@* Cache *@
<div class="ec-section">
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("cache")' aria-expanded="@IsOpen("cache")">
<span class="ec-section-title fw-bold">Cache</span>
<i class="bi @(IsOpen("cache") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen("cache") ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
@if (ec.CacheSize.HasValue) { <EffectiveConfigOptionItem Label="cache-size" DisplayValue="@ec.CacheSize.Value.ToString()" Source="@(src?.CacheSize)" /> }
@if (ec.LocalTtl.HasValue) { <EffectiveConfigOptionItem Label="local-ttl" DisplayValue="@ec.LocalTtl.Value.ToString()" Source="@(src?.LocalTtl)" /> }
<EffectiveConfigOptionItem Label="no-negcache" DisplayValue="@(ec.NoNegcache ? "Yes" : "No")" Source="@(src?.NoNegcache)" />
@if (ec.NegTtl.HasValue) { <EffectiveConfigOptionItem Label="neg-ttl" DisplayValue="@ec.NegTtl.Value.ToString()" Source="@(src?.NegTtl)" /> }
@if (ec.MaxTtl.HasValue) { <EffectiveConfigOptionItem Label="max-ttl" DisplayValue="@ec.MaxTtl.Value.ToString()" Source="@(src?.MaxTtl)" /> }
@if (ec.MaxCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="max-cache-ttl" DisplayValue="@ec.MaxCacheTtl.Value.ToString()" Source="@(src?.MaxCacheTtl)" /> }
@if (ec.MinCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="min-cache-ttl" DisplayValue="@ec.MinCacheTtl.Value.ToString()" Source="@(src?.MinCacheTtl)" /> }
</ul>
</div>
</div>
</div>
@* Process & networking *@
<div class="ec-section">
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("process")' aria-expanded="@IsOpen("process")">
<span class="ec-section-title fw-bold">Process &amp; networking</span>
<i class="bi @(IsOpen("process") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen("process") ? "show" : "")">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
<EffectiveConfigOptionItem Label="no-poll" DisplayValue="@(ec.NoPoll ? "Yes" : "No")" Source="@(src?.NoPoll)" />
<EffectiveConfigOptionItem Label="bind-interfaces" DisplayValue="@(ec.BindInterfaces ? "Yes" : "No")" Source="@(src?.BindInterfaces)" />
@if (ec.Interfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="interface" Content="@((MarkupString)RenderMultiValueList(ec.Interfaces, src?.Interfaces))" /> }
@if (ec.ListenAddresses?.Count > 0) { <EffectiveConfigMultiValueRow Label="listen-address" Content="@((MarkupString)RenderMultiValueList(ec.ListenAddresses, src?.ListenAddresses))" /> }
@if (ec.ExceptInterfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="except-interface" Content="@((MarkupString)RenderMultiValueList(ec.ExceptInterfaces, src?.ExceptInterfaces))" /> }
@if (ec.PidFilePath != null) { <EffectiveConfigOptionItem Label="pid-file" DisplayValue="@ec.PidFilePath" Source="@(src?.PidFilePath)" /> }
@if (ec.User != null) { <EffectiveConfigOptionItem Label="user" DisplayValue="@ec.User" Source="@(src?.User)" /> }
@if (ec.Group != null) { <EffectiveConfigOptionItem Label="group" DisplayValue="@ec.Group" Source="@(src?.Group)" /> }
@if (ec.LogFacility != null) { <EffectiveConfigOptionItem Label="log-facility" DisplayValue="@ec.LogFacility" Source="@(src?.LogFacility)" /> }
</ul>
</div>
</div>
</div>
}
</div>
}
@code {
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
private HashSet<string> _openPanels = new();
private bool IsOpen(string id) => _openPanels.Contains(id);
private void TogglePanel(string id)
{
if (_openPanels.Contains(id))
_openPanels.Remove(id);
else
_openPanels.Add(id);
}
private static MarkupString RenderMultiValueList(IReadOnlyList<string>? values, IReadOnlyList<ValueWithSource>? sources)
{
if (values == null || values.Count == 0)
return (MarkupString)"(none)";
if (sources != null && sources.Count == values.Count)
{
var parts = sources.Select(e =>
{
var s = e.Source;
var valueEnc = System.Net.WebUtility.HtmlEncode(e.Value);
if (s?.IsReadOnly == true)
{
var tipEnc = System.Net.WebUtility.HtmlEncode(s.GetReadOnlyTooltip() ?? "");
return $"{valueEnc} <span class=\"badge bg-secondary ms-1\" title=\"{tipEnc}\">readonly</span>";
}
return valueEnc;
});
return (MarkupString)string.Join(", ", parts);
}
return (MarkupString)System.Net.WebUtility.HtmlEncode(string.Join(", ", values));
}
}
@@ -0,0 +1,79 @@
/* Effective config sections – mobile-first, touch-friendly */
.ec-sections {
display: flex;
flex-direction: column;
gap: 0;
}
.ec-section {
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
}
.ec-section:last-child {
border-bottom: none;
}
/* Touch-friendly header: min 44px tap target (WCAG 2.5.5) */
.ec-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 2.75rem;
padding: 0.4rem 0;
margin: 0;
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
font-size: inherit;
line-height: 1.3;
-webkit-tap-highlight-color: transparent;
}
.ec-section-header:hover {
color: var(--bs-primary, #0d6efd);
}
.ec-section-header:focus-visible {
outline: 2px solid var(--bs-primary, #0d6efd);
outline-offset: 2px;
}
.ec-section-title {
flex: 1;
min-width: 0;
}
.ec-section-chevron {
flex-shrink: 0;
font-size: 0.75rem;
opacity: 0.7;
margin-left: 0.25rem;
}
.ec-section-body {
padding: 0.25rem 0 0.5rem 0;
padding-left: 0.5rem;
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
overflow-wrap: break-word;
word-break: break-word;
}
/* Long paths/values wrap on small screens; badges stay inline */
.ec-section-body ul {
overflow-wrap: break-word;
word-break: break-word;
}
.ec-section-body li {
overflow-wrap: break-word;
word-break: break-word;
}
@media (max-width: 767.98px) {
.ec-section-body {
padding-left: 0.35rem;
}
}
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Status;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Dnsmasq;
@@ -1,9 +1,15 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@* Service status — output of StatusShowCommand; only this component re-renders on its timer *@
@if (_status?.StatusShowConfigured == true)
@if (_status != null && _status.StatusShowConfigured)
{
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between">
<strong>Service status</strong>
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between flex-wrap gap-1">
<span class="d-flex align-items-center gap-1">
<strong>Service status</strong>
@{
var isActive = _status.DnsmasqStatus == "active";
}
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger")" style="width: 0.5rem; height: 0.5rem;" title="@(isActive ? "Running" : "Not running")" aria-hidden="true"></span>
</span>
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Service status polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure service status polling interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button>
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Status;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Dnsmasq;
@@ -1,3 +1,4 @@
@namespace DnsmasqWebUI.Components.Hosts
<div class="hosts-file-section">
<h3 class="h6 @(IsFirst ? "mb-1" : "mt-3 mb-1") @(IsEditable ? "" : "text-muted")">
@Path
@@ -17,27 +18,41 @@
<tr><th>#</th><th>Address</th><th>Names</th></tr>
</thead>
<tbody>
@foreach (var e in (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough))
@if (_visibleEntries.Count == 0)
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
<td>@e.LineNumber</td>
<td>@e.Address</td>
<td>@string.Join(", ", e.Names)</td>
</tr>
<tr><td colspan="3" class="text-muted small">No host entries.</td></tr>
}
else
{
@foreach (var e in _visibleEntries)
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
<td>@e.LineNumber</td>
<td>@e.Address</td>
<td>@string.Join(", ", e.Names)</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="d-md-none table-cards-mobile">
@foreach (var e in (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough))
@if (_visibleEntries.Count == 0)
{
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
<div class="card-body py-2">
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
<div class="table-card-row"><span class="table-card-label">Address</span> @e.Address</div>
<div class="table-card-row"><span class="table-card-label">Names</span> @string.Join(", ", e.Names)</div>
<p class="text-muted small mb-0">No host entries.</p>
}
else
{
@foreach (var e in _visibleEntries)
{
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
<div class="card-body py-2">
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
<div class="table-card-row"><span class="table-card-label">Address</span> @e.Address</div>
<div class="table-card-row"><span class="table-card-label">Names</span> @string.Join(", ", e.Names)</div>
</div>
</div>
</div>
}
}
</div>
</div>
@@ -48,4 +63,11 @@
[Parameter] public IReadOnlyList<HostEntry>? Entries { get; set; }
[Parameter] public bool IsEditable { get; set; }
[Parameter] public bool IsFirst { get; set; }
private IReadOnlyList<HostEntry> _visibleEntries = Array.Empty<HostEntry>();
protected override void OnParametersSet()
{
_visibleEntries = (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough).ToList();
}
}
@@ -1 +0,0 @@
@namespace DnsmasqWebUI.Components.Hosts
+27 -172
View File
@@ -21,18 +21,33 @@ else if (_error != null)
else if (_status != null)
{
<section class="page-section">
<h2 class="page-section-title">Service</h2>
<div class="alert alert-@(_status.DnsmasqStatus == "active" ? "success" : "secondary") py-2 mb-2">
<strong>Dnsmasq:</strong> @GetStatusLabel(_status)
@if (_status.StatusCommandExitCode.HasValue)
{
<span class="ms-1 text-muted small">(exit @_status.StatusCommandExitCode.Value)</span>
}
@if (!string.IsNullOrEmpty(_status.StatusCommandStderr))
{
<span class="ms-1 text-muted small">— @_status.StatusCommandStderr</span>
}
</div>
<h2 class="page-section-title">Service configuration</h2>
<ul class="list-unstyled small mb-0">
<li><strong>Main config:</strong> @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")</li>
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
</ul>
@if (_configSet != null && _configSet.Files?.Count > 0)
{
<p class="mb-1 mt-2"><strong>Config set (load order):</strong></p>
<ul class="list-unstyled small ms-2 mb-0">
@foreach (var f in _configSet.Files)
{
<li>
@if (f.IsManaged)
{
<span class="badge bg-primary me-1">managed</span>
}
else
{
<span class="badge bg-secondary me-1">@(f.Source.ToString().ToLowerInvariant())</span>
}
@f.FileName
<span class="text-muted">(@f.Path)</span>
</li>
}
</ul>
}
<EffectiveConfigSection Status="_status" />
@if (_status.DnsmasqStatus != "active" && (_status.StatusCommandStdout != null || _status.StatusCommandStderr != null))
{
<pre class="pre-block mt-2 mb-0" style="max-height: 12rem;">@(_status.StatusCommandStdout != null ? _status.StatusCommandStdout : "")@(_status.StatusCommandStdout != null && _status.StatusCommandStderr != null ? "\n" : "")@(_status.StatusCommandStderr ?? "")</pre>
@@ -63,135 +78,6 @@ else if (_status != null)
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
</section>
<section class="page-section">
<h2 class="page-section-title">Effective config</h2>
<p class="text-muted small mb-2">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).</p>
<ul class="list-unstyled small">
<EffectiveConfigOptionItem Label="no-hosts" DisplayValue="@(_status.NoHosts ? "Yes (hosts files disabled)" : "No")" Source="@(_status.EffectiveConfigSources?.NoHosts)" />
<li>
<strong>addn-hosts:</strong>
@if (_status.AddnHostsPaths?.Count > 0)
{
var srcList = _status.EffectiveConfigSources?.AddnHostsPaths;
if (srcList != null && srcList.Count == _status.AddnHostsPaths.Count)
{
@foreach (var entry in srcList)
{
<span class="d-inline-block me-2" title="@(entry.Source?.GetReadOnlyTooltip())">@entry.Path @(entry.Source?.IsReadOnly == true ? " (readonly)" : "")</span>
}
}
else
{
var deduped = (_status.AddnHostsPaths ?? Array.Empty<string>()).Distinct().ToList();
@(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped))
}
}
else
{
<span class="text-muted">(none)</span>
}
</li>
@if (_status.ManagedHostsFilePath != null)
{
<li><strong>Managed hosts file (editable):</strong> @_status.ManagedHostsFilePath @(_status.ManagedHostsPathExists ? "✓" : "(not created yet)")</li>
}
@if (_status.SystemHostsPath != null)
{
<li><strong>System hosts (read-only):</strong> @_status.SystemHostsPath @(_status.SystemHostsPathExists ? "✓" : "(missing)")</li>
}
</ul>
@if (_status.EffectiveConfig != null)
{
var ec = _status.EffectiveConfig;
var src = _status.EffectiveConfigSources;
<p class="mb-1 mt-2 text-muted small"><strong>Options</strong> (last wins for single-value; readonly when from a non-managed file):</p>
<ul class="list-unstyled small ms-2">
<EffectiveConfigOptionItem Label="expand-hosts" DisplayValue="@(ec.ExpandHosts ? "Yes" : "No")" Source="@(src?.ExpandHosts)" />
<EffectiveConfigOptionItem Label="bogus-priv" DisplayValue="@(ec.BogusPriv ? "Yes" : "No")" Source="@(src?.BogusPriv)" />
<EffectiveConfigOptionItem Label="strict-order" DisplayValue="@(ec.StrictOrder ? "Yes" : "No")" Source="@(src?.StrictOrder)" />
<EffectiveConfigOptionItem Label="no-resolv" DisplayValue="@(ec.NoResolv ? "Yes" : "No")" Source="@(src?.NoResolv)" />
<EffectiveConfigOptionItem Label="domain-needed" DisplayValue="@(ec.DomainNeeded ? "Yes" : "No")" Source="@(src?.DomainNeeded)" />
<EffectiveConfigOptionItem Label="dhcp-authoritative" DisplayValue="@(ec.DhcpAuthoritative ? "Yes" : "No")" Source="@(src?.DhcpAuthoritative)" />
<EffectiveConfigOptionItem Label="no-poll" DisplayValue="@(ec.NoPoll ? "Yes" : "No")" Source="@(src?.NoPoll)" />
<EffectiveConfigOptionItem Label="bind-interfaces" DisplayValue="@(ec.BindInterfaces ? "Yes" : "No")" Source="@(src?.BindInterfaces)" />
<EffectiveConfigOptionItem Label="no-negcache" DisplayValue="@(ec.NoNegcache ? "Yes" : "No")" Source="@(src?.NoNegcache)" />
<EffectiveConfigOptionItem Label="leasefile-ro" DisplayValue="@(ec.LeasefileRo ? "Yes" : "No")" Source="@(src?.LeasefileRo)" />
<EffectiveConfigOptionItem Label="dhcp-leasefile" DisplayValue="@(ec.DhcpLeaseFilePath ?? "(default)")" Source="@(src?.DhcpLeaseFilePath)" />
@if (ec.CacheSize.HasValue) { <EffectiveConfigOptionItem Label="cache-size" DisplayValue="@ec.CacheSize.Value.ToString()" Source="@(src?.CacheSize)" /> }
<EffectiveConfigOptionItem Label="port" DisplayValue="@(ec.Port switch { null => "53 (default)", 0 => "0 (no DNS listener)", _ => ec.Port.Value.ToString() })" Source="@(src?.Port)" />
@if (ec.LocalTtl.HasValue) { <EffectiveConfigOptionItem Label="local-ttl" DisplayValue="@ec.LocalTtl.Value.ToString()" Source="@(src?.LocalTtl)" /> }
@if (ec.PidFilePath != null) { <EffectiveConfigOptionItem Label="pid-file" DisplayValue="@ec.PidFilePath" Source="@(src?.PidFilePath)" /> }
@if (ec.User != null) { <EffectiveConfigOptionItem Label="user" DisplayValue="@ec.User" Source="@(src?.User)" /> }
@if (ec.Group != null) { <EffectiveConfigOptionItem Label="group" DisplayValue="@ec.Group" Source="@(src?.Group)" /> }
@if (ec.LogFacility != null) { <EffectiveConfigOptionItem Label="log-facility" DisplayValue="@ec.LogFacility" Source="@(src?.LogFacility)" /> }
@if (ec.DhcpLeaseMax.HasValue) { <EffectiveConfigOptionItem Label="dhcp-lease-max" DisplayValue="@ec.DhcpLeaseMax.Value.ToString()" Source="@(src?.DhcpLeaseMax)" /> }
@if (ec.NegTtl.HasValue) { <EffectiveConfigOptionItem Label="neg-ttl" DisplayValue="@ec.NegTtl.Value.ToString()" Source="@(src?.NegTtl)" /> }
@if (ec.MaxTtl.HasValue) { <EffectiveConfigOptionItem Label="max-ttl" DisplayValue="@ec.MaxTtl.Value.ToString()" Source="@(src?.MaxTtl)" /> }
@if (ec.MaxCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="max-cache-ttl" DisplayValue="@ec.MaxCacheTtl.Value.ToString()" Source="@(src?.MaxCacheTtl)" /> }
@if (ec.MinCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="min-cache-ttl" DisplayValue="@ec.MinCacheTtl.Value.ToString()" Source="@(src?.MinCacheTtl)" /> }
@if (ec.DhcpTtl.HasValue) { <EffectiveConfigOptionItem Label="dhcp-ttl" DisplayValue="@ec.DhcpTtl.Value.ToString()" Source="@(src?.DhcpTtl)" /> }
</ul>
<p class="mb-1 mt-2 text-muted small"><strong>Multi-value options</strong> (server/local, address, interface, etc.; each with source when known):</p>
<ul class="list-unstyled small ms-2">
@if (ec.ServerLocalValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="server / local" Content="@((MarkupString)RenderMultiValueList(ec.ServerLocalValues, src?.ServerLocalValues))" /> }
@if (ec.AddressValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="address" Content="@((MarkupString)RenderMultiValueList(ec.AddressValues, src?.AddressValues))" /> }
@if (ec.Interfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="interface" Content="@((MarkupString)RenderMultiValueList(ec.Interfaces, src?.Interfaces))" /> }
@if (ec.ListenAddresses?.Count > 0) { <EffectiveConfigMultiValueRow Label="listen-address" Content="@((MarkupString)RenderMultiValueList(ec.ListenAddresses, src?.ListenAddresses))" /> }
@if (ec.ExceptInterfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="except-interface" Content="@((MarkupString)RenderMultiValueList(ec.ExceptInterfaces, src?.ExceptInterfaces))" /> }
@if (ec.DhcpRanges?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-range" Content="@((MarkupString)RenderMultiValueList(ec.DhcpRanges, src?.DhcpRanges))" /> }
@if (ec.DhcpHostLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-host" Content="@((MarkupString)RenderMultiValueList(ec.DhcpHostLines, src?.DhcpHostLines))" /> }
@if (ec.DhcpOptionLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-option" Content="@((MarkupString)RenderMultiValueList(ec.DhcpOptionLines, src?.DhcpOptionLines))" /> }
@if (ec.ResolvFiles?.Count > 0) { <EffectiveConfigMultiValueRow Label="resolv-file" Content="@((MarkupString)RenderMultiValueList(ec.ResolvFiles, src?.ResolvFiles))" /> }
</ul>
}
</section>
<section class="page-section">
<h2 class="page-section-title">Config</h2>
<ul class="list-unstyled small">
<li><strong>Main config:</strong> @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")</li>
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
</ul>
@if (_configSet != null && _configSet.Files?.Count > 0)
{
<p class="mb-1 mt-2"><strong>Config set (load order):</strong></p>
<ul class="list-unstyled small ms-2">
@foreach (var f in _configSet.Files)
{
<li>
@if (f.IsManaged)
{
<span class="badge bg-primary me-1">managed</span>
}
else
{
<span class="badge bg-secondary me-1">@(f.Source.ToString().ToLowerInvariant())</span>
}
@f.FileName
<span class="text-muted">(@f.Path)</span>
</li>
}
</ul>
}
</section>
<section class="page-section">
<h2 class="page-section-title">Paths</h2>
<ul class="list-unstyled small">
<li>
<strong>Leases file:</strong> @(_status.LeasesPath ?? "—")
@if (_status.LeasesPathConfigured)
{
<span>@(_status.LeasesPathExists ? "✓" : "(missing)")</span>
}
else
{
<span class="text-muted">(not configured)</span>
}
</li>
</ul>
</section>
}
@code {
@@ -254,18 +140,6 @@ else if (_status != null)
private void OpenRecentLogsSettings() => OpenSettingsModal(SettingsModalContext.LogsPolling, "Recent logs polling");
private static string GetStatusLabel(DnsmasqServiceStatus s)
{
return s.DnsmasqStatus switch
{
"active" => "Running",
"inactive" => "Not running",
"unknown" => "Unknown",
"notConfigured" => "Status check not configured",
_ => s.DnsmasqStatus ?? "—"
};
}
private async Task ReloadDnsmasq()
{
if (_status == null || !_status.ReloadCommandConfigured || _reloading) return;
@@ -331,25 +205,6 @@ else if (_status != null)
StateHasChanged();
}
/// <summary>Renders a multi-value list with per-value source (readonly/editable + tooltip) when EffectiveConfigSources matches; otherwise comma-separated. Returns MarkupString so Blazor renders raw HTML.</summary>
private static MarkupString RenderMultiValueList(IReadOnlyList<string>? values, IReadOnlyList<ValueWithSource>? sources)
{
if (values == null || values.Count == 0)
return (MarkupString)"(none)";
if (sources != null && sources.Count == values.Count)
{
var parts = sources.Select(e =>
{
var src = e.Source;
var tip = src?.GetReadOnlyTooltip() ?? "";
var label = src == null ? "?" : (src.IsReadOnly ? "from " + src.FileName + ", readonly" : "editable");
return $"{System.Net.WebUtility.HtmlEncode(e.Value)} <span class=\"text-muted\" title=\"{System.Net.WebUtility.HtmlEncode(tip)}\">({System.Net.WebUtility.HtmlEncode(label)})</span>";
});
return (MarkupString)string.Join(", ", parts);
}
return (MarkupString)System.Net.WebUtility.HtmlEncode(string.Join(", ", values));
}
public void Dispose()
{
_cts.Cancel();
@@ -38,10 +38,7 @@ else
<div class="alert alert-success">@_message</div>
}
<p class="text-muted small mb-2">Each hosts file below is rendered with the same component; only the managed file is editable. System hosts is excluded when <strong>no-hosts</strong> is set (dnsmasq does not read it then).</p>
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
<p class="text-muted small">@_status.ManagedHostsFilePath @(!_status.ManagedHostsPathExists ? "(not created yet; will be created on first save)" : "")</p>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
@if (_readOnlyHosts?.Count > 0)
+6 -2
View File
@@ -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
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Status;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
@@ -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;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
@@ -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;
@@ -1,5 +1,5 @@
using System.Text.Json.Serialization;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Models.Config;
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Dhcp;
public class DhcpHostEntry
{
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Dhcp;
public class LeaseEntry
{
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Dhcp;
/// <summary>Result of GET api/leases: whether leases are available and the list of entries.</summary>
public record LeasesResult(bool Available, IReadOnlyList<LeaseEntry>? Entries, string? Message);
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.EffectiveConfig;
/// <summary>
/// Where an effective config value came from (which file). Used so the UI can show exactly which file
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.EffectiveConfig;
/// <summary>Source of a file in the dnsmasq config set: main config, conf-file=, or conf-dir=.</summary>
public enum DnsmasqConfFileSource
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.EffectiveConfig;
/// <summary>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.</summary>
public record DnsmasqConfigSet(
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.EffectiveConfig;
/// <summary>One file in the dnsmasq config set (main or included). IsManaged is true only for the app-managed file.</summary>
public record DnsmasqConfigSetEntry(string Path, string FileName, DnsmasqConfFileSource Source, bool IsManaged);
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.EffectiveConfig;
/// <summary>
/// Source file for each field in <see cref="EffectiveDnsmasqConfig"/>.
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.EffectiveConfig;
/// <summary>
/// Effective dnsmasq config after reading all config files (main + conf-file + conf-dir)
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Hosts;
public class HostEntry
{
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Hosts;
/// <summary>Path and parsed entries for a read-only addn-hosts file (not the managed hosts file).</summary>
public record ReadOnlyHostsFile(string Path, IReadOnlyList<HostEntry> Entries);
@@ -1,4 +1,6 @@
namespace DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Models.Status;
/// <summary>
/// Dnsmasq service and config status returned by GET api/status.
@@ -1,6 +1,6 @@
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Status;
/// <summary>Result of a save operation that triggers a dnsmasq reload (e.g. PUT api/hosts, PUT api/dhcp/hosts).</summary>
public record SaveWithReloadResult(bool Saved, ReloadResult Reload);
@@ -27,4 +27,46 @@ public static class ConfParserHelpers
.Or(Character.EqualTo('#').Value(Unit.Value))
.OptionalOrDefault(Unit.Value)
.Select(_ => Unit.Value);
/// <summary>
/// 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.
/// </summary>
public static readonly TextParser<string> 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);
};
/// <summary>
/// Key (no '='), optional spaces, optional '=value'. Full line. Reusable for key=value config lines.
/// </summary>
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)));
}
@@ -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;
/// </summary>
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<string> 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<Unit> 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;
}
@@ -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;
/// </summary>
public static class DnsmasqConfDirectiveParser
{
// Key: non-= chars, trimmed
private static readonly TextParser<string> 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");
/// <summary>Strip dnsmasq-style comment: from first '#' that is at word start (after whitespace) to end of line.</summary>
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();
}
/// <summary>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);
}
/// <summary>Like <see cref="TryParseKeyValue"/> but when parsing fails returns the Superpower error message and position (line/column).</summary>
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;
}
/// <summary>Parse one non-blank, non-comment .conf line into a typed directive. Returns null if line is empty or comment.</summary>
public static DnsmasqConfDirective? ParseLine(string line, int lineNumber, string sourceFilePath)
{
@@ -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();
@@ -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<string> 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<List<string>> ConfDirValueParser =
ConfDirField.AtLeastOnceDelimitedBy(ConfParserHelpers.Token(Character.EqualTo(',')))
.AtEnd()
.Select(list => list.ToList())
.Named("conf-dir value");
private static (string? directory, List<string>? matchSuffix, List<string>? 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<string>();
var ignoreSuffix = new List<string>();
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);
}
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Hosts;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
@@ -1,5 +1,6 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Services.Abstractions;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Services.Abstractions;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Services.Abstractions;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Services.Abstractions;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Services.Abstractions;
@@ -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;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
@@ -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;
@@ -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;
+1 -1
View File
@@ -1,5 +1,5 @@
using System.Text;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Services;
+1 -1
View File
@@ -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.