Effective config with source tracking, Dnsmasq page, and test harness

- Effective config: track value sources (ConfigValueSource, EffectiveConfigSources)
  and show readonly badges/tooltips for non-managed files. Multi-value options
  use ValueWithSource/PathWithSource for JSON. StatusClient uses camelCase
  serialization for reliable deserialization.
- Parsing: align with dnsmasq (conf-file/conf-dir order, suffix filter, case-
  sensitive keys). DnsmasqConfIncludeParser and DnsmasqConfigSetService build
  effective config and sources. New DnsmasqConfigSetServiceTests.
- UI: rename Overview/Home to Dnsmasq (page, nav, doc comments). Add
  EffectiveConfigOptionItem and EffectiveConfigMultiValueRow components.
  Dnsmasq.razor uses them; Hosts.razor shows addn-hosts with source. Encode
  server-derived strings in markup to avoid XSS.
- Home section namespace: Components.Home -> Components.Dnsmasq (LogsSection,
  StatusSection, _Imports).
- Test data: expand testdata (README, resolv.dnsmasq, hosts.extra, dnsmasq.d
  configs). dhcp.conf static IP aligned with testnet.
- agents.md: add agent instructions for test harness and conventions.
This commit is contained in:
2026-02-02 12:12:01 +10:00
parent a60d0e8db8
commit 888bc980cf
39 changed files with 1204 additions and 107 deletions
+80
View File
@@ -0,0 +1,80 @@
# Agent instructions: dnsmasq-webui
## Test harness (Docker)
The test harness runs the app and dnsmasq in Docker with DHCP clients so you can test the UI and effective config against real dnsmasq.
**Script:** `scripts/prepare-test-mount.sh`
**Compose file:** `docker-compose.test.yml`
### Start the harness
From the repo root:
```bash
./scripts/prepare-test-mount.sh
```
This will:
1. Clear `testdata-mount/` (unless you pass `--no-clear`)
2. Sync `testdata/` → `testdata-mount/`
3. Remove any `*dnsmasq-webui*.conf` in the mount so dnsmasq starts clean
4. Run `docker compose -f docker-compose.test.yml up -d --build`
- **Quick restart (no image rebuild):** `./scripts/prepare-test-mount.sh --no-build`
- **Prepare mount only (no start):** `./scripts/prepare-test-mount.sh --prepare-only`
Then start manually: `TESTDATA_MOUNT=./testdata-mount docker compose -f docker-compose.test.yml up -d [--build]`
- **Preserve mount (e.g. keep leases):** `./scripts/prepare-test-mount.sh --no-clear --no-build`
App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory.
### Stop the harness
```bash
./scripts/prepare-test-mount.sh --stop
```
Runs `docker compose -f docker-compose.test.yml down` (stops and removes containers/networks). Does not change the mount directory.
### Clean (stop + clear mount)
```bash
./scripts/prepare-test-mount.sh --tidy
```
Stops the harness and deletes the contents of `testdata-mount/` so the next run starts from a clean sync of `testdata/`.
### Custom paths
- `--source DIR` — Source to sync from (default: `testdata`)
- `--mount DIR` — Mount directory (default: `testdata-mount`). The script exports `TESTDATA_MOUNT=./DIR` when you use `--mount`, so compose uses it.
Example: sync from a custom dir and start:
```bash
./scripts/prepare-test-mount.sh --source myfixtures --mount mymount
```
### Help
```bash
./scripts/prepare-test-mount.sh -h
```
## Unit tests
From the repo root:
```bash
dotnet test
```
Build only: `dotnet build`
## Test data
- **testdata/** — Source for the harness mount. Synced to `testdata-mount/` by `prepare-test-mount.sh`. Unit tests read from the copy in the test output dir. See **testdata/README** for the full layout.
- **testdata/dnsmasq-test.conf** — Harness main config (`/data/dnsmasq-test.conf`). Richer config: multiple `server=`, `addn-hosts=`, `address=`, `listen-address=`, `conf-dir=/data/dnsmasq.d`.
- **testdata/dnsmasq.d/** — Included configs (01-other.conf, 02-servers.conf, dhcp.conf). App creates `zz-dnsmasq-webui.conf` here when running.
- **testdata/hosts**, **testdata/hosts.extra** — Addn-hosts files so effective config has multiple addn-hosts; UI can show source per path.
@@ -61,7 +61,7 @@ public class DnsmasqConfDirectiveParserTests
[Fact]
public void ParseLine_DhcpLeaseFile_AlternativeKey_ReturnsDhcpLeaseFileOption()
{
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-lease-file=/run/dnsmasq.leases", 1, SourcePath);
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-lease=/run/dnsmasq.leases", 1, SourcePath);
Assert.NotNull(d);
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, d!.Kind);
var opt = Assert.IsType<DhcpLeaseFileOption>(d.TypedOption);
@@ -220,4 +220,22 @@ public class DnsmasqConfDirectiveParserTests
Assert.Equal("aa:bb:cc:dd:ee:ff", opt.MacAddresses[0]);
Assert.Equal("192.168.1.10", opt.Address);
}
/// <summary>dnsmasq read_file: strip from first '#' that is at word start (after whitespace) to EOL.</summary>
[Fact]
public void StripComment_CommentAfterSpace_StripsToEnd()
{
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # DNS port"));
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # comment"));
}
/// <summary>TryParseKeyValue uses StripComment; value should not include # or rest of line.</summary>
[Fact]
public void TryParseKeyValue_LineWithComment_ValueExcludesComment()
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53 # DNS port");
Assert.NotNull(kv);
Assert.Equal("port", kv!.Value.key);
Assert.Equal("53", kv.Value.value);
}
}
@@ -166,6 +166,27 @@ public class DnsmasqConfIncludeParserTests
Assert.Equal(Path.GetFullPath("/data/leases"), result);
}
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
[Fact]
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
{
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
if (!File.Exists(mainPath))
return;
var paths = new[] { mainPath };
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, new[] { "server", "local" });
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "address");
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "listen-address");
Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines");
Assert.Contains("1.1.1.1", servers);
Assert.Contains("8.8.8.8", servers);
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
Assert.Single(listenAddrs);
Assert.Equal("172.28.0.2", listenAddrs[0]);
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
{
@@ -365,4 +386,209 @@ public class DnsmasqConfIncludeParserTests
var result = DnsmasqConfIncludeParser.ResolvePath(abs, "/some/dir");
Assert.Equal(Path.GetFullPath(abs), result);
}
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
[Fact]
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "expand-hosts=1\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "expand-hosts");
Assert.False(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
[Fact]
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "Port=53\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, "port");
Assert.Null(value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
[Fact]
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var main = Path.Combine(dir, "dnsmasq.conf");
var extra = Path.Combine(dir, "extra.conf");
try
{
File.WriteAllText(main, "port=53\nconf-file=extra.conf\nport=5353\n");
File.WriteAllText(extra, "port=54\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "port");
Assert.Equal("54", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
[Fact]
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
try
{
File.WriteAllText(Path.Combine(subDir, "a.conf"), "");
File.WriteAllText(Path.Combine(subDir, "b.txt"), "");
File.WriteAllText(Path.Combine(subDir, "c.conf"), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,*.conf\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count);
Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal));
Assert.Contains(paths, p => p.EndsWith("c.conf", StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith("b.txt", StringComparison.Ordinal));
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
[Fact]
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
try
{
File.WriteAllText(Path.Combine(subDir, "a.conf"), "");
File.WriteAllText(Path.Combine(subDir, "b.conf.bak"), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,.bak\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith("b.conf.bak", StringComparison.Ordinal));
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var managedPath = Path.Combine(dir, "managed.conf");
try
{
File.WriteAllText(f1, "addn-hosts=/etc/hosts.a\n");
File.WriteAllText(f2, "addn-hosts=/etc/hosts.b\naddn-hosts=/etc/hosts.c\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath("/etc/hosts.a"), result[0].Path);
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
Assert.False(result[0].Source.IsManaged);
Assert.Equal(Path.GetFullPath("/etc/hosts.b"), result[1].Path);
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
Assert.Equal(Path.GetFullPath("/etc/hosts.c"), result[2].Path);
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedPath = Path.Combine(dir, "zz-managed.conf");
try
{
File.WriteAllText(mainPath, "addn-hosts=/other/hosts\n");
File.WriteAllText(managedPath, "addn-hosts=/managed/hosts\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
Assert.Equal(2, result.Count);
Assert.False(result[0].Source.IsManaged);
Assert.True(result[1].Source.IsManaged);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
try
{
File.WriteAllText(f1, "server=1.1.1.1\nlocal=/local/\n");
File.WriteAllText(f2, "server=/example.com/192.168.1.1\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, new[] { "server", "local" });
Assert.Equal(3, result.Count);
Assert.Equal("1.1.1.1", result[0]);
Assert.Equal("/local/", result[1]);
Assert.Equal("/example.com/192.168.1.1", result[2]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dhcp-range=172.28.0.10,172.28.0.50,12h\ndhcp-range=192.168.1.10,192.168.1.100,255.255.255.0,24h\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, "dhcp-range");
Assert.Equal(2, result.Count);
Assert.Equal("172.28.0.10,172.28.0.50,12h", result[0]);
Assert.Equal("192.168.1.10,192.168.1.100,255.255.255.0,24h", result[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -37,7 +37,7 @@ public class DnsmasqConfOptionRegistryTests
public void GetKind_DhcpLeasefile_ReturnsDhcpLeaseFile()
{
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-leasefile"));
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-lease-file"));
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-lease"));
}
[Fact]
@@ -76,7 +76,7 @@ public class DnsmasqConfOptionRegistryTests
var registry = DnsmasqConfOptionRegistry.OptionKindByKey;
Assert.True(registry.ContainsKey("addn-hosts"));
Assert.True(registry.ContainsKey("dhcp-leasefile"));
Assert.True(registry.ContainsKey("dhcp-lease-file"));
Assert.True(registry.ContainsKey("dhcp-lease"));
Assert.True(registry.ContainsKey("conf-file"));
Assert.True(registry.ContainsKey("conf-dir"));
Assert.True(registry.ContainsKey("domain"));
@@ -0,0 +1,72 @@
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Services;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for DnsmasqConfigSetService, including GetEffectiveConfigWithSources.
/// </summary>
public class DnsmasqConfigSetServiceTests
{
[Fact]
public void GetEffectiveConfigWithSources_NoMainPath_ReturnsDefaultConfigAndDefaultSources()
{
var options = Options.Create(new DnsmasqOptions { MainConfigPath = "" });
var service = new DnsmasqConfigSetService(options);
var (config, sources) = service.GetEffectiveConfigWithSources();
Assert.NotNull(config);
Assert.False(config.NoHosts);
Assert.Empty(config.AddnHostsPaths);
Assert.NotNull(sources);
Assert.Empty(sources.AddnHostsPaths);
Assert.Empty(sources.ServerLocalValues);
Assert.Empty(sources.DhcpRanges);
Assert.Null(sources.NoHosts);
Assert.Null(sources.Port);
Assert.Null(sources.DhcpLeaseFilePath);
}
[Fact]
public void GetEffectiveConfigWithSources_WithTempConfig_ReturnsConfigAndSourcesFromFiles()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-svc-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedPath = Path.Combine(dir, "zz-dnsmasq-webui.conf");
try
{
File.WriteAllText(mainPath, "port=53\ncache-size=500\naddn-hosts=/etc/hosts.extra\n");
var options = Options.Create(new DnsmasqOptions
{
MainConfigPath = mainPath,
ManagedFileName = "zz-dnsmasq-webui.conf"
});
var service = new DnsmasqConfigSetService(options);
var (config, sources) = service.GetEffectiveConfigWithSources();
Assert.NotNull(config);
Assert.Equal(53, config.Port);
Assert.Equal(500, config.CacheSize);
Assert.Single(config.AddnHostsPaths);
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), config.AddnHostsPaths[0]);
Assert.Empty(config.ServerLocalValues);
Assert.Empty(config.DhcpRanges);
Assert.NotNull(sources);
Assert.NotNull(sources.Port);
Assert.Equal(Path.GetFileName(mainPath), sources.Port!.FileName);
Assert.NotNull(sources.CacheSize);
Assert.Single(sources.AddnHostsPaths);
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), sources.AddnHostsPaths[0].Path);
Assert.NotNull(sources.AddnHostsPaths[0].Source);
Assert.Equal(Path.GetFileName(mainPath), sources.AddnHostsPaths[0].Source!.FileName);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -11,6 +11,15 @@ public class EffectiveDnsmasqConfigTests
new(
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
ServerLocalValues: Array.Empty<string>(),
AddressValues: Array.Empty<string>(),
Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(),
ExceptInterfaces: Array.Empty<string>(),
DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(),
DhcpOptionLines: Array.Empty<string>(),
ResolvFiles: Array.Empty<string>(),
ExpandHosts: false,
BogusPriv: false,
StrictOrder: false,
+9 -1
View File
@@ -1,3 +1,4 @@
using System.Text.Json;
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
@@ -8,9 +9,16 @@ public sealed class StatusClient : IStatusClient
{
private readonly HttpClient _http;
/// <summary>Matches API serialization (camelCase) so nested EffectiveConfigSources and tuple (value, source) deserialize correctly.</summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public StatusClient(HttpClient http) => _http = http;
public async Task<DnsmasqServiceStatus> GetStatusAsync(CancellationToken ct = default) =>
await _http.GetFromJsonAsync<DnsmasqServiceStatus>("api/status", ct)
await _http.GetFromJsonAsync<DnsmasqServiceStatus>("api/status", JsonOptions, ct)
?? throw new InvalidOperationException("Unexpected null from api/status.");
}
+1 -1
View File
@@ -23,5 +23,5 @@
</html>
@code {
private string _appTitle => AppOptions!.Value!.EffectiveTitle;
private string? _appTitle => AppOptions?.Value?.EffectiveTitle;
}
@@ -0,0 +1,10 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
<li>
<strong>@Label:</strong> @Content
</li>
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public MarkupString Content { get; set; }
}
@@ -0,0 +1,15 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
<li title="@(Source?.GetReadOnlyTooltip())">
<strong>@Label:</strong> @DisplayValue
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
</li>
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public string DisplayValue { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
}
@@ -1,4 +1,4 @@
@namespace DnsmasqWebUI.Components.Home
@namespace DnsmasqWebUI.Components.Dnsmasq
@* Recent logs — live view from LogsCommand; only this component re-renders on its timer *@
@if (_status != null && (_status.LogsConfigured || _status.LogsPath != null))
{
@@ -2,7 +2,7 @@ using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Home;
namespace DnsmasqWebUI.Components.Dnsmasq;
/// <summary>
/// Recent logs block: polls LogsCommand output at its own interval and re-renders only itself.
@@ -1,4 +1,4 @@
@namespace DnsmasqWebUI.Components.Home
@namespace DnsmasqWebUI.Components.Dnsmasq
@* Service status — output of StatusShowCommand; only this component re-renders on its timer *@
@if (_status?.StatusShowConfigured == true)
{
@@ -2,7 +2,7 @@ using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Home;
namespace DnsmasqWebUI.Components.Dnsmasq;
/// <summary>
/// Service status block: polls StatusShowCommand output at its own interval and re-renders only itself.
@@ -16,7 +16,7 @@
<nav class="nav flex-column" aria-label="Main navigation">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<i class="bi bi-house-door-fill nav-icon" aria-hidden="true"></i> Overview
<i class="bi bi-house-door-fill nav-icon" aria-hidden="true"></i> Dnsmasq
</NavLink>
</div>
<div class="nav-item px-3">
@@ -33,5 +33,5 @@
</div>
@code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private string? AppTitle => AppOptions?.Value?.EffectiveTitle;
}
@@ -8,7 +8,7 @@
@inject IOptions<ApplicationOptions> AppOptions
@implements IDisposable
<PageTitle>Overview | @AppTitle</PageTitle>
<PageTitle>Dnsmasq | @AppTitle</PageTitle>
@if (_loading)
{
@@ -66,15 +66,26 @@ else if (_status != null)
<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). Only single-value / overridable options are shown here.</p>
<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">
<li><strong>no-hosts:</strong> @(_status.NoHosts ? "Yes (hosts files disabled)" : "No")</li>
<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 deduped = _status.AddnHostsPaths.Distinct().ToList();
@(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped))
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
{
@@ -89,22 +100,45 @@ else if (_status != null)
@if (_status.EffectiveConfig != null)
{
var ec = _status.EffectiveConfig;
<p class="mb-1 mt-2 text-muted small"><strong>Overridable options</strong> (last wins; future UI will allow editing via managed file):</p>
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">
<li><strong>expand-hosts:</strong> @(ec.ExpandHosts ? "Yes" : "No")</li>
<li><strong>bogus-priv:</strong> @(ec.BogusPriv ? "Yes" : "No")</li>
<li><strong>strict-order:</strong> @(ec.StrictOrder ? "Yes" : "No")</li>
<li><strong>no-resolv:</strong> @(ec.NoResolv ? "Yes" : "No")</li>
<li><strong>domain-needed:</strong> @(ec.DomainNeeded ? "Yes" : "No")</li>
<li><strong>dhcp-authoritative:</strong> @(ec.DhcpAuthoritative ? "Yes" : "No")</li>
<li><strong>dhcp-leasefile:</strong> @(ec.DhcpLeaseFilePath ?? "(default)")</li>
@if (ec.CacheSize.HasValue) { <li><strong>cache-size:</strong> @ec.CacheSize.Value</li> }
<li><strong>port:</strong> @(ec.Port switch { null => "53 (default)", 0 => "0 (no DNS listener)", _ => ec.Port.Value.ToString() })</li>
@if (ec.LocalTtl.HasValue) { <li><strong>local-ttl:</strong> @ec.LocalTtl.Value</li> }
@if (ec.PidFilePath != null) { <li><strong>pid-file:</strong> @ec.PidFilePath</li> }
@if (ec.User != null) { <li><strong>user:</strong> @ec.User</li> }
@if (ec.Group != null) { <li><strong>group:</strong> @ec.Group</li> }
@if (ec.DhcpLeaseMax.HasValue) { <li><strong>dhcp-lease-max:</strong> @ec.DhcpLeaseMax.Value</li> }
<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>
@@ -194,7 +228,7 @@ else if (_status != null)
}
catch
{
// Config set is optional for overview
// Config set is optional for Dnsmasq page
}
}
}
@@ -293,6 +327,25 @@ 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();
+21 -2
View File
@@ -20,7 +20,7 @@ else if (_status.NoHosts && !_pathInAddnHosts)
<p class="text-muted">Hosts editing is disabled: <strong>no-hosts</strong> is set in dnsmasq config (dnsmasq only uses addn-hosts files) and the configured path is not in the effective addn-hosts list, so dnsmasq would not read the file.</p>
@if (_status.AddnHostsPaths?.Count > 0)
{
<p class="text-muted small">Effective addn-hosts paths: @string.Join(", ", _status.AddnHostsPaths)</p>
<p class="text-muted small">Effective addn-hosts paths: @RenderAddnHostsList()</p>
}
}
else if (string.IsNullOrEmpty(_status.SystemHostsPath))
@@ -28,7 +28,7 @@ else if (string.IsNullOrEmpty(_status.SystemHostsPath))
<p class="text-muted">No system hosts file configured. Set <strong>Dnsmasq:SystemHostsPath</strong> (e.g. /etc/hosts) to edit a hosts file from this app.</p>
@if (_status.AddnHostsPaths?.Count > 0)
{
<p class="text-muted small">Dnsmasq loads these addn-hosts files (read-only here): @string.Join(", ", _status.AddnHostsPaths)</p>
<p class="text-muted small">Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()</p>
}
}
else if (!_status.SystemHostsPathExists)
@@ -97,6 +97,25 @@ else
&& !string.IsNullOrEmpty(_status.SystemHostsPath)
&& _status.AddnHostsPaths?.Any(p => string.Equals(p, Path.GetFullPath(_status.SystemHostsPath!.Trim()), StringComparison.Ordinal)) == true;
/// <summary>Returns MarkupString so Blazor renders raw HTML (path + source label).</summary>
private MarkupString RenderAddnHostsList()
{
if (_status?.AddnHostsPaths == null || _status.AddnHostsPaths.Count == 0)
return (MarkupString)"(none)";
var srcList = _status.EffectiveConfigSources?.AddnHostsPaths;
if (srcList != null && srcList.Count == _status.AddnHostsPaths.Count)
{
return (MarkupString)string.Join(", ", srcList.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.Path)} <span class=\"text-muted\" title=\"{System.Net.WebUtility.HtmlEncode(tip)}\">({System.Net.WebUtility.HtmlEncode(label)})</span>";
}));
}
return (MarkupString)System.Net.WebUtility.HtmlEncode(string.Join(", ", _status.AddnHostsPaths));
}
protected override async Task OnInitializedAsync()
{
await Load();
+2 -1
View File
@@ -11,7 +11,8 @@
@using DnsmasqWebUI.Client.Services.Abstractions
@using DnsmasqWebUI.Components
@using DnsmasqWebUI.Components.Modals
@using DnsmasqWebUI.Components.Home
@using DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Components.Dnsmasq
@using DnsmasqWebUI.Models
@using DnsmasqWebUI.Configuration
@using DnsmasqWebUI.Client.Http.Abstractions
@@ -32,9 +32,9 @@ public class DnsmasqOptions
/// <summary>Optional command to check dnsmasq service state (e.g. "systemctl is-active dnsmasq" or "pgrep -x dnsmasq"). Runs in the same environment as the app; if app is in a container, this checks for dnsmasq in the container, not on the host.</summary>
public string? StatusCommand { get; set; }
/// <summary>Optional command for full service status (e.g. "systemctl status dnsmasq --no-pager"). Output shown on Overview.</summary>
/// <summary>Optional command for full service status (e.g. "systemctl status dnsmasq --no-pager"). Output shown on Dnsmasq page.</summary>
public string? StatusShowCommand { get; set; }
/// <summary>Optional command for recent logs (e.g. "journalctl -u dnsmasq -n 100 --no-pager"). Output shown on Overview.</summary>
/// <summary>Optional command for recent logs (e.g. "journalctl -u dnsmasq -n 100 --no-pager"). Output shown on Dnsmasq page.</summary>
public string? LogsCommand { get; set; }
}
@@ -31,7 +31,7 @@ public class StatusController : ControllerBase
{
var set = await _configSetService.GetConfigSetAsync(ct);
var effectiveLeasesPath = _configSetService.GetLeasesPath();
var effectiveConfig = _configSetService.GetEffectiveConfig();
var (effectiveConfig, effectiveConfigSources) = _configSetService.GetEffectiveConfigWithSources();
var (dhcpRangeStart, dhcpRangeEnd) = _configSetService.GetDhcpRange();
var systemHostsPath = _options.SystemHostsPath?.Trim();
@@ -65,6 +65,7 @@ public class StatusController : ControllerBase
NoHosts: effectiveConfig.NoHosts,
AddnHostsPaths: effectiveConfig.AddnHostsPaths,
EffectiveConfig: effectiveConfig,
EffectiveConfigSources: effectiveConfigSources,
MainConfigPath: _options.MainConfigPath,
ManagedFilePath: set.ManagedFilePath,
LeasesPath: effectiveLeasesPath,
@@ -127,7 +128,7 @@ public class StatusController : ControllerBase
[HttpGet("logs/download")]
public async Task<IActionResult> GetLogsDownload(CancellationToken ct)
{
var effectiveConfig = _configSetService.GetEffectiveConfig();
var (effectiveConfig, _) = _configSetService.GetEffectiveConfigWithSources();
var logsPath = EffectiveDnsmasqConfig.GetLogsPath(effectiveConfig);
if (string.IsNullOrEmpty(logsPath))
return NotFound();
@@ -13,7 +13,7 @@ public enum DnsmasqConfLineKind
Other
}
/// <summary>One line of a dnsmasq .conf file (managed file only). Use the concrete type (BlankLine, CommentLine, AddnHostsLine, DhcpHostLine, OtherLine). Round-trip via DnsmasqConfFileLineParser.ParseFile / ToLine.</summary>
/// <summary>One line of a dnsmasq .conf file, used for the managed file only. Use the concrete type (BlankLine, CommentLine, AddnHostsLine, DhcpHostLine, OtherLine). Round-trip via DnsmasqConfFileLineParser.ParseFile / ToLine. The same directive types (addn-hosts=, etc.) can appear in any conf file; we use DnsmasqConfLine only for the single managed file because that is the only file we parse into structured lines and write back. Effective config (and where each value came from) is built from all files via DnsmasqConfIncludeParser and GetEffectiveConfigWithSources.</summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(BlankLine), "blank")]
[JsonDerivedType(typeof(CommentLine), "comment")]
@@ -17,7 +17,7 @@ public static class DnsmasqConfOptionRegistry
// Path-valued
{ "addn-hosts", DnsmasqOptionKind.AddnHosts },
{ "dhcp-leasefile", DnsmasqOptionKind.DhcpLeaseFile },
{ "dhcp-lease-file", DnsmasqOptionKind.DhcpLeaseFile },
{ "dhcp-lease", DnsmasqOptionKind.DhcpLeaseFile },
{ "resolv-file", DnsmasqOptionKind.Path },
{ "dhcp-hostsfile", DnsmasqOptionKind.Path },
{ "dhcp-optsfile", DnsmasqOptionKind.Path },
@@ -57,7 +57,7 @@ public static class DnsmasqConfOptionRegistry
{
"domain-needed", "bogus-priv", "no-hosts", "expand-hosts", "strict-order",
"no-resolv", "no-poll", "bind-interfaces", "no-negcache", "log-queries", "log-dhcp",
"all-servers", "leasefile-ro",
"all-servers", "leasefile-ro", "dhcp-authoritative",
"quiet-dhcp", "quiet-dhcp6", "quiet-ra", "dhcp-broadcast", "dhcp-sequential-ip",
"enable-tftp", "self-resolve", "conntrack",
};
@@ -0,0 +1,30 @@
namespace DnsmasqWebUI.Models;
/// <summary>
/// Where an effective config value came from (which file). Used so the UI can show exactly which file
/// set the value and why it is readonly or editable.
/// </summary>
/// <param name="FilePath">Absolute path of the config file that set this value. Use for "edit this file" or tooltips.</param>
/// <param name="FileName">Filename only (e.g. dnsmasq.conf, 01-default.conf) for compact display.</param>
/// <param name="IsManaged">True when this file is the app's managed config file (editable from UI).</param>
/// <remarks>
/// When <see cref="IsManaged"/> is true: the user can change or remove the value from the UI (we read/write the managed file).
/// When false (<see cref="IsReadOnly"/> is true): the value is from main or an included conf file; the user must edit that file to change it.
/// For single-value or multi-value options: show "from FileName (readonly)" and e.g. "Edit FilePath to change."
/// For flags: if the flag is set in a non-managed file, the user cannot unset it from the UI (they must remove the line in that file).
/// </remarks>
public record ConfigValueSource(string FilePath, string FileName, bool IsManaged)
{
/// <summary>True when the value is from a non-managed file: user cannot change it from the UI; for flags, cannot unset without editing that file.</summary>
public bool IsReadOnly => !IsManaged;
/// <summary>Returns a short tooltip for readonly values (which file and how to change). Null when <see cref="IsManaged"/> (editable).</summary>
public string? GetReadOnlyTooltip() =>
IsReadOnly ? $"From {FileName} (readonly). Edit {FilePath} to change." : null;
}
/// <summary>Value plus its config file source. Used for multi-value options (server/local, address, etc.) so JSON serializes as "value" and "source".</summary>
public record ValueWithSource(string Value, ConfigValueSource? Source);
/// <summary>Path plus its config file source. Used for addn-hosts so JSON serializes as "path" and "source".</summary>
public record PathWithSource(string Path, ConfigValueSource? Source);
@@ -8,7 +8,8 @@ namespace DnsmasqWebUI.Models;
/// <param name="SystemHostsPathExists">True if <paramref name="SystemHostsPath"/> is set and the file exists on disk.</param>
/// <param name="NoHosts">True when dnsmasq has no-hosts set (hosts files disabled).</param>
/// <param name="AddnHostsPaths">Effective addn-hosts paths dnsmasq loads. Empty when none configured.</param>
/// <param name="EffectiveConfig">Effective dnsmasq config (single-value and flag options) after parsing all config files.</param>
/// <param name="EffectiveConfig">Effective dnsmasq config (single-value, flags, multi-value) after parsing all config files.</param>
/// <param name="EffectiveConfigSources">Source per field (file path, readonly). Use for tooltips and readonly badges; null when not available.</param>
/// <param name="MainConfigPath">Path to the main dnsmasq config file (e.g. /etc/dnsmasq.conf).</param>
/// <param name="ManagedFilePath">Path to the app's managed config file (same directory as main config, e.g. zz-dnsmasq-webui.conf).</param>
/// <param name="LeasesPath">Path to the DHCP leases file from effective config (dhcp-leasefile). Null when not configured.</param>
@@ -18,8 +19,8 @@ namespace DnsmasqWebUI.Models;
/// <param name="LeasesPathExists">True if <paramref name="LeasesPath"/> is set and the file exists on disk.</param>
/// <param name="ReloadCommandConfigured">True when ReloadCommand is set in app config (reload after save).</param>
/// <param name="StatusCommandConfigured">True when StatusCommand is set (used to determine dnsmasq active/inactive).</param>
/// <param name="StatusShowConfigured">True when StatusShowCommand is set (full service status output on Overview).</param>
/// <param name="LogsConfigured">True when LogsCommand is set (recent logs preview on Overview).</param>
/// <param name="StatusShowConfigured">True when StatusShowCommand is set (full service status output on Dnsmasq page).</param>
/// <param name="LogsConfigured">True when LogsCommand is set (recent logs preview on Dnsmasq page).</param>
/// <param name="LogsPath">Log file path from effective config (log-facility when it is a file path). Used for full download. Null when log-facility is not a file path or not set.</param>
/// <param name="StatusShowCommand">The StatusShowCommand string from config (e.g. systemctl status dnsmasq --no-pager). Null when not configured.</param>
/// <param name="LogsCommand">The LogsCommand string from config (e.g. tail -n 100 /var/log/dnsmasq.log). Null when not configured.</param>
@@ -37,6 +38,7 @@ public record DnsmasqServiceStatus(
bool NoHosts,
IReadOnlyList<string> AddnHostsPaths,
EffectiveDnsmasqConfig? EffectiveConfig,
EffectiveConfigSources? EffectiveConfigSources,
string? MainConfigPath,
string? ManagedFilePath,
string? LeasesPath,
@@ -0,0 +1,55 @@
namespace DnsmasqWebUI.Models;
/// <summary>
/// Source file for each field in <see cref="EffectiveDnsmasqConfig"/>.
/// Same property names as config: use <c>sources.Port</c> for <c>config.Port</c>, <c>sources.AddnHostsPaths[i].Source</c> for <c>config.AddnHostsPaths[i]</c>, etc.
/// Enables the UI to show exactly where each value came from and whether it is readonly.
/// </summary>
/// <remarks>
/// <para><b>Single-value and flags:</b> Property is <see cref="ConfigValueSource"/>?. Readonly = <c>source?.IsReadOnly == true</c>. Tooltip = <see cref="ConfigValueSource.GetReadOnlyTooltip"/>.</para>
/// <para><b>Multi-value:</b> Property is <c>IReadOnlyList&lt;ValueWithSource&gt;</c> or <c>PathWithSource</c> for addn-hosts. Use <c>sources.AddnHostsPaths[i].Source</c>. Same readonly/tooltip from that Source.</para>
/// </remarks>
public record EffectiveConfigSources(
// --- Hosts ---
ConfigValueSource? NoHosts,
IReadOnlyList<PathWithSource> AddnHostsPaths,
// --- Multi-value (ARG_DUP): source per value ---
IReadOnlyList<ValueWithSource> ServerLocalValues,
IReadOnlyList<ValueWithSource> AddressValues,
IReadOnlyList<ValueWithSource> Interfaces,
IReadOnlyList<ValueWithSource> ListenAddresses,
IReadOnlyList<ValueWithSource> ExceptInterfaces,
IReadOnlyList<ValueWithSource> DhcpRanges,
IReadOnlyList<ValueWithSource> DhcpHostLines,
IReadOnlyList<ValueWithSource> DhcpOptionLines,
IReadOnlyList<ValueWithSource> ResolvFiles,
// --- Flags (set if any occurrence; source = first file that set it, so we know if readonly) ---
ConfigValueSource? ExpandHosts,
ConfigValueSource? BogusPriv,
ConfigValueSource? StrictOrder,
ConfigValueSource? NoResolv,
ConfigValueSource? DomainNeeded,
ConfigValueSource? NoPoll,
ConfigValueSource? BindInterfaces,
ConfigValueSource? NoNegcache,
ConfigValueSource? DhcpAuthoritative,
ConfigValueSource? LeasefileRo,
// --- Single-value (last occurrence wins; source = file that set the last value) ---
ConfigValueSource? DhcpLeaseFilePath,
ConfigValueSource? CacheSize,
ConfigValueSource? Port,
ConfigValueSource? LocalTtl,
ConfigValueSource? PidFilePath,
ConfigValueSource? User,
ConfigValueSource? Group,
ConfigValueSource? LogFacility,
ConfigValueSource? DhcpLeaseMax,
ConfigValueSource? NegTtl,
ConfigValueSource? MaxTtl,
ConfigValueSource? MaxCacheTtl,
ConfigValueSource? MinCacheTtl,
ConfigValueSource? DhcpTtl
);
@@ -1,18 +1,34 @@
namespace DnsmasqWebUI.Models;
/// <summary>
/// Effective dnsmasq config after reading all config files (main + conf-file + conf-dir).
/// Represents the final values that dnsmasq uses. Only includes options that are
/// single-value (last wins) or boolean flags, so they can be overridden by writing
/// to our managed file. Multi-value options (e.g. addn-hosts, server=) are either
/// listed read-only or omitted from this model.
/// Based on dnsmasq option.c: ARG_ONE = last occurrence wins; flag options = set if any.
/// Effective dnsmasq config after reading all config files (main + conf-file + conf-dir)
/// in the order returned by <see cref="DnsmasqConfIncludeParser.GetIncludedPathsWithSource"/>.
/// Represents the final values dnsmasq uses: single-value (last wins), flags (set if any),
/// and multi-value (all occurrences in order). Source per value is in <see cref="EffectiveConfigSources"/>
/// from <see cref="IDnsmasqConfigSetService.GetEffectiveConfigWithSources"/> (file path, IsManaged).
/// Non-managed source → readonly in UI; flags set in a non-managed file cannot be unset from the UI.
///
/// Single-value (ARG_ONE): last occurrence wins; can be overridden by writing to the managed file.
/// Flags: set if any file contains the option (key-only line).
/// Multi-value (ARG_DUP): addn-hosts, server/local, address, interface, listen-address, except-interface,
/// dhcp-range, dhcp-host, dhcp-option, resolv-file — all values in order; each has source in EffectiveConfigSources.
/// </summary>
public record EffectiveDnsmasqConfig(
// --- Hosts (already used by Hosts UI) ---
bool NoHosts,
IReadOnlyList<string> AddnHostsPaths,
// --- Multi-value (ARG_DUP): all occurrences in order ---
IReadOnlyList<string> ServerLocalValues,
IReadOnlyList<string> AddressValues,
IReadOnlyList<string> Interfaces,
IReadOnlyList<string> ListenAddresses,
IReadOnlyList<string> ExceptInterfaces,
IReadOnlyList<string> DhcpRanges,
IReadOnlyList<string> DhcpHostLines,
IReadOnlyList<string> DhcpOptionLines,
IReadOnlyList<string> ResolvFiles,
// --- Boolean flags (set if any file contains the option) ---
bool ExpandHosts,
bool BogusPriv,
@@ -19,7 +19,7 @@ public static class DnsmasqConfDirectiveParser
.AtLeastOnce().Text()
.Select(s => s.TrimEnd());
// Optional whitespace then optional '=value'
// 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()
@@ -33,11 +33,30 @@ public static class DnsmasqConfDirectiveParser
private static readonly TextParser<(string key, string value)> DirectiveLine =
KeyValue.AtEnd();
/// <summary>Parse a non-comment line into key and value. Returns null for empty or comment-only lines.</summary>
/// <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;
}
/// <summary>Parse a non-comment line into key and value. Returns null for empty or comment-only lines.
/// Strips dnsmasq-style comments (# and rest when # is after whitespace). Supports key-only lines (value "").</summary>
public static (string key, string value)? TryParseKeyValue(string line)
{
var t = line.Trim();
if (string.IsNullOrEmpty(t) || t.StartsWith("#", StringComparison.Ordinal))
var t = StripComment(line).TrimStart();
if (string.IsNullOrEmpty(t))
return null;
if (t.StartsWith("#", StringComparison.Ordinal))
return null;
var result = DirectiveLine.TryParse(t);
if (!result.HasValue)
@@ -5,15 +5,17 @@ namespace DnsmasqWebUI.Parsers;
/// <summary>
/// Parses the main dnsmasq .conf file only for <c>conf-file=</c> and <c>conf-dir=</c> directives
/// to discover the ordered list of included config file paths. Used for config set discovery.
/// Line parsing uses <see cref="DnsmasqConfDirectiveParser.TryParseKeyValue"/> (Superpower).
/// For parsing .conf file content use <see cref="DnsmasqConfFileLineParser"/> or <see cref="DnsmasqConfDirectiveParser"/>.
/// File order and option semantics match dnsmasq source: main file line-by-line with conf-file/conf-dir
/// processed in place (interleaved). Option names matched case-sensitively per dnsmasq.
/// See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
/// </summary>
public static class DnsmasqConfIncludeParser
{
private static readonly StringComparison KeyComparison = StringComparison.Ordinal;
/// <summary>
/// Returns the ordered list of absolute config file paths dnsmasq loads: main file first,
/// then each conf-file= path in order, then each file from each conf-dir= (alphabetically).
/// Returns the ordered list of absolute config file paths dnsmasq loads, in the exact order
/// dnsmasq reads them (main interleaved with conf-file and conf-dir).
/// </summary>
public static IReadOnlyList<string> GetIncludedPaths(string mainConfigPath)
{
@@ -23,50 +25,138 @@ public static class DnsmasqConfIncludeParser
}
/// <summary>
/// Returns the ordered list of (path, source) for config set display. Main first, then ConfFile entries, then ConfDir entries.
/// Returns the ordered list of (path, source) for config set display, in the exact order
/// dnsmasq reads files (main, then conf-file/conf-dir as encountered line-by-line).
/// </summary>
public static IReadOnlyList<(string Path, DnsmasqConfFileSource Source)> GetIncludedPathsWithSource(string mainConfigPath)
{
var mainFull = Path.GetFullPath(mainConfigPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var result = new List<(string Path, DnsmasqConfFileSource Source)> { (mainFull, DnsmasqConfFileSource.Main) };
if (!File.Exists(mainFull))
return result;
return new List<(string Path, DnsmasqConfFileSource Source)> { (mainFull, DnsmasqConfFileSource.Main) };
foreach (var line in File.ReadAllLines(mainFull))
var result = new List<(string Path, DnsmasqConfFileSource Source)>();
var added = new HashSet<string>(StringComparer.Ordinal);
var seenPaths = new HashSet<string>(StringComparer.Ordinal);
foreach (var (filePath, _, _, source) in EnumerateConfigLinesInDnsmasqOrder(mainFull, Path.GetDirectoryName(mainFull) ?? "", DnsmasqConfFileSource.Main, seenPaths))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (key.Equals("conf-file", StringComparison.OrdinalIgnoreCase))
{
var path = value.Trim();
var resolved = Path.GetFullPath(Path.Combine(mainDir, path));
if (File.Exists(resolved))
result.Add((resolved, DnsmasqConfFileSource.ConfFile));
continue;
}
if (key.Equals("conf-dir", StringComparison.OrdinalIgnoreCase))
{
var path = value.Split(',')[0].Trim();
var dir = Path.GetFullPath(Path.Combine(mainDir, path));
if (!Directory.Exists(dir))
continue;
var files = Directory.GetFiles(dir)
.OrderBy(Path.GetFileName, StringComparer.Ordinal)
.ToList();
foreach (var f in files)
result.Add((f, DnsmasqConfFileSource.ConfDir));
}
if (added.Add(filePath))
result.Add((filePath, source));
}
return result;
}
/// <summary>
/// Yields (filePath, lineNumber, line, source) in the exact order dnsmasq reads config.
/// When conf-file=X or conf-dir=Y is encountered, that file(s) are yielded before continuing the current file.
/// </summary>
public static IEnumerable<(string FilePath, int LineNumber, string Line, DnsmasqConfFileSource Source)> EnumerateConfigLinesInDnsmasqOrder(
string currentFilePath,
string currentFileDir,
DnsmasqConfFileSource source,
HashSet<string>? seenPaths = null)
{
seenPaths ??= new HashSet<string>(StringComparer.Ordinal);
if (!File.Exists(currentFilePath))
yield break;
var canonicalPath = Path.GetFullPath(currentFilePath);
if (!seenPaths.Add(canonicalPath))
yield break; // dnsmasq skips re-reading same file (by inode; we use path)
var lines = File.ReadAllLines(currentFilePath);
var dir = Path.GetDirectoryName(canonicalPath) ?? "";
// Yield "file started" so callers (e.g. GetIncludedPathsWithSource) can add this file even if it only has conf-file/conf-dir lines.
yield return (canonicalPath, 0, "", source);
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (string.Equals(key, "conf-file", KeyComparison))
{
var path = value.Trim();
if (string.IsNullOrEmpty(path)) continue;
var resolved = ResolvePath(path, dir) ?? path;
foreach (var t in EnumerateConfigLinesInDnsmasqOrder(resolved, Path.GetDirectoryName(resolved) ?? "", DnsmasqConfFileSource.ConfFile, seenPaths))
yield return t;
continue;
}
if (string.Equals(key, "conf-dir", KeyComparison))
{
var (directory, matchSuffix, ignoreSuffix) = ParseConfDirValue(value);
if (string.IsNullOrEmpty(directory)) continue;
var resolvedDir = ResolvePath(directory, dir) ?? directory;
if (!Directory.Exists(resolvedDir)) continue;
var files = GetConfDirFilesSorted(resolvedDir, matchSuffix, ignoreSuffix);
foreach (var f in files)
{
foreach (var t in EnumerateConfigLinesInDnsmasqOrder(f, Path.GetDirectoryName(f) ?? "", DnsmasqConfFileSource.ConfDir, seenPaths))
yield return t;
}
continue;
}
yield return (canonicalPath, i + 1, line, source);
}
}
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 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..] : "");
else
ignoreSuffix.Add(parts[i]);
}
return (dir, matchSuffix.Count > 0 ? matchSuffix : null, ignoreSuffix.Count > 0 ? ignoreSuffix : null);
}
private static bool ConfDirFileFilter(string name)
{
if (string.IsNullOrEmpty(name)) return false;
if (name[^1] == '~') return false;
if (name.Length >= 2 && name[0] == '#' && name[^1] == '#') return false;
if (name[0] == '.') return false;
return true;
}
private static IReadOnlyList<string> GetConfDirFilesSorted(string directory, List<string>? matchSuffix, List<string>? ignoreSuffix)
{
var files = new List<string>();
foreach (var name in Directory.GetFiles(directory).Select(Path.GetFileName).Where(n => n != null).Cast<string>())
{
if (!ConfDirFileFilter(name)) continue;
var fullPath = Path.GetFullPath(Path.Combine(directory, name));
if ((File.GetAttributes(fullPath) & FileAttributes.Directory) != 0) continue;
if (matchSuffix != null && matchSuffix.Count > 0)
{
var hasMatch = matchSuffix.Any(s => s.Length > 0 && name.EndsWith(s, StringComparison.Ordinal));
if (!hasMatch) continue;
}
if (ignoreSuffix != null && ignoreSuffix.Count > 0)
{
var ignored = ignoreSuffix.Any(s => name.EndsWith(s, StringComparison.Ordinal));
if (ignored) continue;
}
files.Add(fullPath);
}
files.Sort(StringComparer.Ordinal);
return files;
}
/// <summary>
/// Returns the first conf-dir= path (absolute directory), or null if none. Used as the directory
/// where we create the managed file (e.g. zz-dnsmasq-webui.conf, so it loads last).
@@ -85,7 +175,7 @@ public static class DnsmasqConfIncludeParser
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!key.Equals("conf-dir", StringComparison.OrdinalIgnoreCase))
if (!string.Equals(key, "conf-dir", KeyComparison))
continue;
var path = value.Split(',')[0].Trim();
return Path.GetFullPath(Path.Combine(mainDir, path));
@@ -95,8 +185,9 @@ public static class DnsmasqConfIncludeParser
}
/// <summary>
/// Returns true if any config file contains the given option as a flag (no value or empty value).
/// Used for options like no-hosts, expand-hosts, bogus-priv (dnsmasq: once set, set).
/// Returns true if any config file contains the given option as a flag (key-only line, no value).
/// dnsmasq: flag options (has_arg==0) must have no '='; extraneous parameter otherwise.
/// Keys matched case-sensitively per dnsmasq.
/// </summary>
public static bool GetFlagFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder, string optionKey)
{
@@ -113,8 +204,10 @@ public static class DnsmasqConfIncludeParser
if (kv == null)
continue;
var (k, v) = kv.Value;
if (!k.Equals(key, StringComparison.OrdinalIgnoreCase))
if (!string.Equals(k, key, KeyComparison))
continue;
if (!string.IsNullOrEmpty(v?.Trim()))
continue; // dnsmasq rejects flag with value
return true;
}
}
@@ -123,7 +216,7 @@ public static class DnsmasqConfIncludeParser
/// <summary>
/// Returns the last value for the given option key across config files, and the directory of the file
/// that contained it (for resolving relative paths). Keys are matched case-insensitively.
/// that contained it (for resolving relative paths). Keys matched case-sensitively per dnsmasq.
/// </summary>
public static (string? Value, string? ConfigFileDir) GetLastValueFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder, string optionKey)
{
@@ -143,7 +236,7 @@ public static class DnsmasqConfIncludeParser
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!k.Equals(key, StringComparison.OrdinalIgnoreCase))
if (!string.Equals(k, key, KeyComparison))
continue;
var trimmed = value.Trim();
lastValue = trimmed;
@@ -168,8 +261,8 @@ public static class DnsmasqConfIncludeParser
}
/// <summary>
/// Reads the given config files in order and returns the last <c>dhcp-leasefile=</c> or <c>dhcp-lease-file=</c> path.
/// Both option names are the same in dnsmasq (last occurrence of either wins). Relative paths resolved against config file dir.
/// Reads the given config files in order and returns the last <c>dhcp-leasefile=</c> or <c>dhcp-lease=</c> path.
/// dnsmasq only accepts these two option names (no dhcp-lease-file). Case-sensitive. Relative paths resolved against config file dir.
/// </summary>
public static string? GetDhcpLeaseFilePathFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder)
{
@@ -185,8 +278,7 @@ public static class DnsmasqConfIncludeParser
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!key.Equals("dhcp-leasefile", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("dhcp-lease-file", StringComparison.OrdinalIgnoreCase))
if (!string.Equals(key, "dhcp-leasefile", KeyComparison) && !string.Equals(key, "dhcp-lease", KeyComparison))
continue;
var path = value.Trim();
if (!string.IsNullOrEmpty(path))
@@ -221,7 +313,7 @@ public static class DnsmasqConfIncludeParser
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!key.Equals("addn-hosts", StringComparison.OrdinalIgnoreCase))
if (!string.Equals(key, "addn-hosts", KeyComparison))
continue;
var path = value.Trim();
if (string.IsNullOrEmpty(path))
@@ -231,4 +323,228 @@ public static class DnsmasqConfIncludeParser
}
return result;
}
private static ConfigValueSource MakeSource(string configPath, string? managedFilePath) =>
new(configPath, Path.GetFileName(configPath), string.Equals(Path.GetFullPath(configPath), managedFilePath != null ? Path.GetFullPath(managedFilePath) : null, StringComparison.Ordinal));
/// <summary>Like <see cref="GetLastValueFromConfigFiles"/> but returns which file set the value (for readonly/editable UI).</summary>
public static (string? Value, ConfigValueSource? Source) GetLastValueFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, string optionKey, string? managedFilePath)
{
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return (null, null);
string? lastValue = null;
ConfigValueSource? lastSource = null;
foreach (var configPath in configFilePathsInOrder)
{
if (!File.Exists(configPath))
continue;
var dir = Path.GetDirectoryName(configPath) ?? "";
foreach (var line in File.ReadAllLines(configPath))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!string.Equals(k, key, KeyComparison))
continue;
var trimmed = value.Trim();
lastValue = trimmed;
lastSource = MakeSource(configPath, managedFilePath);
}
}
return (lastValue, lastSource);
}
/// <summary>Like <see cref="GetFlagFromConfigFiles"/> but returns which file set the flag (for readonly: if not managed, user cannot unset from UI).</summary>
public static (bool IsSet, ConfigValueSource? Source) GetFlagFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, string optionKey, string? managedFilePath)
{
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return (false, null);
foreach (var configPath in configFilePathsInOrder)
{
if (!File.Exists(configPath))
continue;
foreach (var line in File.ReadAllLines(configPath))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, v) = kv.Value;
if (!string.Equals(k, key, KeyComparison))
continue;
if (!string.IsNullOrEmpty(v?.Trim()))
continue;
return (true, MakeSource(configPath, managedFilePath));
}
}
return (false, null);
}
/// <summary>Like <see cref="GetDhcpLeaseFilePathFromConfigFiles"/> but returns which file set the value.</summary>
public static (string? Path, ConfigValueSource? Source) GetDhcpLeaseFilePathFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, string? managedFilePath)
{
string? result = null;
ConfigValueSource? lastSource = null;
foreach (var configPath in configFilePathsInOrder)
{
if (!File.Exists(configPath))
continue;
var dir = Path.GetDirectoryName(configPath) ?? "";
foreach (var line in File.ReadAllLines(configPath))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, "dhcp-leasefile", KeyComparison) && !string.Equals(key, "dhcp-lease", KeyComparison))
continue;
var path = value.Trim();
if (!string.IsNullOrEmpty(path))
{
result = ResolvePath(path, dir) ?? result;
lastSource = MakeSource(configPath, managedFilePath);
}
}
}
return (result, lastSource);
}
/// <summary>Like <see cref="GetAddnHostsPathsFromConfigFiles"/> but returns source file for each path (for multi-value; each entry can be readonly or from managed).</summary>
public static IReadOnlyList<(string Path, ConfigValueSource Source)> GetAddnHostsPathsFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, string? managedFilePath)
{
var result = new List<(string Path, ConfigValueSource Source)>();
foreach (var configPath in configFilePathsInOrder)
{
if (!File.Exists(configPath))
continue;
var dir = Path.GetDirectoryName(configPath) ?? "";
var source = MakeSource(configPath, managedFilePath);
foreach (var line in File.ReadAllLines(configPath))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, "addn-hosts", KeyComparison))
continue;
var path = value.Trim();
if (string.IsNullOrEmpty(path))
continue;
result.Add((Path.GetFullPath(Path.Combine(dir, path)), source));
}
}
return result;
}
/// <summary>Collects all values for a single option key across config files (ARG_DUP style; order preserved).</summary>
public static IReadOnlyList<string> GetMultiValueFromConfigFiles(
IReadOnlyList<string> configFilePathsInOrder, string optionKey)
{
var result = new List<string>();
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return result;
foreach (var configPath in configFilePathsInOrder)
{
if (!File.Exists(configPath))
continue;
foreach (var line in File.ReadAllLines(configPath))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!string.Equals(k, key, KeyComparison))
continue;
result.Add(value.Trim());
}
}
return result;
}
/// <summary>Collects all values for multiple option keys (e.g. server and local) in file order. Keys matched case-sensitively.</summary>
public static IReadOnlyList<string> GetMultiValueFromConfigFiles(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyList<string> optionKeys)
{
var keys = new HashSet<string>(optionKeys.Select(k => k.Trim()).Where(k => k.Length > 0), StringComparer.Ordinal);
if (keys.Count == 0)
return Array.Empty<string>();
var result = new List<string>();
foreach (var configPath in configFilePathsInOrder)
{
if (!File.Exists(configPath))
continue;
foreach (var line in File.ReadAllLines(configPath))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!keys.Contains(k))
continue;
result.Add(value.Trim());
}
}
return result;
}
/// <summary>Like <see cref="GetMultiValueFromConfigFiles(IReadOnlyList{string}, string)"/> but returns source per value.</summary>
public static IReadOnlyList<(string Value, ConfigValueSource Source)> GetMultiValueFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, string optionKey, string? managedFilePath)
{
var result = new List<(string Value, ConfigValueSource Source)>();
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return result;
foreach (var configPath in configFilePathsInOrder)
{
if (!File.Exists(configPath))
continue;
var source = MakeSource(configPath, managedFilePath);
foreach (var line in File.ReadAllLines(configPath))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!string.Equals(k, key, KeyComparison))
continue;
result.Add((value.Trim(), source));
}
}
return result;
}
/// <summary>Like <see cref="GetMultiValueFromConfigFiles(IReadOnlyList{string}, IReadOnlyList{string})"/> but returns source per value.</summary>
public static IReadOnlyList<(string Value, ConfigValueSource Source)> GetMultiValueFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyList<string> optionKeys, string? managedFilePath)
{
var keys = new HashSet<string>(optionKeys.Select(k => k.Trim()).Where(k => k.Length > 0), StringComparer.Ordinal);
if (keys.Count == 0)
return Array.Empty<(string, ConfigValueSource)>();
var result = new List<(string Value, ConfigValueSource Source)>();
foreach (var configPath in configFilePathsInOrder)
{
if (!File.Exists(configPath))
continue;
var source = MakeSource(configPath, managedFilePath);
foreach (var line in File.ReadAllLines(configPath))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!keys.Contains(k))
continue;
result.Add((value.Trim(), source));
}
}
return result;
}
}
@@ -7,12 +7,15 @@ public interface IDnsmasqConfigSetService : IApplicationSingleton
{
Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default);
/// <summary>Leases path discovered from the config set (dhcp-leasefile= / dhcp-lease-file=; last wins). Null if not found.</summary>
/// <summary>Leases path discovered from the config set (dhcp-leasefile= / dhcp-lease=; last wins). Null if not found.</summary>
string? GetLeasesPath();
/// <summary>Effective hosts-related config after reading all config files: no-hosts flag and addn-hosts= paths (cumulative).</summary>
EffectiveDnsmasqConfig GetEffectiveConfig();
/// <summary>Effective config plus source per field so the UI can show exactly where each value came from (file path, readonly reason).</summary>
(EffectiveDnsmasqConfig Config, EffectiveConfigSources Sources) GetEffectiveConfigWithSources();
/// <summary>Additional hosts paths discovered from the config set (addn-hosts=; cumulative, all in order). Empty if none.</summary>
IReadOnlyList<string> GetAddnHostsPaths();
@@ -18,7 +18,7 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
public Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default) =>
Task.FromResult(GetConfigSet());
/// <summary>Leases path discovered from the config set (dhcp-leasefile= or dhcp-lease-file=; last wins). Null if main config missing or no directive found.</summary>
/// <summary>Leases path discovered from the config set (dhcp-leasefile= or dhcp-lease=; last wins). Null if main config missing or no directive found.</summary>
public string? GetLeasesPath()
{
var set = GetConfigSet();
@@ -28,6 +28,18 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
return DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
}
/// <summary>Effective config plus source per field so the UI can show "from X (readonly)" and why a flag cannot be unset.</summary>
public (EffectiveDnsmasqConfig Config, EffectiveConfigSources Sources) GetEffectiveConfigWithSources()
{
var set = GetConfigSet();
if (set.Files.Count == 0)
return (CreateDefaultEffectiveConfig(), CreateDefaultEffectiveConfigSources());
var paths = set.Files.Select(f => f.Path).ToList();
var config = GetEffectiveConfig();
var sources = BuildEffectiveConfigSources(paths, set.ManagedFilePath);
return (config, sources);
}
/// <summary>Effective config from the config set (single-value and flag options; last/any wins).</summary>
public EffectiveDnsmasqConfig GetEffectiveConfig()
{
@@ -38,6 +50,15 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
var serverLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, new[] { "server", "local" });
var addressValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "address");
var interfaces = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "interface");
var listenAddresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "listen-address");
var exceptInterfaces = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "except-interface");
var dhcpRanges = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "dhcp-range");
var dhcpHostLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "dhcp-host");
var dhcpOptionLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "dhcp-option");
var resolvFiles = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "resolv-file");
var expandHosts = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "expand-hosts");
var bogusPriv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "bogus-priv");
@@ -93,6 +114,7 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
return new EffectiveDnsmasqConfig(
noHosts, addnHosts,
serverLocal, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines, resolvFiles,
expandHosts, bogusPriv, strictOrder, noResolv, domainNeeded, noPoll, bindInterfaces, noNegcache, dhcpAuthoritative, leasefileRo,
dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, dhcpLeaseMax,
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl
@@ -102,12 +124,87 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
private static EffectiveDnsmasqConfig CreateDefaultEffectiveConfig() =>
new(
NoHosts: false, AddnHostsPaths: Array.Empty<string>(),
ServerLocalValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(), ExceptInterfaces: Array.Empty<string>(), DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(), DhcpOptionLines: Array.Empty<string>(), ResolvFiles: Array.Empty<string>(),
ExpandHosts: false, BogusPriv: false, StrictOrder: false, NoResolv: false, DomainNeeded: false, NoPoll: false,
BindInterfaces: false, NoNegcache: false, DhcpAuthoritative: false, LeasefileRo: false,
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
LogFacility: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null
);
private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() =>
new(
NoHosts: null, AddnHostsPaths: Array.Empty<PathWithSource>(),
ServerLocalValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
Interfaces: Array.Empty<ValueWithSource>(), ListenAddresses: Array.Empty<ValueWithSource>(),
ExceptInterfaces: Array.Empty<ValueWithSource>(), DhcpRanges: Array.Empty<ValueWithSource>(),
DhcpHostLines: Array.Empty<ValueWithSource>(), DhcpOptionLines: Array.Empty<ValueWithSource>(),
ResolvFiles: Array.Empty<ValueWithSource>(),
ExpandHosts: null, BogusPriv: null, StrictOrder: null, NoResolv: null, DomainNeeded: null, NoPoll: null,
BindInterfaces: null, NoNegcache: null, DhcpAuthoritative: null, LeasefileRo: null,
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
LogFacility: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null
);
private static EffectiveConfigSources BuildEffectiveConfigSources(IReadOnlyList<string> paths, string managedFilePath)
{
var (_, noHostsSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "no-hosts", managedFilePath);
var addnHostsWithSource = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(paths, managedFilePath);
var serverLocalWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, new[] { "server", "local" }, managedFilePath);
var addressWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "address", managedFilePath);
var interfacesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "interface", managedFilePath);
var listenAddressesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "listen-address", managedFilePath);
var exceptInterfacesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "except-interface", managedFilePath);
var dhcpRangesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "dhcp-range", managedFilePath);
var dhcpHostLinesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "dhcp-host", managedFilePath);
var dhcpOptionLinesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "dhcp-option", managedFilePath);
var resolvFilesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "resolv-file", managedFilePath);
var (_, expandHostsSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "expand-hosts", managedFilePath);
var (_, bogusPrivSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "bogus-priv", managedFilePath);
var (_, strictOrderSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "strict-order", managedFilePath);
var (_, noResolvSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "no-resolv", managedFilePath);
var (_, domainNeededSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "domain-needed", managedFilePath);
var (_, noPollSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "no-poll", managedFilePath);
var (_, bindInterfacesSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "bind-interfaces", managedFilePath);
var (_, noNegcacheSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "no-negcache", managedFilePath);
var (_, dhcpAuthoritativeSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "dhcp-authoritative", managedFilePath);
var (_, leasefileRoSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "leasefile-ro", managedFilePath);
var (_, dhcpLeaseFilePathSource) = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFilesWithSource(paths, managedFilePath);
var (_, cacheSizeSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "cache-size", managedFilePath);
var (_, portSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "port", managedFilePath);
var (_, localTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "local-ttl", managedFilePath);
var (_, pidFilePathSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "pid-file", managedFilePath);
var (_, userSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "user", managedFilePath);
var (_, groupSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "group", managedFilePath);
var (_, logFacilitySource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "log-facility", managedFilePath);
var (_, dhcpLeaseMaxSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "dhcp-lease-max", managedFilePath);
var (_, negTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "neg-ttl", managedFilePath);
var (_, maxTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "max-ttl", managedFilePath);
var (_, maxCacheTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "max-cache-ttl", managedFilePath);
var (_, minCacheTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "min-cache-ttl", managedFilePath);
var (_, dhcpTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "dhcp-ttl", managedFilePath);
return new EffectiveConfigSources(
noHostsSource, addnHostsWithSource.Select(t => new PathWithSource(t.Path, t.Source)).ToList(),
serverLocalWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
addressWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
interfacesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
listenAddressesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
exceptInterfacesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpRangesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpHostLinesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpOptionLinesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
resolvFilesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
expandHostsSource, bogusPrivSource, strictOrderSource, noResolvSource, domainNeededSource, noPollSource,
bindInterfacesSource, noNegcacheSource, dhcpAuthoritativeSource, leasefileRoSource,
dhcpLeaseFilePathSource, cacheSizeSource, portSource, localTtlSource, pidFilePathSource, userSource, groupSource,
logFacilitySource, dhcpLeaseMaxSource, negTtlSource, maxTtlSource, maxCacheTtlSource, minCacheTtlSource, dhcpTtlSource
);
}
private static int? TryParseInt(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
+22
View File
@@ -0,0 +1,22 @@
# Test data
Source for the Docker test harness: `scripts/prepare-test-mount.sh` syncs this dir to `testdata-mount/`, which is bind-mounted as `/data`. Unit tests use the copy in the test output dir (copied at build via the .csproj).
## Layout
| File / dir | Purpose |
|------------|--------|
| **dnsmasq-test.conf** | Harness main config (`/data/dnsmasq-test.conf`). DNS + DHCP; `conf-dir=/data/dnsmasq.d`, multiple server/addn-hosts/address/dhcp-range. |
| **dnsmasq.conf** | Minimal main (conf-dir only). Used by unit tests when conf-dir is absent. |
| **dnsmasq.conf.example** | Commented example; not used by harness or tests. |
| **dnsmasq.d/** | Included configs (alphabetical order). |
| **dnsmasq.d/01-other.conf** | `domain=local`. Not managed. |
| **dnsmasq.d/02-servers.conf** | Extra `server=` / `local=` (readonly in UI). |
| **dnsmasq.d/03-more.conf** | `resolv-file=`, `dhcp-option=`. Not managed. |
| **dnsmasq.d/dhcp.conf** | `dhcp-host=`; managed by app in real use, used as-is in harness. |
| **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. |
Harness usage (start/stop/tidy): see **agents.md** in the repo root.
+8 -1
View File
@@ -1,8 +1,10 @@
# Config for the dnsmasq test container only (docker-compose.test.yml).
# DNS + DHCP: forwards DNS to Cloudflare/Google with caching; hands out DHCP on testnet.
# Uses same testdata layout so app and dnsmasq share hosts, dhcp.conf, leases.
# Richer config so UI shows multi-value lists (server, addn-hosts, address, dhcp-range) and readonly sources.
port=53
interface=eth0
listen-address=172.28.0.2
bind-interfaces
# Upstream DNS (forward queries not in cache/hosts to these)
@@ -15,12 +17,17 @@ cache-size=1000
domain-needed
bogus-priv
# Local overrides (multi-value: address=)
address=/local.example/127.0.0.1
address=/test.lan/172.28.0.1
# DHCP
dhcp-range=172.28.0.10,172.28.0.50,255.255.255.0,12h
dhcp-leasefile=/data/leases
# Additional config and hosts
# Additional config and hosts (multi-value: addn-hosts)
conf-dir=/data/dnsmasq.d
addn-hosts=/data/hosts
addn-hosts=/data/hosts.extra
# Log to file so the harness can show real logs (LogsCommand tails this)
log-facility=/data/dnsmasq.log
+4
View File
@@ -0,0 +1,4 @@
# Extra upstream/local (included by conf-dir). Not managed by app; values here show as readonly in UI.
# Adds more server= and local= so ServerLocalValues has entries from multiple files.
server=/internal.lan/192.168.1.1
local=/localdomain/
+4
View File
@@ -0,0 +1,4 @@
# Extra options so effective config has resolv-file and dhcp-option (multi-value). Not managed by app.
# resolv-file must point to a file that exists in the container (/data is the mount).
resolv-file=/data/resolv.dnsmasq
dhcp-option=3,172.28.0.1
+3 -2
View File
@@ -1,2 +1,3 @@
# Sample dhcp-host entries for integration testing (managed by this app)
dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite
# Sample dhcp-host entries for integration testing (managed by this app).
# Use testnet IP (172.28.0.x) so static host is on same subnet as dhcp-range; outside range 172.28.0.10–50.
dhcp-host=aa:bb:cc:dd:ee:ff,172.28.0.100,testpc,infinite
+3
View File
@@ -1,2 +1,5 @@
127.0.0.1 localhost
::1 localhost
192.168.1.1 router gateway
192.168.1.10 testpc
172.28.0.2 dnsmasq-webui
+3
View File
@@ -0,0 +1,3 @@
# Extra hosts file (second addn-hosts= in harness). Used so effective config has multiple addn-hosts with source per path.
192.168.1.1 gateway
192.168.1.254 dns
+3
View File
@@ -0,0 +1,3 @@
# Minimal resolv file for harness; dnsmasq uses server= in main config when no-resolv is set.
# File must exist so resolv-file= does not cause dnsmasq to fail or warn.
nameserver 127.0.0.1