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