diff --git a/Dockerfile b/Dockerfile
index 657938e..96e41e9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
index acb9ad5..580812a 100644
--- a/docker-compose.test.yml
+++ b/docker-compose.test.yml
@@ -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:
diff --git a/scripts/dnsmasq-status.sh b/scripts/dnsmasq-status.sh
new file mode 100755
index 0000000..9a6655f
--- /dev/null
+++ b/scripts/dnsmasq-status.sh
@@ -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
diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh
index 53ea3cd..9ed5e42 100755
--- a/scripts/entrypoint.sh
+++ b/scripts/entrypoint.sh
@@ -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
diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh
index 9dad222..ea237df 100755
--- a/scripts/prepare-test-mount.sh
+++ b/scripts/prepare-test-mount.sh
@@ -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"
diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs
index ec7d505..d6e2645 100644
--- a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs
+++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs
index cbc67e3..a926ea7 100644
--- a/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs
+++ b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs
index 16bad06..fbe9e27 100644
--- a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs
+++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs
@@ -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);
+ }
}
diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs
index b495845..7487e51 100644
--- a/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs
+++ b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs
@@ -1,4 +1,4 @@
-using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Config;
namespace DnsmasqWebUI.Tests;
diff --git a/src/DnsmasqWebUI/Components/Layout/MainLayout.razor b/src/DnsmasqWebUI/Components/Layout/MainLayout.razor
index 78624f3..2c15d11 100644
--- a/src/DnsmasqWebUI/Components/Layout/MainLayout.razor
+++ b/src/DnsmasqWebUI/Components/Layout/MainLayout.razor
@@ -1,4 +1,5 @@
-@inherits LayoutComponentBase
+@inherits LayoutComponentBase
+@namespace DnsmasqWebUI.Components.Layout
-
-
@Body
diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor
index 3aeaafe..4cc5318 100644
--- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor
+++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor
@@ -1,3 +1,5 @@
+@namespace DnsmasqWebUI.Components.Layout
+
DnsmasqWebUI
@@ -10,7 +12,7 @@
- Overview
+ Dnsmasq
@@ -25,12 +27,6 @@
DHCP
-
-
-
- Leases
-
-
diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor
index b1be40b..10a035b 100644
--- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor
+++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor
@@ -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
-DHCP hosts
+DHCP
-DHCP static hosts
+DHCP
@if (_status == null)
{
Loading...
}
-else if (!_status.managedFilePathExists)
-{
- Managed config file not found: @_status.managedFilePath
-}
else
{
- @if (_status.dnsmasqStatus == "inactive" || _status.dnsmasqStatus == "unknown")
+ @* ---- Static hosts (dhcp-host= in managed file) ---- *@
+
+ Static hosts
+ @if (!_status.ManagedFilePathExists)
+ {
+ Managed config file not found: @_status.ManagedFilePath
+ }
+ else
+ {
+ @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
{
- Dnsmasq service appears to be @_status.dnsmasqStatus . Save will update the file but reload may fail until the service is fixed.
+ Dnsmasq service appears to be @_status.DnsmasqStatus . Save will update the file but reload may fail until the service is fixed.
}
@if (_error != null)
{
@@ -53,16 +63,65 @@ else
Add host
Save
- @if (_status.reloadCommandConfigured)
+ @if (_status.ReloadCommandConfigured)
{
Reload dnsmasq
}
- Managed file: @_status.managedFilePath
+ Managed file: @_status.ManagedFilePath
+ }
+
+
+ @* ---- Leases (dhcp-leasefile, read-only) ---- *@
+
+ Leases
+ @if (!_status.LeasesPathConfigured)
+ {
+ Leases not configured (no dhcp-leasefile in config).
+ }
+ else if (!_status.LeasesPathExists)
+ {
+ Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.
+ }
+ else if (_leases == null)
+ {
+ Could not read leases file.
+ }
+ else if (_leases.Count == 0)
+ {
+ No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.
+ Path: @_status.LeasesPath
+ Refresh
+ }
+ else
+ {
+
+
+ Expiry MAC Address Name Client ID
+
+
+ @foreach (var e in _leases)
+ {
+
+ @e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")
+ @e.Mac
+ @e.Address
+ @e.Name
+ @e.ClientId
+
+ }
+
+
+ Path: @_status.LeasesPath (read-only). Cached and refreshed when the file changes.
+ Refresh
+ }
+
}
@code {
- private DhcpStatusDto? _status;
- private List _entries = new();
+ private DnsmasqServiceStatus? _status;
+ private List _entries = new();
+ private IReadOnlyList? _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("api/status");
- var entries = await Http.GetFromJsonAsync>("api/dhcp/hosts");
- if (entries != null)
- _entries = entries;
+ _status = await StatusClient.GetStatusAsync();
+ var entries = await DhcpHostsClient.GetDhcpHostsAsync();
+ _entries = entries?.ToList() ?? new List();
+ 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(), Extra = new List() });
+ _entries.Add(new DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, MacAddresses = new List(), Extra = new List() });
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();
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();
- 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();
- 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; } }
}
diff --git a/src/DnsmasqWebUI/Components/Pages/Error.razor b/src/DnsmasqWebUI/Components/Pages/Error.razor
index 576cc2d..d555f45 100644
--- a/src/DnsmasqWebUI/Components/Pages/Error.razor
+++ b/src/DnsmasqWebUI/Components/Pages/Error.razor
@@ -1,4 +1,5 @@
-@page "/Error"
+@page "/Error"
+@namespace DnsmasqWebUI.Components.Pages
@using System.Diagnostics
Error
diff --git a/src/DnsmasqWebUI/Components/Pages/Home.razor b/src/DnsmasqWebUI/Components/Pages/Home.razor
index 161fe71..b2d2f99 100644
--- a/src/DnsmasqWebUI/Components/Pages/Home.razor
+++ b/src/DnsmasqWebUI/Components/Pages/Home.razor
@@ -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
-Overview
+Dnsmasq
-Overview
+Dnsmasq
-Dnsmasq configuration and service status at a glance.
+Configuration and service status at a glance.
-@if (_status == null && _error == null)
+@if (_loading)
{
Loading…
}
@@ -21,22 +24,22 @@ else if (_status != null)
{
Service
-
+
Dnsmasq: @GetStatusLabel(_status)
- @if (_status.statusCommandExitCode.HasValue)
+ @if (_status.StatusCommandExitCode.HasValue)
{
- (exit @_status.statusCommandExitCode.Value)
+ (exit @_status.StatusCommandExitCode.Value)
}
- @if (!string.IsNullOrEmpty(_status.statusCommandStderr))
+ @if (!string.IsNullOrEmpty(_status.StatusCommandStderr))
{
- — @_status.statusCommandStderr
+ — @_status.StatusCommandStderr
}
- @if (_status.dnsmasqStatus != "active" && (_status.statusCommandStdout != null || _status.statusCommandStderr != null))
+ @if (_status.DnsmasqStatus != "active" && (_status.StatusCommandStdout != null || _status.StatusCommandStderr != null))
{
-
@(_status.statusCommandStdout != null ? _status.statusCommandStdout : "")@(_status.statusCommandStdout != null && _status.statusCommandStderr != null ? "\n" : "")@(_status.statusCommandStderr ?? "")
+
@(_status.StatusCommandStdout != null ? _status.StatusCommandStdout : "")@(_status.StatusCommandStdout != null && _status.StatusCommandStderr != null ? "\n" : "")@(_status.StatusCommandStderr ?? "")
}
- @if (_status.reloadCommandConfigured)
+ @if (_status.ReloadCommandConfigured)
{
@@ -56,31 +59,111 @@ else if (_status != null)
}
}
+
+ @* Full service status — output of StatusShowCommand; format depends on command (systemctl on host, ps/pgrep in containers) *@
+ @if (_status.StatusShowConfigured)
+ {
+
Service status (output of StatusShowCommand)
+ @if (string.IsNullOrWhiteSpace(_status.StatusShowOutput))
+ {
+
No output. On a systemd host use systemctl status dnsmasq --no-pager for full status; in Docker or without systemd use ps -C dnsmasq -o pid,user,args or pgrep -a dnsmasq for minimal process info.
+ }
+ else
+ {
+
@_status.StatusShowOutput
+ }
+ }
+
+ @* Recent logs — live view, auto-refreshes *@
+ @if (_status.LogsConfigured)
+ {
+
Recent logs (live, refreshes every @(_refreshIntervalSeconds)s)
+ @if (string.IsNullOrWhiteSpace(_status.LogsOutput))
+ {
+
No output.
+ }
+ else
+ {
+
@_status.LogsOutput
+ }
+ }
+
+ @if (_status.StatusShowConfigured || _status.LogsConfigured)
+ {
+
+ Refresh status & logs
+
+ }
+
+
+
+ Effective config
+ What dnsmasq uses after reading all config files (main + conf-file + conf-dir). Only single-value / overridable options are shown here.
+
+ no-hosts: @(_status.NoHosts ? "Yes (hosts files disabled)" : "No")
+
+ addn-hosts:
+ @if (_status.AddnHostsPaths?.Count > 0)
+ {
+ var deduped = _status.AddnHostsPaths.Distinct().ToList();
+ @(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped))
+ }
+ else
+ {
+ (none)
+ }
+
+ @if (_status.SystemHostsPath != null)
+ {
+ System hosts file (editable): @_status.SystemHostsPath @(_status.SystemHostsPathExists ? "✓" : "(missing)")
+ }
+
+ @if (_status.EffectiveConfig != null)
+ {
+ var ec = _status.EffectiveConfig;
+ Overridable options (last wins; future UI will allow editing via managed file):
+
+ expand-hosts: @(ec.ExpandHosts ? "Yes" : "No")
+ bogus-priv: @(ec.BogusPriv ? "Yes" : "No")
+ strict-order: @(ec.StrictOrder ? "Yes" : "No")
+ no-resolv: @(ec.NoResolv ? "Yes" : "No")
+ domain-needed: @(ec.DomainNeeded ? "Yes" : "No")
+ dhcp-authoritative: @(ec.DhcpAuthoritative ? "Yes" : "No")
+ dhcp-leasefile: @(ec.DhcpLeaseFilePath ?? "(default)")
+ @if (ec.CacheSize.HasValue) { cache-size: @ec.CacheSize.Value }
+ @if (ec.Port.HasValue) { port: @ec.Port.Value }
+ @if (ec.LocalTtl.HasValue) { local-ttl: @ec.LocalTtl.Value }
+ @if (ec.PidFilePath != null) { pid-file: @ec.PidFilePath }
+ @if (ec.User != null) { user: @ec.User }
+ @if (ec.Group != null) { group: @ec.Group }
+ @if (ec.DhcpLeaseMax.HasValue) { dhcp-lease-max: @ec.DhcpLeaseMax.Value }
+
+ }
Config
- Main config: @(_status.mainConfigPath ?? "—") @(_status.mainConfigPathExists ? "✓" : "(missing)")
- Managed file: @(_status.managedFilePath ?? "—") @(_status.managedFilePathExists ? "✓" : "(not created yet)")
+ Main config: @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")
+ Managed file: @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")
- @if (_configSet != null && _configSet.files?.Count > 0)
+ @if (_configSet != null && _configSet.Files?.Count > 0)
{
Config set (load order):
- @foreach (var f in _configSet.files)
+ @foreach (var f in _configSet.Files)
{
- @if (f.isManaged)
+ @if (f.IsManaged)
{
managed
}
else
{
- @((f.source ?? "").ToLowerInvariant())
+ @(f.Source.ToString().ToLowerInvariant())
}
- @f.fileName
- (@f.path)
+ @f.FileName
+ (@f.Path)
}
@@ -90,12 +173,11 @@ else if (_status != null)
Paths
- Hosts file: @(_status.hostsPath ?? "—") @(_status.hostsPathExists ? "✓" : "(missing)")
- Leases file: @(_status.leasesPath ?? "—")
- @if (_status.leasesPathConfigured)
+ Leases file: @(_status.LeasesPath ?? "—")
+ @if (_status.LeasesPathConfigured)
{
- @(_status.leasesPathExists ? "✓" : "(missing)")
+ @(_status.LeasesPathExists ? "✓" : "(missing)")
}
else
{
@@ -109,72 +191,52 @@ else if (_status != null)
Quick links
}
@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? 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("api/status", token);
+ _status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
try
{
- _configSet = await Http.GetFromJsonAsync("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(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("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();
- 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();
}
diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor
index 0c7ff0b..1027a64 100644
--- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor
+++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor
@@ -1,6 +1,8 @@
@page "/hosts"
@rendermode InteractiveServer
-@inject HttpClient Http
+@namespace DnsmasqWebUI.Components.Pages
+@inject IStatusClient StatusClient
+@inject IHostsClient HostsClient
Hosts
@@ -10,15 +12,31 @@
{
Loading...
}
-else if (!_status.hostsPathExists)
+else if (_status.NoHosts && !_pathInAddnHosts)
{
- Hosts file not found: @_status.hostsPath
+ Hosts editing is disabled: no-hosts 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.
+ @if (_status.AddnHostsPaths?.Count > 0)
+ {
+ Effective addn-hosts paths: @string.Join(", ", _status.AddnHostsPaths)
+ }
+}
+else if (string.IsNullOrEmpty(_status.SystemHostsPath))
+{
+ No system hosts file configured. Set Dnsmasq:SystemHostsPath (e.g. /etc/hosts) to edit a hosts file from this app.
+ @if (_status.AddnHostsPaths?.Count > 0)
+ {
+ Dnsmasq loads these addn-hosts files (read-only here): @string.Join(", ", _status.AddnHostsPaths)
+ }
+}
+else if (!_status.SystemHostsPathExists)
+{
+ System hosts file not found: @_status.SystemHostsPath
}
else
{
- @if (_status.dnsmasqStatus == "inactive" || _status.dnsmasqStatus == "unknown")
+ @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
{
- Dnsmasq service appears to be @_status.dnsmasqStatus . Save will update the file but reload may fail until the service is fixed.
+ Dnsmasq service appears to be @_status.DnsmasqStatus . Save will update the file but reload may fail until the service is fixed.
}
@if (_error != null)
{
@@ -43,23 +61,21 @@ else
}
- Path: @_status.hostsPath
+ Path: @_status.SystemHostsPath
Save
}
@code {
- private HostsStatusDto? _status;
- private List _entries = new();
+ private DnsmasqServiceStatus? _status;
+ private List _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; }
- }
+ /// True when SystemHostsPath is in the effective addn-hosts list (editing allowed when no-hosts is set).
+ 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("api/status");
- var entries = await Http.GetFromJsonAsync>("api/hosts");
- if (entries != null)
- _entries = entries;
+ _status = await StatusClient.GetStatusAsync();
+ var entries = await HostsClient.GetHostsAsync();
+ _entries = entries?.ToList() ?? new List();
}
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();
- 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; } }
}
diff --git a/src/DnsmasqWebUI/Components/Pages/Leases.razor b/src/DnsmasqWebUI/Components/Pages/Leases.razor
deleted file mode 100644
index 0be4e08..0000000
--- a/src/DnsmasqWebUI/Components/Pages/Leases.razor
+++ /dev/null
@@ -1,117 +0,0 @@
-@page "/leases"
-@rendermode InteractiveServer
-@inject HttpClient Http
-
-DHCP Leases
-
-DHCP Leases
-
-@if (_status == null)
-{
- Loading...
-}
-else if (!_status.leasesPathConfigured)
-{
- Leases not configured.
-}
-else
-{
- @if (_error != null)
- {
- @_error
- }
- @if (!_status.leasesPathExists)
- {
- Leases file not found: @_status.leasesPath. DHCP may be disabled or path may be wrong.
- }
- else if (_leases == null)
- {
- Could not read leases file.
- }
- else
- {
-
-
- Expiry MAC Address Name Client ID
-
-
- @foreach (var e in _leases)
- {
-
- @e.timestamp.ToString("yyyy-MM-dd HH:mm:ss")
- @e.mac
- @e.address
- @e.name
- @e.clientId
-
- }
-
-
- Path: @_status.leasesPath (read-only). Cached and refreshed when the file changes; use Refresh to refetch now.
- Refresh
- }
-}
-
-@code {
- private StatusDto? _status;
- private List? _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? entries { get; set; }
- }
-
- protected override async Task OnInitializedAsync()
- {
- try
- {
- _status = await Http.GetFromJsonAsync("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("api/leases");
- if (resp?.entries != null)
- _leases = resp.entries;
- }
- catch (Exception ex)
- {
- _error = ex.Message;
- }
- finally
- {
- _loading = false;
- }
- }
-}
diff --git a/src/DnsmasqWebUI/Components/_Imports.razor b/src/DnsmasqWebUI/Components/_Imports.razor
index 2f8bd6c..5ce44e3 100644
--- a/src/DnsmasqWebUI/Components/_Imports.razor
+++ b/src/DnsmasqWebUI/Components/_Imports.razor
@@ -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
diff --git a/src/DnsmasqWebUI/Controllers/ConfigController.cs b/src/DnsmasqWebUI/Controllers/ConfigController.cs
index e86c185..d33aed6 100644
--- a/src/DnsmasqWebUI/Controllers/ConfigController.cs
+++ b/src/DnsmasqWebUI/Controllers/ConfigController.cs
@@ -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
}
}
- /// Writes the full managed config file (ensures one addn-hosts line = HostsPath), then optionally triggers reload.
+ /// 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.
[HttpPut("managed")]
public async Task> PutManaged([FromBody] List? lines, CancellationToken ct)
{
diff --git a/src/DnsmasqWebUI/Controllers/DhcpController.cs b/src/DnsmasqWebUI/Controllers/DhcpController.cs
index 0fe785c..9a1eb52 100644
--- a/src/DnsmasqWebUI/Controllers/DhcpController.cs
+++ b/src/DnsmasqWebUI/Controllers/DhcpController.cs
@@ -32,7 +32,7 @@ public class DhcpController : ControllerBase
}
[HttpPut("hosts")]
- public async Task> PutHosts([FromBody] List? entries, CancellationToken ct)
+ public async Task> PutHosts([FromBody] List? 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)
{
diff --git a/src/DnsmasqWebUI/Controllers/HostsController.cs b/src/DnsmasqWebUI/Controllers/HostsController.cs
index f88ad93..4534215 100644
--- a/src/DnsmasqWebUI/Controllers/HostsController.cs
+++ b/src/DnsmasqWebUI/Controllers/HostsController.cs
@@ -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 options)
{
_hostsService = hostsService;
_reloadService = reloadService;
+ _configSetService = configSetService;
+ _options = options.Value;
}
[HttpGet]
@@ -32,15 +41,23 @@ public class HostsController : ControllerBase
}
[HttpPut]
- public async Task> Put([FromBody] List? entries, CancellationToken ct)
+ public async Task> Put([FromBody] List? 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)
{
diff --git a/src/DnsmasqWebUI/Controllers/LeasesController.cs b/src/DnsmasqWebUI/Controllers/LeasesController.cs
index 72bb623..965c0a0 100644
--- a/src/DnsmasqWebUI/Controllers/LeasesController.cs
+++ b/src/DnsmasqWebUI/Controllers/LeasesController.cs
@@ -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> Get(CancellationToken ct)
+ public async Task> Get(CancellationToken ct)
{
try
{
var (available, entries) = await _leasesService.TryReadAsync(ct);
if (!available)
- return Ok(new { available = false, entries = (IReadOnlyList?)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(), message = "Leases file not readable." });
- return Ok(new { available = true, entries });
+ return Ok(new LeasesResult(true, Array.Empty(), "Leases file not readable."));
+ return Ok(new LeasesResult(true, entries, null));
}
catch (Exception ex)
{
diff --git a/src/DnsmasqWebUI/Controllers/StatusController.cs b/src/DnsmasqWebUI/Controllers/StatusController.cs
index f323623..e9ad013 100644
--- a/src/DnsmasqWebUI/Controllers/StatusController.cs
+++ b/src/DnsmasqWebUI/Controllers/StatusController.cs
@@ -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 options, IDnsmasqConfigSetService configSetService)
+ public StatusController(
+ IOptions options,
+ IDnsmasqConfigSetService configSetService,
+ IProcessRunner processRunner)
{
_options = options.Value;
_configSetService = configSetService;
+ _processRunner = processRunner;
}
[HttpGet]
- public async Task Get(CancellationToken ct)
+ public async Task> 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
- {
- ["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 });
}
}
-
- /// Runs StatusCommand if configured; returns status and optional exit code/stdout/stderr for UI.
- 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);
- }
- }
}
diff --git a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs
index 8a92b7e..8b24c2e 100644
--- a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs
+++ b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs
@@ -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)),
];
///
/// Scans the assembly for types implementing application marker interfaces
- /// ( , , , etc.)
+ /// ( , , , , etc.)
/// and registers each with the configured lifetime. Hosted services are registered via AddHostedService<T> .
/// Skips open generics; requires exactly one public implementation per interface (scoped/singleton).
///
diff --git a/src/DnsmasqWebUI/Http/Clients/ConfigSetClient.cs b/src/DnsmasqWebUI/Http/Clients/ConfigSetClient.cs
new file mode 100644
index 0000000..72bbd8b
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/ConfigSetClient.cs
@@ -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 GetConfigSetAsync(CancellationToken ct = default) =>
+ await _http.GetFromJsonAsync("api/config/set", ct)
+ ?? throw new InvalidOperationException("Unexpected null from api/config/set.");
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/DhcpHostsClient.cs b/src/DnsmasqWebUI/Http/Clients/DhcpHostsClient.cs
new file mode 100644
index 0000000..aed61a6
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/DhcpHostsClient.cs
@@ -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> GetDhcpHostsAsync(CancellationToken ct = default)
+ {
+ var list = await _http.GetFromJsonAsync>("api/dhcp/hosts", ct);
+ return list ?? new List();
+ }
+
+ public async Task SaveDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default)
+ {
+ var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ct);
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadFromJsonAsync(ct)
+ ?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts.");
+ }
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/HostsClient.cs b/src/DnsmasqWebUI/Http/Clients/HostsClient.cs
new file mode 100644
index 0000000..7b4187a
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/HostsClient.cs
@@ -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> GetHostsAsync(CancellationToken ct = default)
+ {
+ var list = await _http.GetFromJsonAsync>("api/hosts", ct);
+ return list ?? new List();
+ }
+
+ public async Task SaveHostsAsync(IReadOnlyList entries, CancellationToken ct = default)
+ {
+ var response = await _http.PutAsJsonAsync("api/hosts", entries, ct);
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadFromJsonAsync(ct)
+ ?? throw new InvalidOperationException("Unexpected null from api/hosts.");
+ }
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/IConfigSetClient.cs b/src/DnsmasqWebUI/Http/Clients/IConfigSetClient.cs
new file mode 100644
index 0000000..a874b09
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/IConfigSetClient.cs
@@ -0,0 +1,9 @@
+using DnsmasqWebUI.Models;
+
+namespace DnsmasqWebUI.Http.Clients;
+
+/// Typed client for GET api/config/set.
+public interface IConfigSetClient
+{
+ Task GetConfigSetAsync(CancellationToken ct = default);
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/IDhcpHostsClient.cs b/src/DnsmasqWebUI/Http/Clients/IDhcpHostsClient.cs
new file mode 100644
index 0000000..913231e
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/IDhcpHostsClient.cs
@@ -0,0 +1,10 @@
+using DnsmasqWebUI.Models;
+
+namespace DnsmasqWebUI.Http.Clients;
+
+/// Typed client for GET/PUT api/dhcp/hosts.
+public interface IDhcpHostsClient
+{
+ Task> GetDhcpHostsAsync(CancellationToken ct = default);
+ Task SaveDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default);
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/IHostsClient.cs b/src/DnsmasqWebUI/Http/Clients/IHostsClient.cs
new file mode 100644
index 0000000..a6f6cf0
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/IHostsClient.cs
@@ -0,0 +1,10 @@
+using DnsmasqWebUI.Models;
+
+namespace DnsmasqWebUI.Http.Clients;
+
+/// Typed client for GET/PUT api/hosts.
+public interface IHostsClient
+{
+ Task> GetHostsAsync(CancellationToken ct = default);
+ Task SaveHostsAsync(IReadOnlyList entries, CancellationToken ct = default);
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/ILeasesClient.cs b/src/DnsmasqWebUI/Http/Clients/ILeasesClient.cs
new file mode 100644
index 0000000..a4a1a1e
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/ILeasesClient.cs
@@ -0,0 +1,9 @@
+using DnsmasqWebUI.Models;
+
+namespace DnsmasqWebUI.Http.Clients;
+
+/// Typed client for GET api/leases.
+public interface ILeasesClient
+{
+ Task GetLeasesAsync(CancellationToken ct = default);
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/IReloadClient.cs b/src/DnsmasqWebUI/Http/Clients/IReloadClient.cs
new file mode 100644
index 0000000..1d6eead
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/IReloadClient.cs
@@ -0,0 +1,9 @@
+using DnsmasqWebUI.Services.Abstractions;
+
+namespace DnsmasqWebUI.Http.Clients;
+
+/// Typed client for POST api/reload.
+public interface IReloadClient
+{
+ Task ReloadAsync(CancellationToken ct = default);
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/IStatusClient.cs b/src/DnsmasqWebUI/Http/Clients/IStatusClient.cs
new file mode 100644
index 0000000..54f794b
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/IStatusClient.cs
@@ -0,0 +1,9 @@
+using DnsmasqWebUI.Models;
+
+namespace DnsmasqWebUI.Http.Clients;
+
+/// Typed client for GET api/status.
+public interface IStatusClient
+{
+ Task GetStatusAsync(CancellationToken ct = default);
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/LeasesClient.cs b/src/DnsmasqWebUI/Http/Clients/LeasesClient.cs
new file mode 100644
index 0000000..7ec6510
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/LeasesClient.cs
@@ -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 GetLeasesAsync(CancellationToken ct = default) =>
+ await _http.GetFromJsonAsync("api/leases", ct)
+ ?? throw new InvalidOperationException("Unexpected null from api/leases.");
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/ReloadClient.cs b/src/DnsmasqWebUI/Http/Clients/ReloadClient.cs
new file mode 100644
index 0000000..3c0ca3b
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/ReloadClient.cs
@@ -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 ReloadAsync(CancellationToken ct = default)
+ {
+ var response = await _http.PostAsync("api/reload", null, ct);
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadFromJsonAsync(ct)
+ ?? throw new InvalidOperationException("Unexpected null from api/reload.");
+ }
+}
diff --git a/src/DnsmasqWebUI/Http/Clients/StatusClient.cs b/src/DnsmasqWebUI/Http/Clients/StatusClient.cs
new file mode 100644
index 0000000..0c26363
--- /dev/null
+++ b/src/DnsmasqWebUI/Http/Clients/StatusClient.cs
@@ -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 GetStatusAsync(CancellationToken ct = default) =>
+ await _http.GetFromJsonAsync("api/status", ct)
+ ?? throw new InvalidOperationException("Unexpected null from api/status.");
+}
diff --git a/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs b/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs
index b26049a..f865058 100644
--- a/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs
+++ b/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs
@@ -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.
///
-public sealed class SameHostBaseAddressHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler
+public sealed class SameHostBaseAddressHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler, IApplicationScopedConcrete
{
protected override Task 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);
diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfLine.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqConfLine.cs
similarity index 96%
rename from src/DnsmasqWebUI/Models/DnsmasqConfLine.cs
rename to src/DnsmasqWebUI/Models/Config/DnsmasqConfLine.cs
index a6bbc62..2b8dce8 100644
--- a/src/DnsmasqWebUI/Models/DnsmasqConfLine.cs
+++ b/src/DnsmasqWebUI/Models/Config/DnsmasqConfLine.cs
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
+using DnsmasqWebUI.Models;
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Config;
/// Kind of line in a dnsmasq .conf file. One option per line, key=value (no leading --), # for comments.
public enum DnsmasqConfLineKind
diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfigOptions.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs
similarity index 98%
rename from src/DnsmasqWebUI/Models/DnsmasqConfigOptions.cs
rename to src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs
index f2f3a52..11b7ad9 100644
--- a/src/DnsmasqWebUI/Models/DnsmasqConfigOptions.cs
+++ b/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Config;
///
/// Registry of dnsmasq .conf option names to their .
diff --git a/src/DnsmasqWebUI/Models/DnsmasqOptionKind.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionKind.cs
similarity index 93%
rename from src/DnsmasqWebUI/Models/DnsmasqOptionKind.cs
rename to src/DnsmasqWebUI/Models/Config/DnsmasqOptionKind.cs
index 66669a3..103a60a 100644
--- a/src/DnsmasqWebUI/Models/DnsmasqOptionKind.cs
+++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionKind.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Config;
///
/// Kind of dnsmasq .conf option. Each kind has a dedicated parser and backing model
@@ -35,7 +35,7 @@ public enum DnsmasqOptionKind
// --- DHCP (structured) ---
/// dhcp-range=... → (complex)
DhcpRange,
- /// dhcp-host=... → (already have parser)
+ /// dhcp-host=... → DhcpHostEntry (in DnsmasqWebUI.Models)
DhcpHost,
/// dhcp-option=... (complex)
DhcpOption,
diff --git a/src/DnsmasqWebUI/Models/DnsmasqOptionModels.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionModels.cs
similarity index 98%
rename from src/DnsmasqWebUI/Models/DnsmasqOptionModels.cs
rename to src/DnsmasqWebUI/Models/Config/DnsmasqOptionModels.cs
index a656472..3a13a51 100644
--- a/src/DnsmasqWebUI/Models/DnsmasqOptionModels.cs
+++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionModels.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Config;
/// Backing model for conf-file=path. Parsed from main config only.
public record ConfFileOption(string Path, int LineNumber, string SourceFilePath);
diff --git a/src/DnsmasqWebUI/Models/ParsedDirective.cs b/src/DnsmasqWebUI/Models/Config/ParsedDirective.cs
similarity index 93%
rename from src/DnsmasqWebUI/Models/ParsedDirective.cs
rename to src/DnsmasqWebUI/Models/Config/ParsedDirective.cs
index fb3b298..b4cbcaa 100644
--- a/src/DnsmasqWebUI/Models/ParsedDirective.cs
+++ b/src/DnsmasqWebUI/Models/Config/ParsedDirective.cs
@@ -1,4 +1,4 @@
-namespace DnsmasqWebUI.Models;
+namespace DnsmasqWebUI.Models.Config;
///
/// One parsed directive from a dnsmasq .conf file. Kind tells which option type;
diff --git a/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs b/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs
new file mode 100644
index 0000000..0950039
--- /dev/null
+++ b/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs
@@ -0,0 +1,27 @@
+namespace DnsmasqWebUI.Models;
+
+/// Dnsmasq service and config paths status returned by api/status.
+public record DnsmasqServiceStatus(
+ string? SystemHostsPath,
+ bool SystemHostsPathExists,
+ bool NoHosts,
+ IReadOnlyList 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
+);
diff --git a/src/DnsmasqWebUI/Models/EffectiveDnsmasqConfig.cs b/src/DnsmasqWebUI/Models/EffectiveDnsmasqConfig.cs
new file mode 100644
index 0000000..2fc3213
--- /dev/null
+++ b/src/DnsmasqWebUI/Models/EffectiveDnsmasqConfig.cs
@@ -0,0 +1,43 @@
+namespace DnsmasqWebUI.Models;
+
+///
+/// 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.
+///
+public record EffectiveDnsmasqConfig(
+ // --- Hosts (already used by Hosts UI) ---
+ bool NoHosts,
+ IReadOnlyList 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
+);
diff --git a/src/DnsmasqWebUI/Models/LeasesResult.cs b/src/DnsmasqWebUI/Models/LeasesResult.cs
new file mode 100644
index 0000000..41f5ca6
--- /dev/null
+++ b/src/DnsmasqWebUI/Models/LeasesResult.cs
@@ -0,0 +1,4 @@
+namespace DnsmasqWebUI.Models;
+
+/// Result of GET api/leases: whether leases are available and the list of entries.
+public record LeasesResult(bool Available, IReadOnlyList? Entries, string? Message);
diff --git a/src/DnsmasqWebUI/Models/SaveWithReloadResult.cs b/src/DnsmasqWebUI/Models/SaveWithReloadResult.cs
new file mode 100644
index 0000000..3307a4a
--- /dev/null
+++ b/src/DnsmasqWebUI/Models/SaveWithReloadResult.cs
@@ -0,0 +1,6 @@
+using DnsmasqWebUI.Services.Abstractions;
+
+namespace DnsmasqWebUI.Models;
+
+/// Result of a save operation that triggers a dnsmasq reload (e.g. PUT api/hosts, PUT api/dhcp/hosts).
+public record SaveWithReloadResult(bool Saved, ReloadResult Reload);
diff --git a/src/DnsmasqWebUI/Options/DnsmasqOptions.cs b/src/DnsmasqWebUI/Options/DnsmasqOptions.cs
index f955328..889dbea 100644
--- a/src/DnsmasqWebUI/Options/DnsmasqOptions.cs
+++ b/src/DnsmasqWebUI/Options/DnsmasqOptions.cs
@@ -3,7 +3,7 @@ namespace DnsmasqWebUI.Options;
///
/// 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
/// Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in <main-config-dir>/dnsmasq.d/ and included via conf-file= at the end of the main config so it loads last. Managed file content parsed with DnsmasqConfFileLineParser.
public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf";
- /// Path we write as addn-hosts= in the managed file; HostsFileService reads/writes this path. Process must have read/write access.
- public string HostsPath { get; set; } = "";
+ /// 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.
+ public string? SystemHostsPath { get; set; }
/// 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.
public string? ReloadCommand { get; set; }
/// 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.
public string? StatusCommand { get; set; }
+
+ /// Optional command for full service status (e.g. "systemctl status dnsmasq --no-pager"). Output shown on Overview.
+ public string? StatusShowCommand { get; set; }
+
+ /// Optional command for recent logs (e.g. "journalctl -u dnsmasq -n 100 --no-pager"). Output shown on Overview.
+ public string? LogsCommand { get; set; }
}
diff --git a/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs b/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs
index ad79a7d..16c71e1 100644
--- a/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs
+++ b/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs
@@ -28,18 +28,9 @@ public sealed class DnsmasqOptionsValidator : IValidateOptions
}
}
- 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;
diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs
index c1dd96c..945e2f1 100644
--- a/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs
+++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Config;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs
index d1362e1..c073511 100644
--- a/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs
+++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Config;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs
index 870ee1a..ccff5ab 100644
--- a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs
+++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs
@@ -94,9 +94,82 @@ public static class DnsmasqConfIncludeParser
return null;
}
+ ///
+ /// 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).
+ ///
+ public static bool GetFlagFromConfigFiles(IReadOnlyList 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public static (string? Value, string? ConfigFileDir) GetLastValueFromConfigFiles(IReadOnlyList 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);
+ }
+
+ ///
+ /// Resolves a path value against the config file directory. If value is null/empty or already absolute, returns as-is (or null).
+ ///
+ 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));
+ }
+
///
/// Reads the given config files in order and returns the last dhcp-leasefile= or dhcp-lease-file= 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.
///
public static string? GetDhcpLeaseFilePathFromConfigFiles(IReadOnlyList 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;
}
+ ///
+ /// Reads the given config files in order and returns true if no-hosts appears in any file.
+ /// When true, dnsmasq does not read /etc/hosts; only addn-hosts= files are used (if any).
+ ///
+ public static bool GetNoHostsFromConfigFiles(IReadOnlyList configFilePathsInOrder) =>
+ GetFlagFromConfigFiles(configFilePathsInOrder, "no-hosts");
+
///
/// Reads the given config files in order and returns all addn-hosts= 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.
diff --git a/src/DnsmasqWebUI/Program.cs b/src/DnsmasqWebUI/Program.cs
index e4e8110..3850f84 100644
--- a/src/DnsmasqWebUI/Program.cs
+++ b/src/DnsmasqWebUI/Program.cs
@@ -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()
.Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName))
.ValidateOnStart();
-builder.Services.AddSingleton, DnsmasqOptionsValidator>();
+builder.Services.AddSingleton, 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();
-// Same app hosts both:
-// - API: AddControllers() + MapControllers() → routes like /api/status, /api/hosts, /api/reload.
-// - Blazor: AddRazorComponents + AddInteractiveServerComponents + MapRazorComponents() → pages like /, /hosts, /dhcp, /leases.
+const string ApiClientName = "DnsmasqWebUI.Api";
+builder.Services.AddHttpClient(ApiClientName, client =>
+{
+ client.BaseAddress = new Uri("http://localhost/", UriKind.Absolute);
+})
+.AddHttpMessageHandler();
+
+builder.Services.AddHttpClient(ApiClientName);
+builder.Services.AddHttpClient(ApiClientName);
+builder.Services.AddHttpClient(ApiClientName);
+builder.Services.AddHttpClient(ApiClientName);
+builder.Services.AddHttpClient(ApiClientName);
+builder.Services.AddHttpClient(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("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>().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("ForwardedHeaders:Enabled");
-if (forwardedEnabled)
+if (builder.Configuration.GetValue("ForwardedHeaders:Enabled"))
{
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
@@ -107,17 +105,27 @@ if (builder.Configuration.GetValue("Https:UseRedirectAndHsts"))
}
if (corsEnabled)
-{
- var policyName = builder.Configuration.GetValue("Cors:PolicyName") ?? "Default";
- app.UseCors(policyName);
-}
+ app.UseCors(builder.Configuration.GetValue("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()
.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();
+}
diff --git a/src/DnsmasqWebUI/Services/Abstractions/IApplicationScopedConcrete.cs b/src/DnsmasqWebUI/Services/Abstractions/IApplicationScopedConcrete.cs
new file mode 100644
index 0000000..ffe1125
--- /dev/null
+++ b/src/DnsmasqWebUI/Services/Abstractions/IApplicationScopedConcrete.cs
@@ -0,0 +1,9 @@
+namespace DnsmasqWebUI.Services.Abstractions;
+
+///
+/// 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).
+///
+public interface IApplicationScopedConcrete
+{
+}
diff --git a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs
index 21dd4fa..b767323 100644
--- a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs
+++ b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models;
+using DnsmasqWebUI.Models.Config;
namespace DnsmasqWebUI.Services.Abstractions;
diff --git a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs
index 7dcdeb4..920e101 100644
--- a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs
+++ b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs
@@ -10,6 +10,9 @@ public interface IDnsmasqConfigSetService : IApplicationSingleton
/// Leases path discovered from the config set (dhcp-leasefile= / dhcp-lease-file=; last wins). Null if not found.
string? GetLeasesPath();
+ /// Effective hosts-related config after reading all config files: no-hosts flag and addn-hosts= paths (cumulative).
+ EffectiveDnsmasqConfig GetEffectiveConfig();
+
/// Additional hosts paths discovered from the config set (addn-hosts=; cumulative, all in order). Empty if none.
IReadOnlyList GetAddnHostsPaths();
}
diff --git a/src/DnsmasqWebUI/Services/Abstractions/IProcessRunner.cs b/src/DnsmasqWebUI/Services/Abstractions/IProcessRunner.cs
new file mode 100644
index 0000000..aaee890
--- /dev/null
+++ b/src/DnsmasqWebUI/Services/Abstractions/IProcessRunner.cs
@@ -0,0 +1,8 @@
+namespace DnsmasqWebUI.Services.Abstractions;
+
+/// Runs a shell command with timeout and cancellation. Used for status, reload, status show, and logs commands.
+public interface IProcessRunner : IApplicationScopedService
+{
+ /// 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.
+ Task RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default);
+}
diff --git a/src/DnsmasqWebUI/Services/Abstractions/ProcessRunResult.cs b/src/DnsmasqWebUI/Services/Abstractions/ProcessRunResult.cs
new file mode 100644
index 0000000..9aef926
--- /dev/null
+++ b/src/DnsmasqWebUI/Services/Abstractions/ProcessRunResult.cs
@@ -0,0 +1,15 @@
+namespace DnsmasqWebUI.Services.Abstractions;
+
+/// Result of running a shell command via .
+/// Process exit code, or null if timed out or failed to start.
+/// Standard output.
+/// Standard error.
+/// True if the command was killed due to timeout.
+/// If run failed with an exception, the message; otherwise null.
+public record ProcessRunResult(
+ int? ExitCode,
+ string Stdout,
+ string Stderr,
+ bool TimedOut,
+ string? ExceptionMessage = null
+);
diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs
index e100cd5..8c24da7 100644
--- a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs
+++ b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs
@@ -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 options, ILogger 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;
}
- /// Ensures exactly one AddnHosts line with path = hostsPath. Replaces first AddnHosts or inserts at start.
+ /// 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.
private static void EnsureOneAddnHostsLine(List configLines, string hostsPath)
{
if (string.IsNullOrEmpty(hostsPath))
diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs
index b1ce5d5..70d8e8b 100644
--- a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs
+++ b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs
@@ -28,14 +28,96 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
return DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
}
- /// Additional hosts paths discovered from the config set (addn-hosts=; cumulative). Empty list if main config missing or no addn-hosts.
- public IReadOnlyList GetAddnHostsPaths()
+ /// Effective config from the config set (single-value and flag options; last/any wins).
+ public EffectiveDnsmasqConfig GetEffectiveConfig()
{
var set = GetConfigSet();
if (set.Files.Count == 0)
- return Array.Empty();
+ 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(),
+ 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;
+ }
+
+ /// Additional hosts paths discovered from the config set (addn-hosts=; cumulative). Empty list if main config missing or no addn-hosts.
+ public IReadOnlyList GetAddnHostsPaths()
+ {
+ return GetEffectiveConfig().AddnHostsPaths;
}
private DnsmasqConfigSet GetConfigSet()
diff --git a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs
index dead341..2848397 100644
--- a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs
+++ b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs
@@ -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;
diff --git a/src/DnsmasqWebUI/Services/HostsFileService.cs b/src/DnsmasqWebUI/Services/HostsFileService.cs
index 42a9e36..46c7a3d 100644
--- a/src/DnsmasqWebUI/Services/HostsFileService.cs
+++ b/src/DnsmasqWebUI/Services/HostsFileService.cs
@@ -14,12 +14,14 @@ public class HostsFileService : IHostsFileService
public HostsFileService(IOptions options, ILogger logger)
{
- _path = options.Value.HostsPath;
+ _path = options.Value.SystemHostsPath?.Trim() ?? "";
_logger = logger;
}
public async Task> ReadAsync(CancellationToken ct = default)
{
+ if (string.IsNullOrEmpty(_path))
+ return Array.Empty();
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 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);
diff --git a/src/DnsmasqWebUI/Services/ProcessRunner.cs b/src/DnsmasqWebUI/Services/ProcessRunner.cs
new file mode 100644
index 0000000..3c7436f
--- /dev/null
+++ b/src/DnsmasqWebUI/Services/ProcessRunner.cs
@@ -0,0 +1,72 @@
+using System.Diagnostics;
+using System.Text;
+using DnsmasqWebUI.Services.Abstractions;
+using Microsoft.Extensions.Logging;
+
+namespace DnsmasqWebUI.Services;
+
+/// Runs shell commands via /bin/sh with async output capture and timeout. Used by StatusController and ReloadService.
+public sealed class ProcessRunner : IProcessRunner
+{
+ private readonly ILogger _logger;
+
+ public ProcessRunner(ILogger logger) => _logger = logger;
+
+ public async Task 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);
+ }
+ }
+}
diff --git a/src/DnsmasqWebUI/Services/ReloadService.cs b/src/DnsmasqWebUI/Services/ReloadService.cs
index 16d1d01..d8f02b2 100644
--- a/src/DnsmasqWebUI/Services/ReloadService.cs
+++ b/src/DnsmasqWebUI/Services/ReloadService.cs
@@ -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 _logger;
private readonly SemaphoreSlim _reloadLock = new(1, 1);
- public ReloadService(IOptions options, ILogger logger)
+ public ReloadService(IProcessRunner processRunner, IOptions options, ILogger logger)
{
- _command = options.Value.ReloadCommand;
+ _processRunner = processRunner;
+ _options = options.Value;
_logger = logger;
}
public async Task 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)
{
diff --git a/src/DnsmasqWebUI/appsettings.json b/src/DnsmasqWebUI/appsettings.json
index 7328db6..c91ff3f 100644
--- a/src/DnsmasqWebUI/appsettings.json
+++ b/src/DnsmasqWebUI/appsettings.json
@@ -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"
}
}
diff --git a/testdata/dnsmasq-test.conf b/testdata/dnsmasq-test.conf
index 92663ad..540a6c4 100644
--- a/testdata/dnsmasq-test.conf
+++ b/testdata/dnsmasq-test.conf
@@ -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