Effective config, hosts/addn-hosts logic, test harness status & logs

- EffectiveDnsmasqConfig: overridable single-value options (no-hosts, addn-hosts,
  flags, dhcp-leasefile, cache-size, port, etc.) from dnsmasq source/docs.
- Hosts UI: allow editing when no-hosts but SystemHostsPath is in addn-hosts;
  HostsController and Hosts.razor check pathInAddnHosts.
- Config comments: managed file ensures one addn-hosts line in our file only;
  DnsmasqOptions/validator docs for SystemHostsPath and addn-hosts.
- Test harness: dnsmasq logs to /data/dnsmasq.log (log-facility + log-queries in
  dnsmasq-test.conf); entrypoint touches log file for write access.
- dnsmasq-status.sh: simulates systemctl status output in container (ps/pgrep);
  Dockerfile copies and chmod +x; StatusShowCommand/LogsCommand use script and
  tail /data/dnsmasq.log.
- Project: Models/Config namespace, typed HTTP clients, combined DHCP+Leases
  page, ProcessRunner, shared models (PascalCase).
This commit is contained in:
2026-02-01 01:17:32 +10:00
parent 5bd9eaf55d
commit d0391ef058
64 changed files with 1184 additions and 512 deletions
+2 -1
View File
@@ -28,5 +28,6 @@ FROM app AS final
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/entrypoint.sh .
RUN chmod +x entrypoint.sh
COPY scripts/dnsmasq-status.sh .
RUN chmod +x entrypoint.sh dnsmasq-status.sh
ENTRYPOINT ["./entrypoint.sh"]
+8 -3
View File
@@ -2,8 +2,10 @@
# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
#
# app builds from Dockerfile (app + dnsmasq in one container; app is main process).
# ReloadCommand/StatusCommand use pkill/pgrep (procps in image). cap_add NET_ADMIN for dnsmasq.
# No systemd in container (dnsmasq runs as a process from entrypoint, like most dnsmasq images).
# StatusShowCommand/LogsCommand use ps so the Service status / Recent logs sections show something
# (process info). On a host with systemd you'd use systemctl status dnsmasq and journalctl instead.
# cap_add NET_ADMIN for dnsmasq.
#
# Networks: testnet 172.28.0.0/16 so dnsmasq can hand out 172.28.0.10–50.
services:
@@ -18,9 +20,12 @@ services:
DNSMASQ_CONF: /data/dnsmasq-test.conf
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf
Dnsmasq__HostsPath: /data/hosts
Dnsmasq__SystemHostsPath: /data/hosts
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)
Dnsmasq__StatusShowCommand: "/app/dnsmasq-status.sh"
Dnsmasq__LogsCommand: "tail -n 100 /data/dnsmasq.log 2>/dev/null || echo '(no log file yet)'"
volumes:
- ${TESTDATA_MOUNT:-./testdata-mount}:/data
networks:
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Simulates "systemctl status dnsmasq" output when run in a container without systemd.
# Uses ps/pgrep so the UI looks familiar. Usage: dnsmasq-status.sh
pid=$(pgrep -x dnsmasq)
if [ -z "$pid" ]; then
echo "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server"
echo " Loaded: (container, no systemd)"
echo " Active: inactive (dead)"
echo ""
exit 0
fi
# One ps + awk: parse pid,user,etime,rss,args and print systemctl-style (rss in KB -> MB)
ps -o pid=,user=,etime=,rss=,args= -p "$pid" 2>/dev/null | awk '{
pid=$1; user=$2; etime=$3; rss=$4
args=""
for (i=5;i<=NF;i++) args = args (i>5?" ":"") $i
rss_mb = (rss+0) / 1024
if (rss_mb < 0.1) rss_mb = 0.0
printf "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server\n"
printf " Loaded: loaded (container, no systemd)\n"
printf " Active: active (running) since container start\n"
printf " Main PID: %s (dnsmasq)\n", pid
printf " Tasks: 1 (limit: unknown)\n"
printf " Memory: %.1fM\n", rss_mb
printf " CPU: %s\n", etime
printf " CGroup: (container)\n"
printf " └─%s %s\n", pid, args
}'
exit 0
+2
View File
@@ -2,6 +2,8 @@
# If DNSMASQ_CONF is set, run dnsmasq in the background (same container as the app).
# Container main process is the app (exec below).
if [ -n "$DNSMASQ_CONF" ]; then
# Ensure dnsmasq log file exists and is writable (dnsmasq may drop to dnsmasq user)
touch /data/dnsmasq.log 2>/dev/null && chmod a+rw /data/dnsmasq.log 2>/dev/null || true
dnsmasq -k --conf-file="$DNSMASQ_CONF" &
fi
exec dotnet DnsmasqWebUI.dll
+3 -4
View File
@@ -27,7 +27,7 @@ usage() {
echo ""
echo "Prepare the testdata mount directory and optionally start the Docker test harness"
echo "(app with dnsmasq in one container, plus a DHCP client). The mount is synced from"
echo "testdata/ by default; 'leases' is excluded so dnsmasq creates the real leases file."
echo "testdata/ by default (includes sample leases file so the harness shows leases)."
echo ""
echo "Steps:"
echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount."
@@ -176,16 +176,15 @@ if [ "$NO_CLEAR" = false ]; then
fi
if command -v rsync >/dev/null 2>&1; then
rsync -a --exclude=leases "$SOURCE_DIR/" "$MOUNT_DIR/"
rsync -a "$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).
find "$MOUNT_DIR" -name '*dnsmasq-webui*.conf' -type f -delete 2>/dev/null || true
echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR, leases excluded)."
echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR)."
if [ "$PREPARE_ONLY" = true ]; then
echo "To start the harness: TESTDATA_MOUNT=./$MOUNT_DIR docker compose -f $COMPOSE_FILE up -d --build"
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
@@ -252,4 +252,78 @@ public class DnsmasqConfIncludeParserTests
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "expand-hosts");
Assert.True(result);
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "no-resolv"));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "port=53\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "no-hosts");
Assert.False(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + 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, "cache-size=100\n");
File.WriteAllText(f2, "cache-size=200\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, "cache-size");
Assert.Equal("200", value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ResolvePath_Relative_ResolvesAgainstDir()
{
var dir = Path.GetTempPath();
var result = DnsmasqConfIncludeParser.ResolvePath("sub/pid.pid", dir);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "sub", "pid.pid")), result);
}
[Fact]
public void ResolvePath_Absolute_ReturnsAsIs()
{
var abs = Path.Combine(Path.GetTempPath(), "absolute.pid");
var result = DnsmasqConfIncludeParser.ResolvePath(abs, "/some/dir");
Assert.Equal(Path.GetFullPath(abs), result);
}
}
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
namespace DnsmasqWebUI.Tests;
@@ -1,4 +1,5 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@namespace DnsmasqWebUI.Components.Layout
<div class="page">
<div class="sidebar">
@@ -6,10 +7,6 @@
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
@@ -1,3 +1,5 @@
@namespace DnsmasqWebUI.Components.Layout
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">DnsmasqWebUI</a>
@@ -10,7 +12,7 @@
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Overview
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dnsmasq
</NavLink>
</div>
@@ -25,12 +27,6 @@
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> DHCP
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="leases">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Leases
</NavLink>
</div>
</nav>
</div>
+103 -53
View File
@@ -1,24 +1,34 @@
@page "/dhcp"
@page "/leases"
@rendermode InteractiveServer
@inject HttpClient Http
@namespace DnsmasqWebUI.Components.Pages
@inject IStatusClient StatusClient
@inject IDhcpHostsClient DhcpHostsClient
@inject ILeasesClient LeasesClient
@inject IReloadClient ReloadClient
<PageTitle>DHCP hosts</PageTitle>
<PageTitle>DHCP</PageTitle>
<h1>DHCP static hosts</h1>
<h1 class="mb-4">DHCP</h1>
@if (_status == null)
{
<p>Loading...</p>
}
else if (!_status.managedFilePathExists)
{
<p class="text-warning">Managed config file not found: @_status.managedFilePath</p>
}
else
{
@if (_status.dnsmasqStatus == "inactive" || _status.dnsmasqStatus == "unknown")
@* ---- Static hosts (dhcp-host= in managed file) ---- *@
<section class="mb-4">
<h2 class="h5 mb-2">Static hosts</h2>
@if (!_status.ManagedFilePathExists)
{
<p class="text-warning">Managed config file not found: @_status.ManagedFilePath</p>
}
else
{
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
{
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.dnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
}
@if (_error != null)
{
@@ -53,16 +63,65 @@ else
</table>
<button class="btn btn-secondary mt-2" @onclick="Add">Add host</button>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
@if (_status.reloadCommandConfigured)
@if (_status.ReloadCommandConfigured)
{
<button class="btn btn-outline-primary mt-2" @onclick="Reload" disabled="@_saving">Reload dnsmasq</button>
}
<p class="text-muted small mt-2">Managed file: @_status.managedFilePath</p>
<p class="text-muted small mt-2">Managed file: @_status.ManagedFilePath</p>
}
</section>
@* ---- Leases (dhcp-leasefile, read-only) ---- *@
<section class="mb-4">
<h2 class="h5 mb-2">Leases</h2>
@if (!_status.LeasesPathConfigured)
{
<p class="text-muted">Leases not configured (no dhcp-leasefile in config).</p>
}
else if (!_status.LeasesPathExists)
{
<p class="text-warning">Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.</p>
}
else if (_leases == null)
{
<p class="text-warning">Could not read leases file.</p>
}
else if (_leases.Count == 0)
{
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
<p class="text-muted small">Path: @_status.LeasesPath</p>
<button class="btn btn-outline-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_leasesLoading">Refresh</button>
}
else
{
<table class="table table-bordered">
<thead>
<tr><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th></tr>
</thead>
<tbody>
@foreach (var e in _leases)
{
<tr>
<td>@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td>@e.Mac</td>
<td>@e.Address</td>
<td>@e.Name</td>
<td>@e.ClientId</td>
</tr>
}
</tbody>
</table>
<p class="text-muted small">Path: @_status.LeasesPath (read-only). Cached and refreshed when the file changes.</p>
<button class="btn btn-outline-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_leasesLoading">Refresh</button>
}
</section>
}
@code {
private DhcpStatusDto? _status;
private List<DnsmasqWebUI.Models.DhcpHostEntry> _entries = new();
private DnsmasqServiceStatus? _status;
private List<DhcpHostEntry> _entries = new();
private IReadOnlyList<LeaseEntry>? _leases;
private bool _leasesLoading;
private string? _error;
private string? _message;
private bool _saving;
@@ -78,10 +137,10 @@ else
_message = null;
try
{
_status = await Http.GetFromJsonAsync<DhcpStatusDto>("api/status");
var entries = await Http.GetFromJsonAsync<List<DnsmasqWebUI.Models.DhcpHostEntry>>("api/dhcp/hosts");
if (entries != null)
_entries = entries;
_status = await StatusClient.GetStatusAsync();
var entries = await DhcpHostsClient.GetDhcpHostsAsync();
_entries = entries?.ToList() ?? new List<DhcpHostEntry>();
await LoadLeases();
}
catch (Exception ex)
{
@@ -89,31 +148,46 @@ else
}
}
private async Task LoadLeases()
{
if (_status == null || !_status.LeasesPathConfigured) return;
_leasesLoading = true;
try
{
var resp = await LeasesClient.GetLeasesAsync();
_leases = resp.Entries;
}
finally
{
_leasesLoading = false;
}
}
private void Add()
{
_entries.Add(new DnsmasqWebUI.Models.DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, MacAddresses = new List<string>(), Extra = new List<string>() });
_entries.Add(new DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, MacAddresses = new List<string>(), Extra = new List<string>() });
StateHasChanged();
}
private void Delete(DnsmasqWebUI.Models.DhcpHostEntry e)
private void Delete(DhcpHostEntry e)
{
e.IsDeleted = true;
StateHasChanged();
}
private void ToggleValid(DnsmasqWebUI.Models.DhcpHostEntry e)
private void ToggleValid(DhcpHostEntry e)
{
e.IsComment = !e.IsComment;
StateHasChanged();
}
private void ToggleIgnore(DnsmasqWebUI.Models.DhcpHostEntry e)
private void ToggleIgnore(DhcpHostEntry e)
{
e.Ignore = !e.Ignore;
StateHasChanged();
}
private void SetMac(DnsmasqWebUI.Models.DhcpHostEntry e, string? value)
private void SetMac(DhcpHostEntry e, string? value)
{
e.MacAddresses = value?.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList() ?? new List<string>();
StateHasChanged();
@@ -126,17 +200,11 @@ else
_message = null;
try
{
var response = await Http.PutAsJsonAsync("api/dhcp/hosts", _entries);
if (!response.IsSuccessStatusCode)
{
_error = await response.Content.ReadAsStringAsync();
return;
}
var result = await response.Content.ReadFromJsonAsync<SaveResultDto>();
if (result?.reload?.success == true)
var result = await DhcpHostsClient.SaveDhcpHostsAsync(_entries);
if (result.Reload.Success)
_message = "Saved and dnsmasq reloaded.";
else if (result?.reload != null && !string.IsNullOrEmpty(result.reload.stdErr))
_error = "Saved but dnsmasq reload failed: " + result.reload.stdErr;
else if (!string.IsNullOrEmpty(result.Reload.StdErr))
_error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr;
else
_message = "Saved.";
await Load();
@@ -151,15 +219,6 @@ else
}
}
private class SaveResultDto { public ReloadDto? reload { get; set; } }
private class DhcpStatusDto
{
public string? managedFilePath { get; set; }
public bool managedFilePathExists { get; set; }
public bool reloadCommandConfigured { get; set; }
public string? dnsmasqStatus { get; set; }
}
private async Task Reload()
{
_saving = true;
@@ -167,13 +226,11 @@ else
_message = null;
try
{
var result = await Http.PostAsync("api/reload", null);
result.EnsureSuccessStatusCode();
var reload = await result.Content.ReadFromJsonAsync<ReloadResultDto>();
if (reload?.success == true)
var result = await ReloadClient.ReloadAsync();
if (result.Success)
_message = "Dnsmasq reloaded.";
else
_error = "Reload failed: " + (reload?.stdErr ?? "unknown");
_error = "Reload failed: " + (result.StdErr ?? "unknown");
}
catch (Exception ex)
{
@@ -184,11 +241,4 @@ else
_saving = false;
}
}
private class ReloadResultDto
{
public bool success { get; set; }
public string? stdErr { get; set; }
}
private class ReloadDto { public bool success { get; set; } public string? stdErr { get; set; } }
}
@@ -1,4 +1,5 @@
@page "/Error"
@page "/Error"
@namespace DnsmasqWebUI.Components.Pages
@using System.Diagnostics
<PageTitle>Error</PageTitle>
+183 -91
View File
@@ -1,15 +1,18 @@
@page "/"
@rendermode InteractiveServer
@inject HttpClient Http
@namespace DnsmasqWebUI.Components.Pages
@inject IStatusClient StatusClient
@inject IConfigSetClient ConfigSetClient
@inject IReloadClient ReloadClient
@implements IDisposable
<PageTitle>Overview</PageTitle>
<PageTitle>Dnsmasq</PageTitle>
<h1 class="mb-4">Overview</h1>
<h1 class="mb-4">Dnsmasq</h1>
<p class="lead text-muted mb-4">Dnsmasq configuration and service status at a glance.</p>
<p class="lead text-muted mb-4">Configuration and service status at a glance.</p>
@if (_status == null && _error == null)
@if (_loading)
{
<p><em>Loading…</em></p>
}
@@ -21,22 +24,22 @@ else if (_status != null)
{
<section class="mb-4">
<h2 class="h5 mb-2">Service</h2>
<div class="alert alert-@(_status.dnsmasqStatus == "active" ? "success" : "secondary") py-2 mb-0">
<div class="alert alert-@(_status.DnsmasqStatus == "active" ? "success" : "secondary") py-2 mb-0">
<strong>Dnsmasq:</strong> @GetStatusLabel(_status)
@if (_status.statusCommandExitCode.HasValue)
@if (_status.StatusCommandExitCode.HasValue)
{
<span class="ms-1 text-muted small">(exit @_status.statusCommandExitCode.Value)</span>
<span class="ms-1 text-muted small">(exit @_status.StatusCommandExitCode.Value)</span>
}
@if (!string.IsNullOrEmpty(_status.statusCommandStderr))
@if (!string.IsNullOrEmpty(_status.StatusCommandStderr))
{
<span class="ms-1 text-muted small">— @_status.statusCommandStderr</span>
<span class="ms-1 text-muted small">— @_status.StatusCommandStderr</span>
}
</div>
@if (_status.dnsmasqStatus != "active" && (_status.statusCommandStdout != null || _status.statusCommandStderr != null))
@if (_status.DnsmasqStatus != "active" && (_status.StatusCommandStdout != null || _status.StatusCommandStderr != null))
{
<pre class="small bg-dark text-light p-2 rounded mt-1 mb-0" style="max-height: 12rem; overflow: auto;">@(_status.statusCommandStdout != null ? _status.statusCommandStdout : "")@(_status.statusCommandStdout != null && _status.statusCommandStderr != null ? "\n" : "")@(_status.statusCommandStderr ?? "")</pre>
<pre class="small bg-dark text-light p-2 rounded mt-1 mb-0" style="max-height: 12rem; overflow: auto;">@(_status.StatusCommandStdout != null ? _status.StatusCommandStdout : "")@(_status.StatusCommandStdout != null && _status.StatusCommandStderr != null ? "\n" : "")@(_status.StatusCommandStderr ?? "")</pre>
}
@if (_status.reloadCommandConfigured)
@if (_status.ReloadCommandConfigured)
{
<div class="d-flex align-items-center gap-2 flex-wrap">
<button class="btn btn-primary btn-sm" @onclick="ReloadDnsmasq" disabled="@_reloading">
@@ -56,31 +59,111 @@ else if (_status != null)
}
</div>
}
@* Full service status — output of StatusShowCommand; format depends on command (systemctl on host, ps/pgrep in containers) *@
@if (_status.StatusShowConfigured)
{
<p class="mb-1 mt-3"><strong>Service status</strong> <span class="text-muted small">(output of StatusShowCommand)</span></p>
@if (string.IsNullOrWhiteSpace(_status.StatusShowOutput))
{
<p class="text-muted small">No output. On a systemd host use <code>systemctl status dnsmasq --no-pager</code> for full status; in Docker or without systemd use <code>ps -C dnsmasq -o pid,user,args</code> or <code>pgrep -a dnsmasq</code> for minimal process info.</p>
}
else
{
<pre class="small bg-dark text-light p-2 rounded mb-0" style="max-height: 22rem; overflow: auto;">@_status.StatusShowOutput</pre>
}
}
@* Recent logs — live view, auto-refreshes *@
@if (_status.LogsConfigured)
{
<p class="mb-1 mt-3"><strong>Recent logs</strong> <span class="text-muted small">(live, refreshes every @(_refreshIntervalSeconds)s)</span></p>
@if (string.IsNullOrWhiteSpace(_status.LogsOutput))
{
<p class="text-muted small">No output.</p>
}
else
{
<pre class="small bg-dark text-light p-2 rounded mb-0" style="max-height: 24rem; overflow: auto;">@_status.LogsOutput</pre>
}
}
@if (_status.StatusShowConfigured || _status.LogsConfigured)
{
<div class="mt-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshStatus" disabled="@_refreshing">Refresh status &amp; logs</button>
</div>
}
</section>
<section class="mb-4">
<h2 class="h5 mb-2">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>
<ul class="list-unstyled small">
<li><strong>no-hosts:</strong> @(_status.NoHosts ? "Yes (hosts files disabled)" : "No")</li>
<li>
<strong>addn-hosts:</strong>
@if (_status.AddnHostsPaths?.Count > 0)
{
var deduped = _status.AddnHostsPaths.Distinct().ToList();
@(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped))
}
else
{
<span class="text-muted">(none)</span>
}
</li>
@if (_status.SystemHostsPath != null)
{
<li><strong>System hosts file (editable):</strong> @_status.SystemHostsPath @(_status.SystemHostsPathExists ? "✓" : "(missing)")</li>
}
</ul>
@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>
<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> }
@if (ec.Port.HasValue) { <li><strong>port:</strong> @ec.Port.Value</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> }
</ul>
}
</section>
<section class="mb-4">
<h2 class="h5 mb-2">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>
<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)
@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)
@foreach (var f in _configSet.Files)
{
<li>
@if (f.isManaged)
@if (f.IsManaged)
{
<span class="badge bg-primary me-1">managed</span>
}
else
{
<span class="badge bg-secondary me-1">@((f.source ?? "").ToLowerInvariant())</span>
<span class="badge bg-secondary me-1">@(f.Source.ToString().ToLowerInvariant())</span>
}
@f.fileName
<span class="text-muted">(@f.path)</span>
@f.FileName
<span class="text-muted">(@f.Path)</span>
</li>
}
</ul>
@@ -90,12 +173,11 @@ else if (_status != null)
<section class="mb-4">
<h2 class="h5 mb-2">Paths</h2>
<ul class="list-unstyled small">
<li><strong>Hosts file:</strong> @(_status.hostsPath ?? "—") @(_status.hostsPathExists ? "✓" : "(missing)")</li>
<li>
<strong>Leases file:</strong> @(_status.leasesPath ?? "—")
@if (_status.leasesPathConfigured)
<strong>Leases file:</strong> @(_status.LeasesPath ?? "—")
@if (_status.LeasesPathConfigured)
{
<span>@(_status.leasesPathExists ? "✓" : "(missing)")</span>
<span>@(_status.LeasesPathExists ? "✓" : "(missing)")</span>
}
else
{
@@ -109,72 +191,52 @@ else if (_status != null)
<h2 class="h5 mb-2">Quick links</h2>
<div class="d-flex flex-wrap gap-2">
<a href="hosts" class="btn btn-outline-primary btn-sm">Hosts</a>
<a href="dhcp" class="btn btn-outline-primary btn-sm">DHCP static hosts</a>
<a href="leases" class="btn btn-outline-primary btn-sm">Leases</a>
<a href="dhcp" class="btn btn-outline-primary btn-sm">DHCP</a>
</div>
</section>
}
@code {
private OverviewStatusDto? _status;
private OverviewConfigSetDto? _configSet;
private const int _refreshIntervalSeconds = 15;
private DnsmasqServiceStatus? _status;
private DnsmasqConfigSet? _configSet;
private string? _error;
private bool _loading = true;
private bool _reloading;
private bool _refreshing;
private string? _reloadMessage;
private string? _reloadDetail;
private bool _reloadSuccess;
private readonly CancellationTokenSource _cts = new();
private class OverviewConfigSetDto
{
public string? mainConfigPath { get; set; }
public string? managedFilePath { get; set; }
public List<OverviewConfigSetEntryDto>? files { get; set; }
}
private class OverviewConfigSetEntryDto
{
public string? path { get; set; }
public string? fileName { get; set; }
public string? source { get; set; }
public bool isManaged { get; set; }
}
private class OverviewStatusDto
{
public string? hostsPath { get; set; }
public string? mainConfigPath { get; set; }
public string? managedFilePath { get; set; }
public string? leasesPath { get; set; }
public bool hostsPathExists { get; set; }
public bool mainConfigPathExists { get; set; }
public bool managedFilePathExists { get; set; }
public bool leasesPathConfigured { get; set; }
public bool leasesPathExists { get; set; }
public bool reloadCommandConfigured { get; set; }
public bool statusCommandConfigured { get; set; }
public string? dnsmasqStatus { get; set; }
public int? statusCommandExitCode { get; set; }
public string? statusCommandStdout { get; set; }
public string? statusCommandStderr { get; set; }
}
private Timer? _refreshTimer;
protected override async Task OnInitializedAsync()
{
_loading = true;
try
{
await Task.Delay(1, _cts.Token);
var token = _cts.Token;
_status = await Http.GetFromJsonAsync<OverviewStatusDto>("api/status", token);
_status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
try
{
_configSet = await Http.GetFromJsonAsync<OverviewConfigSetDto>("api/config/set", token);
_configSet = await ConfigSetClient.GetConfigSetAsync(token);
}
catch
{
// Config set is optional for overview
}
if (_status.StatusShowConfigured || _status.LogsConfigured)
{
_refreshTimer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_refreshIntervalSeconds),
TimeSpan.FromSeconds(_refreshIntervalSeconds));
}
}
}
catch (OperationCanceledException)
@@ -185,49 +247,86 @@ else if (_status != null)
{
_error = ex.Message;
}
finally
{
_loading = false;
}
}
private static string GetStatusLabel(OverviewStatusDto s)
private async Task OnRefreshTick()
{
return s.dnsmasqStatus switch
if (_cts.Token.IsCancellationRequested) return;
await RefreshStatus();
StateHasChanged();
}
private async Task RefreshStatus()
{
if (_status == null) return;
_refreshing = true;
try
{
var token = _cts.Token;
_status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
try
{
_configSet = await ConfigSetClient.GetConfigSetAsync(token);
}
catch
{
// Config set is optional
}
}
}
catch (OperationCanceledException) { }
catch (Exception)
{
// Don't overwrite _error on background refresh
}
finally
{
_refreshing = false;
}
}
private static string GetStatusLabel(DnsmasqServiceStatus s)
{
return s.DnsmasqStatus switch
{
"active" => "Running",
"inactive" => "Not running",
"unknown" => "Unknown",
"notConfigured" => "Status check not configured",
_ => s.dnsmasqStatus ?? "—"
_ => s.DnsmasqStatus ?? "—"
};
}
private async Task ReloadDnsmasq()
{
if (_status == null || !_status.reloadCommandConfigured || _reloading) return;
if (_status == null || !_status.ReloadCommandConfigured || _reloading) return;
_reloading = true;
_reloadMessage = null;
_reloadDetail = null;
try
{
var token = _cts.Token;
var result = await Http.PostAsync("api/reload", null, token);
var body = await result.Content.ReadAsStringAsync(token);
ReloadResultDto? reload = null;
try { reload = System.Text.Json.JsonSerializer.Deserialize<ReloadResultDto>(body); } catch { }
if (reload?.success == true)
var result = await ReloadClient.ReloadAsync(token);
if (result.Success)
{
_reloadMessage = "Reloaded.";
_reloadSuccess = true;
}
else
{
_reloadMessage = reload != null
? "Reload failed" + (reload.exitCode.HasValue ? " (exit " + reload.exitCode.Value + ")" : "") + "."
: (result.IsSuccessStatusCode ? "Reload failed." : "Request failed: " + body);
_reloadDetail = BuildReloadDetail(reload);
_reloadMessage = "Reload failed" + (result.ExitCode != 0 ? " (exit " + result.ExitCode + ")" : "") + ".";
_reloadDetail = BuildReloadDetail(result);
_reloadSuccess = false;
}
try
{
_status = await Http.GetFromJsonAsync<OverviewStatusDto>("api/status", token);
_status = await StatusClient.GetStatusAsync(token);
}
catch
{
@@ -245,25 +344,18 @@ else if (_status != null)
}
}
private class ReloadResultDto
private static string? BuildReloadDetail(ReloadResult r)
{
public bool success { get; set; }
public int? exitCode { get; set; }
public string? stdOut { get; set; }
public string? stdErr { get; set; }
}
private static string? BuildReloadDetail(ReloadResultDto? r)
{
if (r == null) return null;
var parts = new List<string>();
if (!string.IsNullOrEmpty(r.stdOut)) parts.Add("stdout:\n" + r.stdOut);
if (!string.IsNullOrEmpty(r.stdErr)) parts.Add("stderr:\n" + r.stdErr);
if (!string.IsNullOrEmpty(r.StdOut)) parts.Add("stdout:\n" + r.StdOut);
if (!string.IsNullOrEmpty(r.StdErr)) parts.Add("stderr:\n" + r.StdErr);
return parts.Count > 0 ? string.Join("\n\n", parts) : null;
}
public void Dispose()
{
_refreshTimer?.Dispose();
_refreshTimer = null;
_cts.Cancel();
_cts.Dispose();
}
+37 -36
View File
@@ -1,6 +1,8 @@
@page "/hosts"
@rendermode InteractiveServer
@inject HttpClient Http
@namespace DnsmasqWebUI.Components.Pages
@inject IStatusClient StatusClient
@inject IHostsClient HostsClient
<PageTitle>Hosts</PageTitle>
@@ -10,15 +12,31 @@
{
<p>Loading...</p>
}
else if (!_status.hostsPathExists)
else if (_status.NoHosts && !_pathInAddnHosts)
{
<p class="text-warning">Hosts file not found: @_status.hostsPath</p>
<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>
}
}
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>
}
}
else if (!_status.SystemHostsPathExists)
{
<p class="text-warning">System hosts file not found: @_status.SystemHostsPath</p>
}
else
{
@if (_status.dnsmasqStatus == "inactive" || _status.dnsmasqStatus == "unknown")
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
{
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.dnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
}
@if (_error != null)
{
@@ -43,23 +61,21 @@ else
}
</tbody>
</table>
<p class="text-muted small">Path: @_status.hostsPath</p>
<p class="text-muted small">Path: @_status.SystemHostsPath</p>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
}
@code {
private HostsStatusDto? _status;
private List<DnsmasqWebUI.Models.HostEntry> _entries = new();
private DnsmasqServiceStatus? _status;
private List<HostEntry> _entries = new();
private string? _error;
private string? _message;
private bool _saving;
private class HostsStatusDto
{
public string? hostsPath { get; set; }
public bool hostsPathExists { get; set; }
public string? dnsmasqStatus { get; set; }
}
/// <summary>True when SystemHostsPath is in the effective addn-hosts list (editing allowed when no-hosts is set).</summary>
private bool _pathInAddnHosts => _status != null
&& !string.IsNullOrEmpty(_status.SystemHostsPath)
&& _status.AddnHostsPaths?.Any(p => string.Equals(p, Path.GetFullPath(_status.SystemHostsPath!.Trim()), StringComparison.Ordinal)) == true;
protected override async Task OnInitializedAsync()
{
@@ -72,10 +88,9 @@ else
_message = null;
try
{
_status = await Http.GetFromJsonAsync<HostsStatusDto>("api/status");
var entries = await Http.GetFromJsonAsync<List<DnsmasqWebUI.Models.HostEntry>>("api/hosts");
if (entries != null)
_entries = entries;
_status = await StatusClient.GetStatusAsync();
var entries = await HostsClient.GetHostsAsync();
_entries = entries?.ToList() ?? new List<HostEntry>();
}
catch (Exception ex)
{
@@ -90,18 +105,11 @@ else
_message = null;
try
{
var response = await Http.PutAsJsonAsync("api/hosts", _entries);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
_error = body;
return;
}
var result = await response.Content.ReadFromJsonAsync<SaveResultDto>();
if (result?.reload?.success == true)
var result = await HostsClient.SaveHostsAsync(_entries);
if (result.Reload.Success)
_message = "Saved and dnsmasq reloaded.";
else if (result?.reload != null && !string.IsNullOrEmpty(result.reload.stdErr))
_error = "Saved but dnsmasq reload failed: " + result.reload.stdErr;
else if (!string.IsNullOrEmpty(result.Reload.StdErr))
_error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr;
else
_message = "Saved.";
}
@@ -114,11 +122,4 @@ else
_saving = false;
}
}
private class SaveResultDto
{
public bool saved { get; set; }
public ReloadDto? reload { get; set; }
}
private class ReloadDto { public bool success { get; set; } public string? stdErr { get; set; } }
}
@@ -1,117 +0,0 @@
@page "/leases"
@rendermode InteractiveServer
@inject HttpClient Http
<PageTitle>DHCP Leases</PageTitle>
<h1>DHCP Leases</h1>
@if (_status == null)
{
<p>Loading...</p>
}
else if (!_status.leasesPathConfigured)
{
<p class="text-warning">Leases not configured.</p>
}
else
{
@if (_error != null)
{
<div class="alert alert-danger">@_error</div>
}
@if (!_status.leasesPathExists)
{
<p class="text-warning">Leases file not found: @_status.leasesPath. DHCP may be disabled or path may be wrong.</p>
}
else if (_leases == null)
{
<p class="text-warning">Could not read leases file.</p>
}
else
{
<table class="table table-bordered">
<thead>
<tr><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th></tr>
</thead>
<tbody>
@foreach (var e in _leases)
{
<tr>
<td>@e.timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td>@e.mac</td>
<td>@e.address</td>
<td>@e.name</td>
<td>@e.clientId</td>
</tr>
}
</tbody>
</table>
<p class="text-muted small">Path: @_status.leasesPath (read-only). Cached and refreshed when the file changes; use Refresh to refetch now.</p>
<button class="btn btn-outline-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_loading">Refresh</button>
}
}
@code {
private StatusDto? _status;
private List<LeaseDto>? _leases;
private string? _error;
private bool _loading;
private class StatusDto
{
public string? leasesPath { get; set; }
public bool leasesPathConfigured { get; set; }
public bool leasesPathExists { get; set; }
}
private class LeaseDto
{
public long epoch { get; set; }
public string? mac { get; set; }
public string? address { get; set; }
public string? name { get; set; }
public string? clientId { get; set; }
public DateTime timestamp => DateTimeOffset.FromUnixTimeSeconds(epoch).DateTime;
}
private class LeasesResponse
{
public bool available { get; set; }
public List<LeaseDto>? entries { get; set; }
}
protected override async Task OnInitializedAsync()
{
try
{
_status = await Http.GetFromJsonAsync<StatusDto>("api/status");
await LoadLeases();
}
catch (Exception ex)
{
_error = ex.Message;
}
}
private async Task LoadLeases()
{
if (_status == null || !_status.leasesPathConfigured) return;
_loading = true;
_error = null;
try
{
var resp = await Http.GetFromJsonAsync<LeasesResponse>("api/leases");
if (resp?.entries != null)
_leases = resp.entries;
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_loading = false;
}
}
}
+4 -1
View File
@@ -1,4 +1,4 @@
@using System.Net.Http
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@@ -8,3 +8,6 @@
@using Microsoft.JSInterop
@using DnsmasqWebUI
@using DnsmasqWebUI.Components
@using DnsmasqWebUI.Models
@using DnsmasqWebUI.Http.Clients
@using DnsmasqWebUI.Services.Abstractions
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
@@ -49,7 +50,7 @@ public class ConfigController : ControllerBase
}
}
/// <summary>Writes the full managed config file (ensures one addn-hosts line = HostsPath), then optionally triggers reload.</summary>
/// <summary>Writes the full managed config file, then optionally triggers reload. When SystemHostsPath is configured, the managed file is updated so it contains exactly one addn-hosts line pointing to that path (so dnsmasq loads the editable hosts file); other config files may have additional addn-hosts lines.</summary>
[HttpPut("managed")]
public async Task<ActionResult<object>> PutManaged([FromBody] List<DnsmasqConfLine>? lines, CancellationToken ct)
{
@@ -32,7 +32,7 @@ public class DhcpController : ControllerBase
}
[HttpPut("hosts")]
public async Task<ActionResult<object>> PutHosts([FromBody] List<DhcpHostEntry>? entries, CancellationToken ct)
public async Task<ActionResult<SaveWithReloadResult>> PutHosts([FromBody] List<DhcpHostEntry>? entries, CancellationToken ct)
{
if (entries == null)
return BadRequest(new { error = "Body required" });
@@ -40,7 +40,7 @@ public class DhcpController : ControllerBase
{
await _configService.WriteDhcpHostsAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(ct);
return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } });
return Ok(new SaveWithReloadResult(true, reload));
}
catch (Exception ex)
{
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
@@ -10,11 +11,19 @@ public class HostsController : ControllerBase
{
private readonly IHostsFileService _hostsService;
private readonly IReloadService _reloadService;
private readonly IDnsmasqConfigSetService _configSetService;
private readonly DnsmasqOptions _options;
public HostsController(IHostsFileService hostsService, IReloadService reloadService)
public HostsController(
IHostsFileService hostsService,
IReloadService reloadService,
IDnsmasqConfigSetService configSetService,
Microsoft.Extensions.Options.IOptions<DnsmasqOptions> options)
{
_hostsService = hostsService;
_reloadService = reloadService;
_configSetService = configSetService;
_options = options.Value;
}
[HttpGet]
@@ -32,15 +41,23 @@ public class HostsController : ControllerBase
}
[HttpPut]
public async Task<ActionResult<object>> Put([FromBody] List<HostEntry>? entries, CancellationToken ct)
public async Task<ActionResult<SaveWithReloadResult>> Put([FromBody] List<HostEntry>? entries, CancellationToken ct)
{
if (entries == null)
return BadRequest(new { error = "Body required" });
var effectiveConfig = _configSetService.GetEffectiveConfig();
if (string.IsNullOrWhiteSpace(_options.SystemHostsPath))
return BadRequest(new { error = "No system hosts file configured. Set Dnsmasq:SystemHostsPath to enable hosts editing." });
// When no-hosts is set, dnsmasq only uses addn-hosts files; allow editing only if our path is in that list.
var systemPath = Path.GetFullPath(_options.SystemHostsPath.Trim());
var pathInAddnHosts = effectiveConfig.AddnHostsPaths?.Any(p => string.Equals(p, systemPath, StringComparison.Ordinal)) == true;
if (effectiveConfig.NoHosts && !pathInAddnHosts)
return BadRequest(new { error = "Hosts are disabled by no-hosts in dnsmasq config, and the configured path is not in addn-hosts, so dnsmasq would not read it." });
try
{
await _hostsService.WriteAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(ct);
return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } });
return Ok(new SaveWithReloadResult(true, reload));
}
catch (Exception ex)
{
@@ -1,3 +1,4 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
@@ -15,16 +16,16 @@ public class LeasesController : ControllerBase
}
[HttpGet]
public async Task<ActionResult<object>> Get(CancellationToken ct)
public async Task<ActionResult<LeasesResult>> Get(CancellationToken ct)
{
try
{
var (available, entries) = await _leasesService.TryReadAsync(ct);
if (!available)
return Ok(new { available = false, entries = (IReadOnlyList<object>?)null, message = "Leases not configured." });
return Ok(new LeasesResult(false, null, "Leases not configured."));
if (entries == null)
return Ok(new { available = true, entries = Array.Empty<object>(), message = "Leases file not readable." });
return Ok(new { available = true, entries });
return Ok(new LeasesResult(true, Array.Empty<LeaseEntry>(), "Leases file not readable."));
return Ok(new LeasesResult(true, entries, null));
}
catch (Exception ex)
{
@@ -1,4 +1,4 @@
using System.Diagnostics;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
@@ -12,83 +12,81 @@ public class StatusController : ControllerBase
{
private readonly DnsmasqOptions _options;
private readonly IDnsmasqConfigSetService _configSetService;
private readonly IProcessRunner _processRunner;
public StatusController(IOptions<DnsmasqOptions> options, IDnsmasqConfigSetService configSetService)
public StatusController(
IOptions<DnsmasqOptions> options,
IDnsmasqConfigSetService configSetService,
IProcessRunner processRunner)
{
_options = options.Value;
_configSetService = configSetService;
_processRunner = processRunner;
}
[HttpGet]
public async Task<IActionResult> Get(CancellationToken ct)
public async Task<ActionResult<DnsmasqServiceStatus>> Get(CancellationToken ct)
{
try
{
var set = await _configSetService.GetConfigSetAsync(ct);
var (dnsmasqStatus, statusCommandExitCode, statusCommandStdout, statusCommandStderr) = GetDnsmasqServiceStatus(_options.StatusCommand);
var effectiveLeasesPath = _configSetService.GetLeasesPath();
var payload = new Dictionary<string, object?>
{
["hostsPath"] = _options.HostsPath,
["mainConfigPath"] = _options.MainConfigPath,
["managedFilePath"] = set.ManagedFilePath,
["leasesPath"] = effectiveLeasesPath,
["hostsPathExists"] = !string.IsNullOrEmpty(_options.HostsPath) && System.IO.File.Exists(_options.HostsPath),
["mainConfigPathExists"] = !string.IsNullOrEmpty(_options.MainConfigPath) && System.IO.File.Exists(_options.MainConfigPath),
["managedFilePathExists"] = !string.IsNullOrEmpty(set.ManagedFilePath) && System.IO.File.Exists(set.ManagedFilePath),
["leasesPathConfigured"] = !string.IsNullOrEmpty(effectiveLeasesPath),
["leasesPathExists"] = !string.IsNullOrEmpty(effectiveLeasesPath) && System.IO.File.Exists(effectiveLeasesPath),
["reloadCommandConfigured"] = !string.IsNullOrWhiteSpace(_options.ReloadCommand),
["statusCommandConfigured"] = !string.IsNullOrWhiteSpace(_options.StatusCommand),
["dnsmasqStatus"] = dnsmasqStatus
};
if (dnsmasqStatus != "active" && statusCommandExitCode.HasValue)
payload["statusCommandExitCode"] = statusCommandExitCode.Value;
if (dnsmasqStatus != "active" && !string.IsNullOrEmpty(statusCommandStdout))
payload["statusCommandStdout"] = statusCommandStdout;
if (dnsmasqStatus != "active" && !string.IsNullOrEmpty(statusCommandStderr))
payload["statusCommandStderr"] = statusCommandStderr;
return Ok(payload);
var effectiveConfig = _configSetService.GetEffectiveConfig();
var systemHostsPath = _options.SystemHostsPath?.Trim();
var statusResult = await _processRunner.RunAsync(_options.StatusCommand, TimeSpan.FromSeconds(5), ct);
var dnsmasqStatus = statusResult.ExitCode == 0 ? "active" : (statusResult.ExitCode.HasValue ? "inactive" : "unknown");
var statusCommandStdout = string.IsNullOrWhiteSpace(statusResult.Stdout) ? null : statusResult.Stdout.Trim();
var statusCommandStderr = string.IsNullOrWhiteSpace(statusResult.Stderr) ? null : statusResult.Stderr.Trim();
if (statusResult.ExceptionMessage != null)
statusCommandStderr = (statusCommandStderr ?? "") + (statusCommandStderr != null ? "\n" : "") + statusResult.ExceptionMessage;
var showTask = string.IsNullOrWhiteSpace(_options.StatusShowCommand)
? Task.FromResult(new ProcessRunResult(null, "", "", false))
: _processRunner.RunAsync(_options.StatusShowCommand, TimeSpan.FromSeconds(5), ct);
var logsTask = string.IsNullOrWhiteSpace(_options.LogsCommand)
? Task.FromResult(new ProcessRunResult(null, "", "", false))
: _processRunner.RunAsync(_options.LogsCommand, TimeSpan.FromSeconds(10), ct);
await Task.WhenAll(showTask, logsTask);
var showResult = await showTask;
var logsResult = await logsTask;
var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand)
? showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : "")
: null;
var logsOutput = !string.IsNullOrWhiteSpace(_options.LogsCommand)
? logsResult.Stdout + (logsResult.TimedOut ? "\n(Command timed out.)" : "")
: null;
var status = new DnsmasqServiceStatus(
SystemHostsPath: string.IsNullOrEmpty(systemHostsPath) ? null : systemHostsPath,
SystemHostsPathExists: !string.IsNullOrEmpty(systemHostsPath) && System.IO.File.Exists(systemHostsPath),
NoHosts: effectiveConfig.NoHosts,
AddnHostsPaths: effectiveConfig.AddnHostsPaths,
EffectiveConfig: effectiveConfig,
MainConfigPath: _options.MainConfigPath,
ManagedFilePath: set.ManagedFilePath,
LeasesPath: effectiveLeasesPath,
MainConfigPathExists: !string.IsNullOrEmpty(_options.MainConfigPath) && System.IO.File.Exists(_options.MainConfigPath),
ManagedFilePathExists: !string.IsNullOrEmpty(set.ManagedFilePath) && System.IO.File.Exists(set.ManagedFilePath),
LeasesPathConfigured: !string.IsNullOrEmpty(effectiveLeasesPath),
LeasesPathExists: !string.IsNullOrEmpty(effectiveLeasesPath) && System.IO.File.Exists(effectiveLeasesPath),
ReloadCommandConfigured: !string.IsNullOrWhiteSpace(_options.ReloadCommand),
StatusCommandConfigured: !string.IsNullOrWhiteSpace(_options.StatusCommand),
StatusShowConfigured: !string.IsNullOrWhiteSpace(_options.StatusShowCommand),
LogsConfigured: !string.IsNullOrWhiteSpace(_options.LogsCommand),
DnsmasqStatus: dnsmasqStatus,
StatusCommandExitCode: dnsmasqStatus != "active" && statusResult.ExitCode.HasValue ? statusResult.ExitCode.Value : null,
StatusCommandStdout: dnsmasqStatus != "active" ? statusCommandStdout : null,
StatusCommandStderr: dnsmasqStatus != "active" ? statusCommandStderr : null,
StatusShowOutput: statusShowOutput,
LogsOutput: logsOutput
);
return Ok(status);
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>Runs StatusCommand if configured; returns status and optional exit code/stdout/stderr for UI.</summary>
private static (string Status, int? ExitCode, string? Stdout, string? Stderr) GetDnsmasqServiceStatus(string? statusCommand)
{
if (string.IsNullOrWhiteSpace(statusCommand))
return ("notConfigured", null, null, null);
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "/bin/sh",
Arguments = "-c \"" + statusCommand.Replace("\"", "\\\"") + "\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit(TimeSpan.FromSeconds(5));
var exitCode = process.HasExited ? process.ExitCode : -1;
var status = exitCode == 0 ? "active" : "inactive";
return (status, exitCode,
string.IsNullOrWhiteSpace(stdout) ? null : stdout.Trim(),
string.IsNullOrWhiteSpace(stderr) ? null : stderr.Trim());
}
catch (Exception ex)
{
return ("unknown", null, null, ex.Message);
}
}
}
@@ -12,13 +12,14 @@ public static class ServiceCollectionExtensions
static readonly (Type MarkerInterface, RegisterService Register)[] ApplicationRegistrations =
[
(typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)),
(typeof(IApplicationScopedConcrete), (s, i, impl) => s.AddScoped(impl, impl)),
(typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)),
(typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl)),
];
/// <summary>
/// Scans the assembly for types implementing application marker interfaces
/// (<see cref="IApplicationScopedService"/>, <see cref="IApplicationSingleton"/>, <see cref="IApplicationHostedService"/>, etc.)
/// (<see cref="IApplicationScopedService"/>, <see cref="IApplicationScopedConcrete"/>, <see cref="IApplicationSingleton"/>, <see cref="IApplicationHostedService"/>, etc.)
/// and registers each with the configured lifetime. Hosted services are registered via <c>AddHostedService&lt;T&gt;</c>.
/// Skips open generics; requires exactly one public implementation per interface (scoped/singleton).
/// </summary>
@@ -0,0 +1,15 @@
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
public sealed class ConfigSetClient : IConfigSetClient
{
private readonly HttpClient _http;
public ConfigSetClient(HttpClient http) => _http = http;
public async Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default) =>
await _http.GetFromJsonAsync<DnsmasqConfigSet>("api/config/set", ct)
?? throw new InvalidOperationException("Unexpected null from api/config/set.");
}
@@ -0,0 +1,25 @@
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
public sealed class DhcpHostsClient : IDhcpHostsClient
{
private readonly HttpClient _http;
public DhcpHostsClient(HttpClient http) => _http = http;
public async Task<IReadOnlyList<DhcpHostEntry>> GetDhcpHostsAsync(CancellationToken ct = default)
{
var list = await _http.GetFromJsonAsync<List<DhcpHostEntry>>("api/dhcp/hosts", ct);
return list ?? new List<DhcpHostEntry>();
}
public async Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
{
var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct)
?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts.");
}
}
@@ -0,0 +1,25 @@
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
public sealed class HostsClient : IHostsClient
{
private readonly HttpClient _http;
public HostsClient(HttpClient http) => _http = http;
public async Task<IReadOnlyList<HostEntry>> GetHostsAsync(CancellationToken ct = default)
{
var list = await _http.GetFromJsonAsync<List<HostEntry>>("api/hosts", ct);
return list ?? new List<HostEntry>();
}
public async Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
{
var response = await _http.PutAsJsonAsync("api/hosts", entries, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct)
?? throw new InvalidOperationException("Unexpected null from api/hosts.");
}
}
@@ -0,0 +1,9 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Http.Clients;
/// <summary>Typed client for GET api/config/set.</summary>
public interface IConfigSetClient
{
Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Http.Clients;
/// <summary>Typed client for GET/PUT api/dhcp/hosts.</summary>
public interface IDhcpHostsClient
{
Task<IReadOnlyList<DhcpHostEntry>> GetDhcpHostsAsync(CancellationToken ct = default);
Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Http.Clients;
/// <summary>Typed client for GET/PUT api/hosts.</summary>
public interface IHostsClient
{
Task<IReadOnlyList<HostEntry>> GetHostsAsync(CancellationToken ct = default);
Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Http.Clients;
/// <summary>Typed client for GET api/leases.</summary>
public interface ILeasesClient
{
Task<LeasesResult> GetLeasesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Http.Clients;
/// <summary>Typed client for POST api/reload.</summary>
public interface IReloadClient
{
Task<ReloadResult> ReloadAsync(CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Http.Clients;
/// <summary>Typed client for GET api/status.</summary>
public interface IStatusClient
{
Task<DnsmasqServiceStatus> GetStatusAsync(CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
public sealed class LeasesClient : ILeasesClient
{
private readonly HttpClient _http;
public LeasesClient(HttpClient http) => _http = http;
public async Task<LeasesResult> GetLeasesAsync(CancellationToken ct = default) =>
await _http.GetFromJsonAsync<LeasesResult>("api/leases", ct)
?? throw new InvalidOperationException("Unexpected null from api/leases.");
}
@@ -0,0 +1,19 @@
using DnsmasqWebUI.Services.Abstractions;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
public sealed class ReloadClient : IReloadClient
{
private readonly HttpClient _http;
public ReloadClient(HttpClient http) => _http = http;
public async Task<ReloadResult> ReloadAsync(CancellationToken ct = default)
{
var response = await _http.PostAsync("api/reload", null, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ReloadResult>(ct)
?? throw new InvalidOperationException("Unexpected null from api/reload.");
}
}
@@ -0,0 +1,15 @@
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
public sealed class StatusClient : IStatusClient
{
private readonly HttpClient _http;
public StatusClient(HttpClient http) => _http = http;
public async Task<DnsmasqServiceStatus> GetStatusAsync(CancellationToken ct = default) =>
await _http.GetFromJsonAsync<DnsmasqServiceStatus>("api/status", ct)
?? throw new InvalidOperationException("Unexpected null from api/status.");
}
@@ -1,4 +1,5 @@
using System.Net;
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Http;
@@ -8,17 +9,17 @@ namespace DnsmasqWebUI.Http;
/// Resolved in the same scope as the code that requested the HttpClient (e.g. Blazor component),
/// so IHttpContextAccessor has the current request when available.
/// </summary>
public sealed class SameHostBaseAddressHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler
public sealed class SameHostBaseAddressHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler, IApplicationScopedConcrete
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is { IsAbsoluteUri: false })
var context = httpContextAccessor.HttpContext;
if (context != null && request.RequestUri != null)
{
var context = httpContextAccessor.HttpContext;
var baseUri = context != null
? new Uri($"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value ?? ""}")
: new Uri("http://localhost", UriKind.Absolute);
request.RequestUri = new Uri(baseUri, request.RequestUri);
var baseUri = new Uri($"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value ?? ""}");
request.RequestUri = request.RequestUri.IsAbsoluteUri
? new Uri(baseUri, request.RequestUri.PathAndQuery)
: new Uri(baseUri, request.RequestUri);
}
return base.SendAsync(request, cancellationToken);
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Config;
/// <summary>Kind of line in a dnsmasq .conf file. One option per line, key=value (no leading --), # for comments.</summary>
public enum DnsmasqConfLineKind
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Config;
/// <summary>
/// Registry of dnsmasq .conf option names to their <see cref="DnsmasqOptionKind"/>.
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Config;
/// <summary>
/// Kind of dnsmasq .conf option. Each kind has a dedicated parser and backing model
@@ -35,7 +35,7 @@ public enum DnsmasqOptionKind
// --- DHCP (structured) ---
/// <summary>dhcp-range=... → <see cref="DhcpRangeOption"/> (complex)</summary>
DhcpRange,
/// <summary>dhcp-host=... → <see cref="DhcpHostEntry"/> (already have parser)</summary>
/// <summary>dhcp-host=... → DhcpHostEntry (in DnsmasqWebUI.Models)</summary>
DhcpHost,
/// <summary>dhcp-option=... (complex)</summary>
DhcpOption,
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Config;
/// <summary>Backing model for conf-file=path. Parsed from main config only.</summary>
public record ConfFileOption(string Path, int LineNumber, string SourceFilePath);
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Models.Config;
/// <summary>
/// One parsed directive from a dnsmasq .conf file. Kind tells which option type;
@@ -0,0 +1,27 @@
namespace DnsmasqWebUI.Models;
/// <summary>Dnsmasq service and config paths status returned by api/status.</summary>
public record DnsmasqServiceStatus(
string? SystemHostsPath,
bool SystemHostsPathExists,
bool NoHosts,
IReadOnlyList<string> AddnHostsPaths,
EffectiveDnsmasqConfig? EffectiveConfig,
string? MainConfigPath,
string? ManagedFilePath,
string? LeasesPath,
bool MainConfigPathExists,
bool ManagedFilePathExists,
bool LeasesPathConfigured,
bool LeasesPathExists,
bool ReloadCommandConfigured,
bool StatusCommandConfigured,
bool StatusShowConfigured,
bool LogsConfigured,
string DnsmasqStatus,
int? StatusCommandExitCode,
string? StatusCommandStdout,
string? StatusCommandStderr,
string? StatusShowOutput,
string? LogsOutput
);
@@ -0,0 +1,43 @@
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.
/// </summary>
public record EffectiveDnsmasqConfig(
// --- Hosts (already used by Hosts UI) ---
bool NoHosts,
IReadOnlyList<string> AddnHostsPaths,
// --- Boolean flags (set if any file contains the option) ---
bool ExpandHosts,
bool BogusPriv,
bool StrictOrder,
bool NoResolv,
bool DomainNeeded,
bool NoPoll,
bool BindInterfaces,
bool NoNegcache,
bool DhcpAuthoritative,
bool LeasefileRo,
// --- Single-value options (last occurrence wins; null = not set, dnsmasq default) ---
string? DhcpLeaseFilePath,
int? CacheSize,
int? Port,
int? LocalTtl,
string? PidFilePath,
string? User,
string? Group,
string? LogFacility,
int? DhcpLeaseMax,
int? NegTtl,
int? MaxTtl,
int? MaxCacheTtl,
int? MinCacheTtl,
int? DhcpTtl
);
+4
View File
@@ -0,0 +1,4 @@
namespace DnsmasqWebUI.Models;
/// <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);
@@ -0,0 +1,6 @@
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Models;
/// <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);
+9 -3
View File
@@ -3,7 +3,7 @@ namespace DnsmasqWebUI.Options;
/// <summary>
/// Configuration for dnsmasq paths and reload/status commands.
/// File permissions: the app must be able to read MainConfigPath and the conf-dir (or conf-file) target,
/// and to create/update the managed config file and HostsPath. In the Docker image (Dockerfile)
/// and to create/update the managed config file and SystemHostsPath. In the Docker image (Dockerfile)
/// app and dnsmasq run in one container as root, so this works. When the UI runs in a container and
/// dnsmasq is on the host, bind-mount the host config dir (e.g. /etc/dnsmasq.d) into the container;
/// the container process typically needs to run as root (or the host dir must be writable by the
@@ -23,12 +23,18 @@ public class DnsmasqOptions
/// <summary>Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in &lt;main-config-dir&gt;/dnsmasq.d/ and included via conf-file= at the end of the main config so it loads last. Managed file content parsed with DnsmasqConfFileLineParser.</summary>
public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf";
/// <summary>Path we write as addn-hosts= in the managed file; HostsFileService reads/writes this path. Process must have read/write access.</summary>
public string HostsPath { get; set; } = "";
/// <summary>Optional path to the hosts file the app can edit (e.g. /etc/hosts). When set, the app can read/write it. When no-hosts is not set, the app adds addn-hosts= in the managed file so dnsmasq loads it; when no-hosts is set, dnsmasq only uses addn-hosts files, so this path must be one of the effective addn-hosts paths for editing to take effect. When unset, hosts UI is disabled.</summary>
public string? SystemHostsPath { get; set; }
/// <summary>Command to run after config changes (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). Runs in the same environment as the app; if app is in a container and dnsmasq is on the host, this runs in the container and will not reload host dnsmasq unless you use a host-side relay.</summary>
public string? ReloadCommand { get; set; }
/// <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>
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>
public string? LogsCommand { get; set; }
}
@@ -28,18 +28,9 @@ public sealed class DnsmasqOptionsValidator : IValidateOptions<DnsmasqOptions>
}
}
if (string.IsNullOrWhiteSpace(options.HostsPath))
{
failures.Add("Dnsmasq:HostsPath is required. Set it in appsettings.json (e.g. \"HostsPath\": \"/etc/hosts\") or via the Dnsmasq__HostsPath environment variable.");
}
else
{
var hostsPath = Path.GetFullPath(options.HostsPath.Trim());
if (!File.Exists(hostsPath))
{
failures.Add($"Hosts file not found: {hostsPath}. Ensure Dnsmasq:HostsPath points to an existing hosts file (e.g. /etc/hosts). Override with Dnsmasq__HostsPath if using a different path.");
}
}
// SystemHostsPath is optional. When set, the app can edit that hosts file. Hosts UI is disabled when
// SystemHostsPath is unset, or when no-hosts is set and SystemHostsPath is not in the effective addn-hosts
// list (dnsmasq only uses addn-hosts when no-hosts is set, so the path must be in addn-hosts for editing to take effect).
if (failures.Count == 0)
return ValidateOptionsResult.Success;
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
@@ -94,9 +94,82 @@ public static class DnsmasqConfIncludeParser
return null;
}
/// <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).
/// </summary>
public static bool GetFlagFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder, string optionKey)
{
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return false;
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 (!k.Equals(key, StringComparison.OrdinalIgnoreCase))
continue;
return true;
}
}
return false;
}
/// <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.
/// </summary>
public static (string? Value, string? ConfigFileDir) GetLastValueFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder, string optionKey)
{
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return (null, null);
string? lastValue = null;
string? lastDir = 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 (!k.Equals(key, StringComparison.OrdinalIgnoreCase))
continue;
var trimmed = value.Trim();
lastValue = trimmed;
lastDir = dir;
}
}
return (lastValue, lastDir);
}
/// <summary>
/// Resolves a path value against the config file directory. If value is null/empty or already absolute, returns as-is (or null).
/// </summary>
public static string? ResolvePath(string? value, string? configFileDir)
{
if (string.IsNullOrWhiteSpace(value))
return null;
if (Path.IsPathRooted(value))
return value;
if (string.IsNullOrEmpty(configFileDir))
return Path.GetFullPath(value);
return Path.GetFullPath(Path.Combine(configFileDir, value));
}
/// <summary>
/// Reads the given config files in order and returns the last <c>dhcp-leasefile=</c> or <c>dhcp-lease-file=</c> path.
/// Relative paths are resolved against the config file's directory. Used so the app monitors the same leases file dnsmasq uses.
/// Both option names are the same in dnsmasq (last occurrence of either wins). Relative paths resolved against config file dir.
/// </summary>
public static string? GetDhcpLeaseFilePathFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder)
{
@@ -117,12 +190,19 @@ public static class DnsmasqConfIncludeParser
continue;
var path = value.Trim();
if (!string.IsNullOrEmpty(path))
result = Path.GetFullPath(Path.Combine(dir, path));
result = ResolvePath(path, dir) ?? result;
}
}
return result;
}
/// <summary>
/// Reads the given config files in order and returns true if <c>no-hosts</c> appears in any file.
/// When true, dnsmasq does not read /etc/hosts; only addn-hosts= files are used (if any).
/// </summary>
public static bool GetNoHostsFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder) =>
GetFlagFromConfigFiles(configFilePathsInOrder, "no-hosts");
/// <summary>
/// Reads the given config files in order and returns all <c>addn-hosts=</c> paths (cumulative; dnsmasq loads each in order).
/// Relative paths are resolved against the config file's directory. Used so the app can show which hosts files dnsmasq loads.
+44 -36
View File
@@ -1,38 +1,48 @@
using System.Net;
using DnsmasqWebUI.Components;
using DnsmasqWebUI.Extensions;
using DnsmasqWebUI.Http;
using DnsmasqWebUI.Http.Clients;
using DnsmasqWebUI.Options;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
// CreateBuilder(args) loads config in order: appsettings.json, appsettings.{Environment}.json,
// env vars; override via e.g. Dnsmasq__ReloadCommand= or ForwardedHeaders__Enabled=true.
var builder = WebApplication.CreateBuilder(args);
// ---- Dnsmasq options (required paths validated at startup) ----
builder.Services.AddOptions<DnsmasqOptions>()
.Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName))
.ValidateOnStart();
builder.Services.AddSingleton<Microsoft.Extensions.Options.IValidateOptions<DnsmasqOptions>, DnsmasqOptionsValidator>();
builder.Services.AddSingleton<IValidateOptions<DnsmasqOptions>, DnsmasqOptionsValidator>();
// ---- Application services ----
builder.Services.AddApplicationServices();
builder.Services.AddHttpContextAccessor();
// Same-host HttpClient via IHttpClientFactory: relative URIs (e.g. /api/status) are rewritten to the
// current request's scheme/host/path base by SameHostBaseAddressHandler. Components keep @inject HttpClient.
// Options.DefaultName is the "default" client name (empty string) used when you inject HttpClient or call CreateClient() with no name.
builder.Services.AddHttpClient(Microsoft.Extensions.Options.Options.DefaultName)
.AddHttpMessageHandler<DnsmasqWebUI.Http.SameHostBaseAddressHandler>();
// Same app hosts both:
// - API: AddControllers() + MapControllers() → routes like /api/status, /api/hosts, /api/reload.
// - Blazor: AddRazorComponents + AddInteractiveServerComponents + MapRazorComponents<App>() → pages like /, /hosts, /dhcp, /leases.
const string ApiClientName = "DnsmasqWebUI.Api";
builder.Services.AddHttpClient(ApiClientName, client =>
{
client.BaseAddress = new Uri("http://localhost/", UriKind.Absolute);
})
.AddHttpMessageHandler<SameHostBaseAddressHandler>();
builder.Services.AddHttpClient<IStatusClient, StatusClient>(ApiClientName);
builder.Services.AddHttpClient<IConfigSetClient, ConfigSetClient>(ApiClientName);
builder.Services.AddHttpClient<IReloadClient, ReloadClient>(ApiClientName);
builder.Services.AddHttpClient<IHostsClient, HostsClient>(ApiClientName);
builder.Services.AddHttpClient<IDhcpHostsClient, DhcpHostsClient>(ApiClientName);
builder.Services.AddHttpClient<ILeasesClient, LeasesClient>(ApiClientName);
builder.Services.AddControllers()
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()));
.AddJsonOptions(o =>
{
o.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
o.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// CORS: only added when Cors:Enabled is true. Configure origins/methods/headers in appsettings or
// env (e.g. Cors__Enabled=true, Cors__AllowedOrigins__0=https://app.example.com).
// ---- CORS (optional; Cors:Enabled in appsettings or env) ----
var corsEnabled = builder.Configuration.GetValue<bool>("Cors:Enabled");
if (corsEnabled)
{
@@ -64,34 +74,22 @@ if (corsEnabled)
var app = builder.Build();
// Trigger Dnsmasq options validation and print a clear error to stderr if it fails (before any stack trace).
// ---- Fail fast: validate Dnsmasq options and exit with clear message if invalid ----
try
{
_ = app.Services.GetRequiredService<IOptions<DnsmasqOptions>>().Value;
}
catch (OptionsValidationException ex)
{
var err = Console.Error;
err.WriteLine();
err.WriteLine("*** DNSMASQ-WEBUI CONFIGURATION ERROR ***");
err.WriteLine();
foreach (var failure in ex.Failures)
err.WriteLine(" • " + failure);
err.WriteLine();
err.WriteLine("Fix the configuration (appsettings.json or Dnsmasq__* environment variables) and restart.");
err.WriteLine();
WriteValidationFailure(ex);
Environment.Exit(1);
}
// ---- Middleware ----
if (!app.Environment.IsDevelopment())
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// HTTPS: Kestrel can serve HTTPS via config (no code needed). Set ASPNETCORE_URLS=https://*:5001 and
// ASPNETCORE_Kestrel__Certificates__Default__Path (and __Password) for the cert. When behind a
// reverse proxy that terminates TLS, set ForwardedHeaders__Enabled=true so X-Forwarded-Proto is used;
// set Https__UseRedirectAndHsts=true to redirect HTTP→HTTPS and send HSTS. Both default false.
var forwardedEnabled = builder.Configuration.GetValue<bool>("ForwardedHeaders:Enabled");
if (forwardedEnabled)
if (builder.Configuration.GetValue<bool>("ForwardedHeaders:Enabled"))
{
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
@@ -107,17 +105,27 @@ if (builder.Configuration.GetValue<bool>("Https:UseRedirectAndHsts"))
}
if (corsEnabled)
{
var policyName = builder.Configuration.GetValue<string>("Cors:PolicyName") ?? "Default";
app.UseCors(policyName);
}
app.UseCors(builder.Configuration.GetValue<string>("Cors:PolicyName") ?? "Default");
app.UseAntiforgery();
// Map API controllers first, then static assets, then Blazor (catch-all for SPA-style routes).
// ---- Endpoints ----
app.MapControllers();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
static void WriteValidationFailure(OptionsValidationException ex)
{
var err = Console.Error;
err.WriteLine();
err.WriteLine("*** DNSMASQ-WEBUI CONFIGURATION ERROR ***");
err.WriteLine();
foreach (var failure in ex.Failures)
err.WriteLine(" • " + failure);
err.WriteLine();
err.WriteLine("Fix the configuration (appsettings.json or Dnsmasq__* environment variables) and restart.");
err.WriteLine();
}
@@ -0,0 +1,9 @@
namespace DnsmasqWebUI.Services.Abstractions;
/// <summary>
/// Marker for types that are registered as their own concrete type with scoped lifetime.
/// Used by assembly scanning (e.g. HTTP message handlers for HttpClient).
/// </summary>
public interface IApplicationScopedConcrete
{
}
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
namespace DnsmasqWebUI.Services.Abstractions;
@@ -10,6 +10,9 @@ public interface IDnsmasqConfigSetService : IApplicationSingleton
/// <summary>Leases path discovered from the config set (dhcp-leasefile= / dhcp-lease-file=; 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>Additional hosts paths discovered from the config set (addn-hosts=; cumulative, all in order). Empty if none.</summary>
IReadOnlyList<string> GetAddnHostsPaths();
}
@@ -0,0 +1,8 @@
namespace DnsmasqWebUI.Services.Abstractions;
/// <summary>Runs a shell command with timeout and cancellation. Used for status, reload, status show, and logs commands.</summary>
public interface IProcessRunner : IApplicationScopedService
{
/// <summary>Runs a shell command with timeout and cancellation. Returns exit code (null if timed out or failed to start), stdout, stderr, and whether the run timed out.</summary>
Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
namespace DnsmasqWebUI.Services.Abstractions;
/// <summary>Result of running a shell command via <see cref="IProcessRunner"/>.</summary>
/// <param name="ExitCode">Process exit code, or null if timed out or failed to start.</param>
/// <param name="Stdout">Standard output.</param>
/// <param name="Stderr">Standard error.</param>
/// <param name="TimedOut">True if the command was killed due to timeout.</param>
/// <param name="ExceptionMessage">If run failed with an exception, the message; otherwise null.</param>
public record ProcessRunResult(
int? ExitCode,
string Stdout,
string Stderr,
bool TimedOut,
string? ExceptionMessage = null
);
@@ -1,5 +1,6 @@
using System.Text;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
@@ -16,7 +17,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, IOptions<DnsmasqOptions> options, ILogger<DnsmasqConfigService> logger)
{
_configSetService = configSetService;
_hostsPath = options.Value.HostsPath ?? "";
_hostsPath = options.Value.SystemHostsPath?.Trim() ?? "";
_logger = logger;
}
@@ -67,7 +68,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
return "line:" + e.LineNumber;
}
/// <summary>Ensures exactly one AddnHosts line with path = hostsPath. Replaces first AddnHosts or inserts at start.</summary>
/// <summary>When hostsPath is set, ensures the managed file has exactly one addn-hosts line pointing to it (replaces the first AddnHosts line in the list or inserts at start). So dnsmasq loads that file; other config files may have other addn-hosts lines.</summary>
private static void EnsureOneAddnHostsLine(List<DnsmasqConfLine> configLines, string hostsPath)
{
if (string.IsNullOrEmpty(hostsPath))
@@ -28,14 +28,96 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
return DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
}
/// <summary>Additional hosts paths discovered from the config set (addn-hosts=; cumulative). Empty list if main config missing or no addn-hosts.</summary>
public IReadOnlyList<string> GetAddnHostsPaths()
/// <summary>Effective config from the config set (single-value and flag options; last/any wins).</summary>
public EffectiveDnsmasqConfig GetEffectiveConfig()
{
var set = GetConfigSet();
if (set.Files.Count == 0)
return Array.Empty<string>();
return CreateDefaultEffectiveConfig();
var paths = set.Files.Select(f => f.Path).ToList();
return DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
var expandHosts = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "expand-hosts");
var bogusPriv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "bogus-priv");
var strictOrder = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "strict-order");
var noResolv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "no-resolv");
var domainNeeded = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "domain-needed");
var noPoll = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "no-poll");
var bindInterfaces = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "bind-interfaces");
var noNegcache = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "no-negcache");
var dhcpAuthoritative = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "dhcp-authoritative");
var leasefileRo = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "leasefile-ro");
var dhcpLeaseFilePath = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
var (cacheVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "cache-size");
var cacheSize = TryParseInt(cacheVal);
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "port");
var port = TryParseInt(portVal);
var (localTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "local-ttl");
var localTtl = TryParseInt(localTtlVal);
var (pidVal, pidDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "pid-file");
var pidFilePath = DnsmasqConfIncludeParser.ResolvePath(pidVal, pidDir);
var (userVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "user");
var user = string.IsNullOrWhiteSpace(userVal) ? null : userVal.Trim();
var (groupVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "group");
var group = string.IsNullOrWhiteSpace(groupVal) ? null : groupVal.Trim();
var (logFacVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "log-facility");
var logFacility = string.IsNullOrWhiteSpace(logFacVal) ? null : logFacVal.Trim();
var (leaseMaxVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "dhcp-lease-max");
var dhcpLeaseMax = TryParseInt(leaseMaxVal);
var (negTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "neg-ttl");
var negTtl = TryParseInt(negTtlVal);
var (maxTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "max-ttl");
var maxTtl = TryParseInt(maxTtlVal);
var (maxCacheTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "max-cache-ttl");
var maxCacheTtl = TryParseInt(maxCacheTtlVal);
var (minCacheTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "min-cache-ttl");
var minCacheTtl = TryParseInt(minCacheTtlVal);
var (dhcpTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "dhcp-ttl");
var dhcpTtl = TryParseInt(dhcpTtlVal);
return new EffectiveDnsmasqConfig(
noHosts, addnHosts,
expandHosts, bogusPriv, strictOrder, noResolv, domainNeeded, noPoll, bindInterfaces, noNegcache, dhcpAuthoritative, leasefileRo,
dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, dhcpLeaseMax,
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl
);
}
private static EffectiveDnsmasqConfig CreateDefaultEffectiveConfig() =>
new(
NoHosts: false, AddnHostsPaths: 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 int? TryParseInt(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return int.TryParse(value.Trim(), out var n) ? n : null;
}
/// <summary>Additional hosts paths discovered from the config set (addn-hosts=; cumulative). Empty list if main config missing or no addn-hosts.</summary>
public IReadOnlyList<string> GetAddnHostsPaths()
{
return GetEffectiveConfig().AddnHostsPaths;
}
private DnsmasqConfigSet GetConfigSet()
@@ -1,5 +1,6 @@
using System.Text;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
@@ -14,12 +14,14 @@ public class HostsFileService : IHostsFileService
public HostsFileService(IOptions<DnsmasqOptions> options, ILogger<HostsFileService> logger)
{
_path = options.Value.HostsPath;
_path = options.Value.SystemHostsPath?.Trim() ?? "";
_logger = logger;
}
public async Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default)
{
if (string.IsNullOrEmpty(_path))
return Array.Empty<HostEntry>();
if (!File.Exists(_path))
{
_logger.LogWarning("Hosts file not found: {Path}", _path);
@@ -46,6 +48,8 @@ public class HostsFileService : IHostsFileService
public async Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(_path))
throw new InvalidOperationException("No system hosts file configured. Set Dnsmasq:SystemHostsPath to enable hosts editing.");
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
@@ -0,0 +1,72 @@
using System.Diagnostics;
using System.Text;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Services;
/// <summary>Runs shell commands via /bin/sh with async output capture and timeout. Used by StatusController and ReloadService.</summary>
public sealed class ProcessRunner : IProcessRunner
{
private readonly ILogger<ProcessRunner> _logger;
public ProcessRunner(ILogger<ProcessRunner> logger) => _logger = logger;
public async Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(command))
return new ProcessRunResult(null, "", "", false);
var stdout = new StringBuilder();
var stderr = new StringBuilder();
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "/bin/sh",
Arguments = "-c \"" + command.Replace("\"", "\\\"") + "\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
try
{
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
_logger.LogWarning("Command timed out after {Timeout}s", timeout.TotalSeconds);
try { process.Kill(); } catch { /* best effort */ }
var err = stderr.ToString();
if (!string.IsNullOrEmpty(err)) err += "\n";
err += $"Command timed out after {timeout.TotalSeconds} seconds.";
return new ProcessRunResult(null, stdout.ToString(), err, true);
}
return new ProcessRunResult(
process.HasExited ? process.ExitCode : -1,
stdout.ToString(),
stderr.ToString(),
false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to run command");
return new ProcessRunResult(null, "", "", false, ex.Message);
}
}
}
+21 -44
View File
@@ -1,4 +1,3 @@
using System.Diagnostics;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
@@ -7,19 +6,21 @@ namespace DnsmasqWebUI.Services;
public class ReloadService : IReloadService
{
private readonly string? _command;
private readonly DnsmasqOptions _options;
private readonly IProcessRunner _processRunner;
private readonly ILogger<ReloadService> _logger;
private readonly SemaphoreSlim _reloadLock = new(1, 1);
public ReloadService(IOptions<DnsmasqOptions> options, ILogger<ReloadService> logger)
public ReloadService(IProcessRunner processRunner, IOptions<DnsmasqOptions> options, ILogger<ReloadService> logger)
{
_command = options.Value.ReloadCommand;
_processRunner = processRunner;
_options = options.Value;
_logger = logger;
}
public async Task<ReloadResult> ReloadAsync(CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_command))
if (string.IsNullOrWhiteSpace(_options.ReloadCommand))
{
_logger.LogDebug("Reload command not configured");
return new ReloadResult(true, 0, null, "Reload command not configured");
@@ -33,47 +34,23 @@ public class ReloadService : IReloadService
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "/bin/sh",
Arguments = "-c \"" + _command.Replace("\"", "\\\"") + "\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
var stderrTask = process.StandardError.ReadToEndAsync(ct);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
var timedOut = false;
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
timedOut = true;
_logger.LogWarning("Reload command timed out after 30 seconds");
try { process.Kill(); } catch { /* best effort */ }
}
var stdout = await stdoutTask;
var stderr = await stderrTask;
var exitCode = process.HasExited ? process.ExitCode : -1;
if (timedOut)
var result = await _processRunner.RunAsync(_options.ReloadCommand, TimeSpan.FromSeconds(30), ct);
var stderr = result.Stderr;
if (result.TimedOut)
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + "Reload command timed out after 30 seconds.";
if (exitCode != 0)
_logger.LogWarning("Reload command exited with {ExitCode}: {Stderr}", exitCode, stderr);
else
if (result.ExceptionMessage != null)
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + result.ExceptionMessage;
if (result.ExitCode != 0 && result.ExitCode.HasValue)
_logger.LogWarning("Reload command exited with {ExitCode}: {Stderr}", result.ExitCode.Value, stderr);
else if (result.ExitCode == 0)
_logger.LogInformation("Reload command succeeded");
return new ReloadResult(exitCode == 0, exitCode, stdout, stderr);
return new ReloadResult(
result.ExitCode == 0,
result.ExitCode ?? -1,
result.Stdout,
stderr);
}
catch (Exception ex)
{
+4 -2
View File
@@ -23,8 +23,10 @@
"Dnsmasq": {
"MainConfigPath": "/etc/dnsmasq.conf",
"ManagedFileName": "zz-dnsmasq-webui.conf",
"HostsPath": "/etc/hosts",
"SystemHostsPath": "/etc/hosts",
"ReloadCommand": "systemctl reload dnsmasq",
"StatusCommand": "systemctl is-active dnsmasq"
"StatusCommand": "systemctl is-active dnsmasq",
"StatusShowCommand": "systemctl status dnsmasq --no-pager",
"LogsCommand": "journalctl -u dnsmasq -n 100 --no-pager"
}
}
+3
View File
@@ -7,3 +7,6 @@ dhcp-range=172.28.0.10,172.28.0.50,255.255.255.0,12h
dhcp-leasefile=/data/leases
conf-dir=/data/dnsmasq.d
addn-hosts=/data/hosts
# Log to file so the harness can show real logs (LogsCommand tails this)
log-facility=/data/dnsmasq.log
log-queries