From d0391ef058e5e7ae3b6d3e97d10bdb70fd2c6b29 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sun, 1 Feb 2026 01:17:32 +1000 Subject: [PATCH] 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). --- Dockerfile | 3 +- docker-compose.test.yml | 11 +- scripts/dnsmasq-status.sh | 31 ++ scripts/entrypoint.sh | 2 + scripts/prepare-test-mount.sh | 7 +- .../DnsmasqConfDirectiveParserTests.cs | 1 + .../DnsmasqConfFileLineParserTests.cs | 1 + .../DnsmasqConfIncludeParserTests.cs | 74 +++++ .../DnsmasqConfOptionRegistryTests.cs | 2 +- .../Components/Layout/MainLayout.razor | 7 +- .../Components/Layout/NavMenu.razor | 10 +- src/DnsmasqWebUI/Components/Pages/Dhcp.razor | 156 ++++++---- src/DnsmasqWebUI/Components/Pages/Error.razor | 3 +- src/DnsmasqWebUI/Components/Pages/Home.razor | 274 ++++++++++++------ src/DnsmasqWebUI/Components/Pages/Hosts.razor | 73 ++--- .../Components/Pages/Leases.razor | 117 -------- src/DnsmasqWebUI/Components/_Imports.razor | 5 +- .../Controllers/ConfigController.cs | 3 +- .../Controllers/DhcpController.cs | 4 +- .../Controllers/HostsController.cs | 23 +- .../Controllers/LeasesController.cs | 9 +- .../Controllers/StatusController.cs | 122 ++++---- .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../Http/Clients/ConfigSetClient.cs | 15 + .../Http/Clients/DhcpHostsClient.cs | 25 ++ src/DnsmasqWebUI/Http/Clients/HostsClient.cs | 25 ++ .../Http/Clients/IConfigSetClient.cs | 9 + .../Http/Clients/IDhcpHostsClient.cs | 10 + src/DnsmasqWebUI/Http/Clients/IHostsClient.cs | 10 + .../Http/Clients/ILeasesClient.cs | 9 + .../Http/Clients/IReloadClient.cs | 9 + .../Http/Clients/IStatusClient.cs | 9 + src/DnsmasqWebUI/Http/Clients/LeasesClient.cs | 15 + src/DnsmasqWebUI/Http/Clients/ReloadClient.cs | 19 ++ src/DnsmasqWebUI/Http/Clients/StatusClient.cs | 15 + .../Http/SameHostBaseAddressHandler.cs | 15 +- .../Models/{ => Config}/DnsmasqConfLine.cs | 3 +- .../DnsmasqConfOptionRegistry.cs} | 2 +- .../Models/{ => Config}/DnsmasqOptionKind.cs | 4 +- .../{ => Config}/DnsmasqOptionModels.cs | 2 +- .../Models/{ => Config}/ParsedDirective.cs | 2 +- .../Models/DnsmasqServiceStatus.cs | 27 ++ .../Models/EffectiveDnsmasqConfig.cs | 43 +++ src/DnsmasqWebUI/Models/LeasesResult.cs | 4 + .../Models/SaveWithReloadResult.cs | 6 + src/DnsmasqWebUI/Options/DnsmasqOptions.cs | 12 +- .../Options/DnsmasqOptionsValidator.cs | 15 +- .../Parsers/DnsmasqConfDirectiveParser.cs | 1 + .../Parsers/DnsmasqConfFileLineParser.cs | 1 + .../Parsers/DnsmasqConfIncludeParser.cs | 84 +++++- src/DnsmasqWebUI/Program.cs | 80 ++--- .../IApplicationScopedConcrete.cs | 9 + .../Abstractions/IDnsmasqConfigService.cs | 1 + .../Abstractions/IDnsmasqConfigSetService.cs | 3 + .../Services/Abstractions/IProcessRunner.cs | 8 + .../Services/Abstractions/ProcessRunResult.cs | 15 + .../Services/DnsmasqConfigService.cs | 5 +- .../Services/DnsmasqConfigSetService.cs | 90 +++++- .../EnsureManagedConfigHostedService.cs | 1 + src/DnsmasqWebUI/Services/HostsFileService.cs | 6 +- src/DnsmasqWebUI/Services/ProcessRunner.cs | 72 +++++ src/DnsmasqWebUI/Services/ReloadService.cs | 65 ++--- src/DnsmasqWebUI/appsettings.json | 6 +- testdata/dnsmasq-test.conf | 3 + 64 files changed, 1184 insertions(+), 512 deletions(-) create mode 100755 scripts/dnsmasq-status.sh delete mode 100644 src/DnsmasqWebUI/Components/Pages/Leases.razor create mode 100644 src/DnsmasqWebUI/Http/Clients/ConfigSetClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/DhcpHostsClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/HostsClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/IConfigSetClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/IDhcpHostsClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/IHostsClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/ILeasesClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/IReloadClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/IStatusClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/LeasesClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/ReloadClient.cs create mode 100644 src/DnsmasqWebUI/Http/Clients/StatusClient.cs rename src/DnsmasqWebUI/Models/{ => Config}/DnsmasqConfLine.cs (96%) rename src/DnsmasqWebUI/Models/{DnsmasqConfigOptions.cs => Config/DnsmasqConfOptionRegistry.cs} (98%) rename src/DnsmasqWebUI/Models/{ => Config}/DnsmasqOptionKind.cs (93%) rename src/DnsmasqWebUI/Models/{ => Config}/DnsmasqOptionModels.cs (98%) rename src/DnsmasqWebUI/Models/{ => Config}/ParsedDirective.cs (93%) create mode 100644 src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs create mode 100644 src/DnsmasqWebUI/Models/EffectiveDnsmasqConfig.cs create mode 100644 src/DnsmasqWebUI/Models/LeasesResult.cs create mode 100644 src/DnsmasqWebUI/Models/SaveWithReloadResult.cs create mode 100644 src/DnsmasqWebUI/Services/Abstractions/IApplicationScopedConcrete.cs create mode 100644 src/DnsmasqWebUI/Services/Abstractions/IProcessRunner.cs create mode 100644 src/DnsmasqWebUI/Services/Abstractions/ProcessRunResult.cs create mode 100644 src/DnsmasqWebUI/Services/ProcessRunner.cs diff --git a/Dockerfile b/Dockerfile index 657938e..96e41e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,5 +28,6 @@ FROM app AS final RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \ && rm -rf /var/lib/apt/lists/* COPY scripts/entrypoint.sh . -RUN chmod +x entrypoint.sh +COPY scripts/dnsmasq-status.sh . +RUN chmod +x entrypoint.sh dnsmasq-status.sh ENTRYPOINT ["./entrypoint.sh"] diff --git a/docker-compose.test.yml b/docker-compose.test.yml index acb9ad5..580812a 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -2,8 +2,10 @@ # Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR] # Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount). # -# app builds from Dockerfile (app + dnsmasq in one container; app is main process). -# ReloadCommand/StatusCommand use pkill/pgrep (procps in image). cap_add NET_ADMIN for dnsmasq. +# No systemd in container (dnsmasq runs as a process from entrypoint, like most dnsmasq images). +# StatusShowCommand/LogsCommand use ps so the Service status / Recent logs sections show something +# (process info). On a host with systemd you'd use systemctl status dnsmasq and journalctl instead. +# cap_add NET_ADMIN for dnsmasq. # # Networks: testnet 172.28.0.0/16 so dnsmasq can hand out 172.28.0.10–50. services: @@ -18,9 +20,12 @@ services: DNSMASQ_CONF: /data/dnsmasq-test.conf Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf - Dnsmasq__HostsPath: /data/hosts + Dnsmasq__SystemHostsPath: /data/hosts Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq" Dnsmasq__StatusCommand: "pgrep -x dnsmasq" + # Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf) + Dnsmasq__StatusShowCommand: "/app/dnsmasq-status.sh" + Dnsmasq__LogsCommand: "tail -n 100 /data/dnsmasq.log 2>/dev/null || echo '(no log file yet)'" volumes: - ${TESTDATA_MOUNT:-./testdata-mount}:/data networks: diff --git a/scripts/dnsmasq-status.sh b/scripts/dnsmasq-status.sh new file mode 100755 index 0000000..9a6655f --- /dev/null +++ b/scripts/dnsmasq-status.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Simulates "systemctl status dnsmasq" output when run in a container without systemd. +# Uses ps/pgrep so the UI looks familiar. Usage: dnsmasq-status.sh + +pid=$(pgrep -x dnsmasq) +if [ -z "$pid" ]; then + echo "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server" + echo " Loaded: (container, no systemd)" + echo " Active: inactive (dead)" + echo "" + exit 0 +fi + +# One ps + awk: parse pid,user,etime,rss,args and print systemctl-style (rss in KB -> MB) +ps -o pid=,user=,etime=,rss=,args= -p "$pid" 2>/dev/null | awk '{ + pid=$1; user=$2; etime=$3; rss=$4 + args="" + for (i=5;i<=NF;i++) args = args (i>5?" ":"") $i + rss_mb = (rss+0) / 1024 + if (rss_mb < 0.1) rss_mb = 0.0 + printf "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server\n" + printf " Loaded: loaded (container, no systemd)\n" + printf " Active: active (running) since container start\n" + printf " Main PID: %s (dnsmasq)\n", pid + printf " Tasks: 1 (limit: unknown)\n" + printf " Memory: %.1fM\n", rss_mb + printf " CPU: %s\n", etime + printf " CGroup: (container)\n" + printf " └─%s %s\n", pid, args +}' +exit 0 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 53ea3cd..9ed5e42 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -2,6 +2,8 @@ # If DNSMASQ_CONF is set, run dnsmasq in the background (same container as the app). # Container main process is the app (exec below). if [ -n "$DNSMASQ_CONF" ]; then + # Ensure dnsmasq log file exists and is writable (dnsmasq may drop to dnsmasq user) + touch /data/dnsmasq.log 2>/dev/null && chmod a+rw /data/dnsmasq.log 2>/dev/null || true dnsmasq -k --conf-file="$DNSMASQ_CONF" & fi exec dotnet DnsmasqWebUI.dll diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh index 9dad222..ea237df 100755 --- a/scripts/prepare-test-mount.sh +++ b/scripts/prepare-test-mount.sh @@ -27,7 +27,7 @@ usage() { echo "" echo "Prepare the testdata mount directory and optionally start the Docker test harness" echo "(app with dnsmasq in one container, plus a DHCP client). The mount is synced from" - echo "testdata/ by default; 'leases' is excluded so dnsmasq creates the real leases file." + echo "testdata/ by default (includes sample leases file so the harness shows leases)." echo "" echo "Steps:" echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount." @@ -176,16 +176,15 @@ if [ "$NO_CLEAR" = false ]; then fi if command -v rsync >/dev/null 2>&1; then - rsync -a --exclude=leases "$SOURCE_DIR/" "$MOUNT_DIR/" + rsync -a "$SOURCE_DIR/" "$MOUNT_DIR/" else cp -r "$SOURCE_DIR/." "$MOUNT_DIR/" - rm -f "$MOUNT_DIR/leases" fi # Remove any leftover managed config from previous runs so dnsmasq starts clean (app will create zz-dnsmasq-webui.conf on startup). find "$MOUNT_DIR" -name '*dnsmasq-webui*.conf' -type f -delete 2>/dev/null || true -echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR, leases excluded)." +echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR)." if [ "$PREPARE_ONLY" = true ]; then echo "To start the harness: TESTDATA_MOUNT=./$MOUNT_DIR docker compose -f $COMPOSE_FILE up -d --build" diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs index ec7d505..d6e2645 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs @@ -1,4 +1,5 @@ using DnsmasqWebUI.Models; +using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Tests; diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs index cbc67e3..a926ea7 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs @@ -1,4 +1,5 @@ using DnsmasqWebUI.Models; +using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Tests; diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs index 16bad06..fbe9e27 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs @@ -252,4 +252,78 @@ public class DnsmasqConfIncludeParserTests Directory.Delete(dir, recursive: true); } } + + [Fact] + public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var conf = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(conf, "expand-hosts\nno-resolv\n"); + var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "expand-hosts"); + Assert.True(result); + Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "no-resolv")); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var conf = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(conf, "port=53\n"); + var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "no-hosts"); + Assert.False(result); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetLastValueFromConfigFiles_LastWins() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var f1 = Path.Combine(dir, "01.conf"); + var f2 = Path.Combine(dir, "02.conf"); + try + { + File.WriteAllText(f1, "cache-size=100\n"); + File.WriteAllText(f2, "cache-size=200\n"); + var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, "cache-size"); + Assert.Equal("200", value); + Assert.Equal(dir, configDir); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void ResolvePath_Relative_ResolvesAgainstDir() + { + var dir = Path.GetTempPath(); + var result = DnsmasqConfIncludeParser.ResolvePath("sub/pid.pid", dir); + Assert.Equal(Path.GetFullPath(Path.Combine(dir, "sub", "pid.pid")), result); + } + + [Fact] + public void ResolvePath_Absolute_ReturnsAsIs() + { + var abs = Path.Combine(Path.GetTempPath(), "absolute.pid"); + var result = DnsmasqConfIncludeParser.ResolvePath(abs, "/some/dir"); + Assert.Equal(Path.GetFullPath(abs), result); + } } diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs index b495845..7487e51 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs @@ -1,4 +1,4 @@ -using DnsmasqWebUI.Models; +using DnsmasqWebUI.Models.Config; namespace DnsmasqWebUI.Tests; diff --git a/src/DnsmasqWebUI/Components/Layout/MainLayout.razor b/src/DnsmasqWebUI/Components/Layout/MainLayout.razor index 78624f3..2c15d11 100644 --- a/src/DnsmasqWebUI/Components/Layout/MainLayout.razor +++ b/src/DnsmasqWebUI/Components/Layout/MainLayout.razor @@ -1,4 +1,5 @@ -@inherits LayoutComponentBase +@inherits LayoutComponentBase +@namespace DnsmasqWebUI.Components.Layout
-
- About -
-
@Body
diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor index 3aeaafe..4cc5318 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor @@ -1,3 +1,5 @@ +@namespace DnsmasqWebUI.Components.Layout + diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor index b1be40b..10a035b 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor @@ -1,24 +1,34 @@ @page "/dhcp" +@page "/leases" @rendermode InteractiveServer -@inject HttpClient Http +@namespace DnsmasqWebUI.Components.Pages +@inject IStatusClient StatusClient +@inject IDhcpHostsClient DhcpHostsClient +@inject ILeasesClient LeasesClient +@inject IReloadClient ReloadClient -DHCP hosts +DHCP -

DHCP static hosts

+

DHCP

@if (_status == null) {

Loading...

} -else if (!_status.managedFilePathExists) -{ -

Managed config file not found: @_status.managedFilePath

-} else { - @if (_status.dnsmasqStatus == "inactive" || _status.dnsmasqStatus == "unknown") + @* ---- Static hosts (dhcp-host= in managed file) ---- *@ +
+

Static hosts

+ @if (!_status.ManagedFilePathExists) + { +

Managed config file not found: @_status.ManagedFilePath

+ } + else + { + @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") { -
Dnsmasq service appears to be @_status.dnsmasqStatus. Save will update the file but reload may fail until the service is fixed.
+
Dnsmasq service appears to be @_status.DnsmasqStatus. Save will update the file but reload may fail until the service is fixed.
} @if (_error != null) { @@ -53,16 +63,65 @@ else - @if (_status.reloadCommandConfigured) + @if (_status.ReloadCommandConfigured) { } -

Managed file: @_status.managedFilePath

+

Managed file: @_status.ManagedFilePath

+ } +
+ + @* ---- Leases (dhcp-leasefile, read-only) ---- *@ +
+

Leases

+ @if (!_status.LeasesPathConfigured) + { +

Leases not configured (no dhcp-leasefile in config).

+ } + else if (!_status.LeasesPathExists) + { +

Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.

+ } + else if (_leases == null) + { +

Could not read leases file.

+ } + else if (_leases.Count == 0) + { +

No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.

+

Path: @_status.LeasesPath

+ + } + else + { + + + + + + @foreach (var e in _leases) + { + + + + + + + + } + +
ExpiryMACAddressNameClient ID
@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")@e.Mac@e.Address@e.Name@e.ClientId
+

Path: @_status.LeasesPath (read-only). Cached and refreshed when the file changes.

+ + } +
} @code { - private DhcpStatusDto? _status; - private List _entries = new(); + private DnsmasqServiceStatus? _status; + private List _entries = new(); + private IReadOnlyList? _leases; + private bool _leasesLoading; private string? _error; private string? _message; private bool _saving; @@ -78,10 +137,10 @@ else _message = null; try { - _status = await Http.GetFromJsonAsync("api/status"); - var entries = await Http.GetFromJsonAsync>("api/dhcp/hosts"); - if (entries != null) - _entries = entries; + _status = await StatusClient.GetStatusAsync(); + var entries = await DhcpHostsClient.GetDhcpHostsAsync(); + _entries = entries?.ToList() ?? new List(); + await LoadLeases(); } catch (Exception ex) { @@ -89,31 +148,46 @@ else } } + private async Task LoadLeases() + { + if (_status == null || !_status.LeasesPathConfigured) return; + _leasesLoading = true; + try + { + var resp = await LeasesClient.GetLeasesAsync(); + _leases = resp.Entries; + } + finally + { + _leasesLoading = false; + } + } + private void Add() { - _entries.Add(new DnsmasqWebUI.Models.DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, MacAddresses = new List(), Extra = new List() }); + _entries.Add(new DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, MacAddresses = new List(), Extra = new List() }); StateHasChanged(); } - private void Delete(DnsmasqWebUI.Models.DhcpHostEntry e) + private void Delete(DhcpHostEntry e) { e.IsDeleted = true; StateHasChanged(); } - private void ToggleValid(DnsmasqWebUI.Models.DhcpHostEntry e) + private void ToggleValid(DhcpHostEntry e) { e.IsComment = !e.IsComment; StateHasChanged(); } - private void ToggleIgnore(DnsmasqWebUI.Models.DhcpHostEntry e) + private void ToggleIgnore(DhcpHostEntry e) { e.Ignore = !e.Ignore; StateHasChanged(); } - private void SetMac(DnsmasqWebUI.Models.DhcpHostEntry e, string? value) + private void SetMac(DhcpHostEntry e, string? value) { e.MacAddresses = value?.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList() ?? new List(); StateHasChanged(); @@ -126,17 +200,11 @@ else _message = null; try { - var response = await Http.PutAsJsonAsync("api/dhcp/hosts", _entries); - if (!response.IsSuccessStatusCode) - { - _error = await response.Content.ReadAsStringAsync(); - return; - } - var result = await response.Content.ReadFromJsonAsync(); - if (result?.reload?.success == true) + var result = await DhcpHostsClient.SaveDhcpHostsAsync(_entries); + if (result.Reload.Success) _message = "Saved and dnsmasq reloaded."; - else if (result?.reload != null && !string.IsNullOrEmpty(result.reload.stdErr)) - _error = "Saved but dnsmasq reload failed: " + result.reload.stdErr; + else if (!string.IsNullOrEmpty(result.Reload.StdErr)) + _error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr; else _message = "Saved."; await Load(); @@ -151,15 +219,6 @@ else } } - private class SaveResultDto { public ReloadDto? reload { get; set; } } - private class DhcpStatusDto - { - public string? managedFilePath { get; set; } - public bool managedFilePathExists { get; set; } - public bool reloadCommandConfigured { get; set; } - public string? dnsmasqStatus { get; set; } - } - private async Task Reload() { _saving = true; @@ -167,13 +226,11 @@ else _message = null; try { - var result = await Http.PostAsync("api/reload", null); - result.EnsureSuccessStatusCode(); - var reload = await result.Content.ReadFromJsonAsync(); - if (reload?.success == true) + var result = await ReloadClient.ReloadAsync(); + if (result.Success) _message = "Dnsmasq reloaded."; else - _error = "Reload failed: " + (reload?.stdErr ?? "unknown"); + _error = "Reload failed: " + (result.StdErr ?? "unknown"); } catch (Exception ex) { @@ -184,11 +241,4 @@ else _saving = false; } } - - private class ReloadResultDto - { - public bool success { get; set; } - public string? stdErr { get; set; } - } - private class ReloadDto { public bool success { get; set; } public string? stdErr { get; set; } } } diff --git a/src/DnsmasqWebUI/Components/Pages/Error.razor b/src/DnsmasqWebUI/Components/Pages/Error.razor index 576cc2d..d555f45 100644 --- a/src/DnsmasqWebUI/Components/Pages/Error.razor +++ b/src/DnsmasqWebUI/Components/Pages/Error.razor @@ -1,4 +1,5 @@ -@page "/Error" +@page "/Error" +@namespace DnsmasqWebUI.Components.Pages @using System.Diagnostics Error diff --git a/src/DnsmasqWebUI/Components/Pages/Home.razor b/src/DnsmasqWebUI/Components/Pages/Home.razor index 161fe71..b2d2f99 100644 --- a/src/DnsmasqWebUI/Components/Pages/Home.razor +++ b/src/DnsmasqWebUI/Components/Pages/Home.razor @@ -1,15 +1,18 @@ @page "/" @rendermode InteractiveServer -@inject HttpClient Http +@namespace DnsmasqWebUI.Components.Pages +@inject IStatusClient StatusClient +@inject IConfigSetClient ConfigSetClient +@inject IReloadClient ReloadClient @implements IDisposable -Overview +Dnsmasq -

Overview

+

Dnsmasq

-

Dnsmasq configuration and service status at a glance.

+

Configuration and service status at a glance.

-@if (_status == null && _error == null) +@if (_loading) {

Loading…

} @@ -21,22 +24,22 @@ else if (_status != null) {

Service

-
+
Dnsmasq: @GetStatusLabel(_status) - @if (_status.statusCommandExitCode.HasValue) + @if (_status.StatusCommandExitCode.HasValue) { - (exit @_status.statusCommandExitCode.Value) + (exit @_status.StatusCommandExitCode.Value) } - @if (!string.IsNullOrEmpty(_status.statusCommandStderr)) + @if (!string.IsNullOrEmpty(_status.StatusCommandStderr)) { - — @_status.statusCommandStderr + — @_status.StatusCommandStderr }
- @if (_status.dnsmasqStatus != "active" && (_status.statusCommandStdout != null || _status.statusCommandStderr != null)) + @if (_status.DnsmasqStatus != "active" && (_status.StatusCommandStdout != null || _status.StatusCommandStderr != null)) { -
@(_status.statusCommandStdout != null ? _status.statusCommandStdout : "")@(_status.statusCommandStdout != null && _status.statusCommandStderr != null ? "\n" : "")@(_status.statusCommandStderr ?? "")
+
@(_status.StatusCommandStdout != null ? _status.StatusCommandStdout : "")@(_status.StatusCommandStdout != null && _status.StatusCommandStderr != null ? "\n" : "")@(_status.StatusCommandStderr ?? "")
} - @if (_status.reloadCommandConfigured) + @if (_status.ReloadCommandConfigured) {
} + + @* Full service status — output of StatusShowCommand; format depends on command (systemctl on host, ps/pgrep in containers) *@ + @if (_status.StatusShowConfigured) + { +

Service status (output of StatusShowCommand)

+ @if (string.IsNullOrWhiteSpace(_status.StatusShowOutput)) + { +

No output. On a systemd host use systemctl status dnsmasq --no-pager for full status; in Docker or without systemd use ps -C dnsmasq -o pid,user,args or pgrep -a dnsmasq for minimal process info.

+ } + else + { +
@_status.StatusShowOutput
+ } + } + + @* Recent logs — live view, auto-refreshes *@ + @if (_status.LogsConfigured) + { +

Recent logs (live, refreshes every @(_refreshIntervalSeconds)s)

+ @if (string.IsNullOrWhiteSpace(_status.LogsOutput)) + { +

No output.

+ } + else + { +
@_status.LogsOutput
+ } + } + + @if (_status.StatusShowConfigured || _status.LogsConfigured) + { +
+ +
+ } +
+ +
+

Effective config

+

What dnsmasq uses after reading all config files (main + conf-file + conf-dir). Only single-value / overridable options are shown here.

+
    +
  • no-hosts: @(_status.NoHosts ? "Yes (hosts files disabled)" : "No")
  • +
  • + addn-hosts: + @if (_status.AddnHostsPaths?.Count > 0) + { + var deduped = _status.AddnHostsPaths.Distinct().ToList(); + @(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped)) + } + else + { + (none) + } +
  • + @if (_status.SystemHostsPath != null) + { +
  • System hosts file (editable): @_status.SystemHostsPath @(_status.SystemHostsPathExists ? "✓" : "(missing)")
  • + } +
+ @if (_status.EffectiveConfig != null) + { + var ec = _status.EffectiveConfig; +

Overridable options (last wins; future UI will allow editing via managed file):

+
    +
  • expand-hosts: @(ec.ExpandHosts ? "Yes" : "No")
  • +
  • bogus-priv: @(ec.BogusPriv ? "Yes" : "No")
  • +
  • strict-order: @(ec.StrictOrder ? "Yes" : "No")
  • +
  • no-resolv: @(ec.NoResolv ? "Yes" : "No")
  • +
  • domain-needed: @(ec.DomainNeeded ? "Yes" : "No")
  • +
  • dhcp-authoritative: @(ec.DhcpAuthoritative ? "Yes" : "No")
  • +
  • dhcp-leasefile: @(ec.DhcpLeaseFilePath ?? "(default)")
  • + @if (ec.CacheSize.HasValue) {
  • cache-size: @ec.CacheSize.Value
  • } + @if (ec.Port.HasValue) {
  • port: @ec.Port.Value
  • } + @if (ec.LocalTtl.HasValue) {
  • local-ttl: @ec.LocalTtl.Value
  • } + @if (ec.PidFilePath != null) {
  • pid-file: @ec.PidFilePath
  • } + @if (ec.User != null) {
  • user: @ec.User
  • } + @if (ec.Group != null) {
  • group: @ec.Group
  • } + @if (ec.DhcpLeaseMax.HasValue) {
  • dhcp-lease-max: @ec.DhcpLeaseMax.Value
  • } +
+ }

Config

    -
  • Main config: @(_status.mainConfigPath ?? "—") @(_status.mainConfigPathExists ? "✓" : "(missing)")
  • -
  • Managed file: @(_status.managedFilePath ?? "—") @(_status.managedFilePathExists ? "✓" : "(not created yet)")
  • +
  • Main config: @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")
  • +
  • Managed file: @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")
- @if (_configSet != null && _configSet.files?.Count > 0) + @if (_configSet != null && _configSet.Files?.Count > 0) {

Config set (load order):

    - @foreach (var f in _configSet.files) + @foreach (var f in _configSet.Files) {
  • - @if (f.isManaged) + @if (f.IsManaged) { managed } else { - @((f.source ?? "").ToLowerInvariant()) + @(f.Source.ToString().ToLowerInvariant()) } - @f.fileName - (@f.path) + @f.FileName + (@f.Path)
  • }
@@ -90,12 +173,11 @@ else if (_status != null)

Paths

    -
  • Hosts file: @(_status.hostsPath ?? "—") @(_status.hostsPathExists ? "✓" : "(missing)")
  • - Leases file: @(_status.leasesPath ?? "—") - @if (_status.leasesPathConfigured) + Leases file: @(_status.LeasesPath ?? "—") + @if (_status.LeasesPathConfigured) { - @(_status.leasesPathExists ? "✓" : "(missing)") + @(_status.LeasesPathExists ? "✓" : "(missing)") } else { @@ -109,72 +191,52 @@ else if (_status != null)

    Quick links

} @code { - private OverviewStatusDto? _status; - private OverviewConfigSetDto? _configSet; + private const int _refreshIntervalSeconds = 15; + + private DnsmasqServiceStatus? _status; + private DnsmasqConfigSet? _configSet; private string? _error; + private bool _loading = true; private bool _reloading; + private bool _refreshing; private string? _reloadMessage; private string? _reloadDetail; private bool _reloadSuccess; private readonly CancellationTokenSource _cts = new(); - - private class OverviewConfigSetDto - { - public string? mainConfigPath { get; set; } - public string? managedFilePath { get; set; } - public List? files { get; set; } - } - - private class OverviewConfigSetEntryDto - { - public string? path { get; set; } - public string? fileName { get; set; } - public string? source { get; set; } - public bool isManaged { get; set; } - } - - private class OverviewStatusDto - { - public string? hostsPath { get; set; } - public string? mainConfigPath { get; set; } - public string? managedFilePath { get; set; } - public string? leasesPath { get; set; } - public bool hostsPathExists { get; set; } - public bool mainConfigPathExists { get; set; } - public bool managedFilePathExists { get; set; } - public bool leasesPathConfigured { get; set; } - public bool leasesPathExists { get; set; } - public bool reloadCommandConfigured { get; set; } - public bool statusCommandConfigured { get; set; } - public string? dnsmasqStatus { get; set; } - public int? statusCommandExitCode { get; set; } - public string? statusCommandStdout { get; set; } - public string? statusCommandStderr { get; set; } - } + private Timer? _refreshTimer; protected override async Task OnInitializedAsync() { + _loading = true; try { + await Task.Delay(1, _cts.Token); var token = _cts.Token; - _status = await Http.GetFromJsonAsync("api/status", token); + _status = await StatusClient.GetStatusAsync(token); if (_status != null) { try { - _configSet = await Http.GetFromJsonAsync("api/config/set", token); + _configSet = await ConfigSetClient.GetConfigSetAsync(token); } catch { // Config set is optional for overview } + if (_status.StatusShowConfigured || _status.LogsConfigured) + { + _refreshTimer = new Timer( + _ => _ = InvokeAsync(OnRefreshTick), + null, + TimeSpan.FromSeconds(_refreshIntervalSeconds), + TimeSpan.FromSeconds(_refreshIntervalSeconds)); + } } } catch (OperationCanceledException) @@ -185,49 +247,86 @@ else if (_status != null) { _error = ex.Message; } + finally + { + _loading = false; + } } - private static string GetStatusLabel(OverviewStatusDto s) + private async Task OnRefreshTick() { - return s.dnsmasqStatus switch + if (_cts.Token.IsCancellationRequested) return; + await RefreshStatus(); + StateHasChanged(); + } + + private async Task RefreshStatus() + { + if (_status == null) return; + _refreshing = true; + try + { + var token = _cts.Token; + _status = await StatusClient.GetStatusAsync(token); + if (_status != null) + { + try + { + _configSet = await ConfigSetClient.GetConfigSetAsync(token); + } + catch + { + // Config set is optional + } + } + } + catch (OperationCanceledException) { } + catch (Exception) + { + // Don't overwrite _error on background refresh + } + finally + { + _refreshing = false; + } + } + + private static string GetStatusLabel(DnsmasqServiceStatus s) + { + return s.DnsmasqStatus switch { "active" => "Running", "inactive" => "Not running", "unknown" => "Unknown", "notConfigured" => "Status check not configured", - _ => s.dnsmasqStatus ?? "—" + _ => s.DnsmasqStatus ?? "—" }; } private async Task ReloadDnsmasq() { - if (_status == null || !_status.reloadCommandConfigured || _reloading) return; + if (_status == null || !_status.ReloadCommandConfigured || _reloading) return; _reloading = true; _reloadMessage = null; _reloadDetail = null; try { var token = _cts.Token; - var result = await Http.PostAsync("api/reload", null, token); - var body = await result.Content.ReadAsStringAsync(token); - ReloadResultDto? reload = null; - try { reload = System.Text.Json.JsonSerializer.Deserialize(body); } catch { } - if (reload?.success == true) + var result = await ReloadClient.ReloadAsync(token); + if (result.Success) { _reloadMessage = "Reloaded."; _reloadSuccess = true; } else { - _reloadMessage = reload != null - ? "Reload failed" + (reload.exitCode.HasValue ? " (exit " + reload.exitCode.Value + ")" : "") + "." - : (result.IsSuccessStatusCode ? "Reload failed." : "Request failed: " + body); - _reloadDetail = BuildReloadDetail(reload); + _reloadMessage = "Reload failed" + (result.ExitCode != 0 ? " (exit " + result.ExitCode + ")" : "") + "."; + _reloadDetail = BuildReloadDetail(result); _reloadSuccess = false; } try { - _status = await Http.GetFromJsonAsync("api/status", token); + _status = await StatusClient.GetStatusAsync(token); } catch { @@ -245,25 +344,18 @@ else if (_status != null) } } - private class ReloadResultDto + private static string? BuildReloadDetail(ReloadResult r) { - public bool success { get; set; } - public int? exitCode { get; set; } - public string? stdOut { get; set; } - public string? stdErr { get; set; } - } - - private static string? BuildReloadDetail(ReloadResultDto? r) - { - if (r == null) return null; var parts = new List(); - if (!string.IsNullOrEmpty(r.stdOut)) parts.Add("stdout:\n" + r.stdOut); - if (!string.IsNullOrEmpty(r.stdErr)) parts.Add("stderr:\n" + r.stdErr); + if (!string.IsNullOrEmpty(r.StdOut)) parts.Add("stdout:\n" + r.StdOut); + if (!string.IsNullOrEmpty(r.StdErr)) parts.Add("stderr:\n" + r.StdErr); return parts.Count > 0 ? string.Join("\n\n", parts) : null; } public void Dispose() { + _refreshTimer?.Dispose(); + _refreshTimer = null; _cts.Cancel(); _cts.Dispose(); } diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor index 0c7ff0b..1027a64 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor @@ -1,6 +1,8 @@ @page "/hosts" @rendermode InteractiveServer -@inject HttpClient Http +@namespace DnsmasqWebUI.Components.Pages +@inject IStatusClient StatusClient +@inject IHostsClient HostsClient Hosts @@ -10,15 +12,31 @@ {

Loading...

} -else if (!_status.hostsPathExists) +else if (_status.NoHosts && !_pathInAddnHosts) { -

Hosts file not found: @_status.hostsPath

+

Hosts editing is disabled: no-hosts is set in dnsmasq config (dnsmasq only uses addn-hosts files) and the configured path is not in the effective addn-hosts list, so dnsmasq would not read the file.

+ @if (_status.AddnHostsPaths?.Count > 0) + { +

Effective addn-hosts paths: @string.Join(", ", _status.AddnHostsPaths)

+ } +} +else if (string.IsNullOrEmpty(_status.SystemHostsPath)) +{ +

No system hosts file configured. Set Dnsmasq:SystemHostsPath (e.g. /etc/hosts) to edit a hosts file from this app.

+ @if (_status.AddnHostsPaths?.Count > 0) + { +

Dnsmasq loads these addn-hosts files (read-only here): @string.Join(", ", _status.AddnHostsPaths)

+ } +} +else if (!_status.SystemHostsPathExists) +{ +

System hosts file not found: @_status.SystemHostsPath

} else { - @if (_status.dnsmasqStatus == "inactive" || _status.dnsmasqStatus == "unknown") + @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") { -
Dnsmasq service appears to be @_status.dnsmasqStatus. Save will update the file but reload may fail until the service is fixed.
+
Dnsmasq service appears to be @_status.DnsmasqStatus. Save will update the file but reload may fail until the service is fixed.
} @if (_error != null) { @@ -43,23 +61,21 @@ else } -

Path: @_status.hostsPath

+

Path: @_status.SystemHostsPath

} @code { - private HostsStatusDto? _status; - private List _entries = new(); + private DnsmasqServiceStatus? _status; + private List _entries = new(); private string? _error; private string? _message; private bool _saving; - private class HostsStatusDto - { - public string? hostsPath { get; set; } - public bool hostsPathExists { get; set; } - public string? dnsmasqStatus { get; set; } - } + /// True when SystemHostsPath is in the effective addn-hosts list (editing allowed when no-hosts is set). + private bool _pathInAddnHosts => _status != null + && !string.IsNullOrEmpty(_status.SystemHostsPath) + && _status.AddnHostsPaths?.Any(p => string.Equals(p, Path.GetFullPath(_status.SystemHostsPath!.Trim()), StringComparison.Ordinal)) == true; protected override async Task OnInitializedAsync() { @@ -72,10 +88,9 @@ else _message = null; try { - _status = await Http.GetFromJsonAsync("api/status"); - var entries = await Http.GetFromJsonAsync>("api/hosts"); - if (entries != null) - _entries = entries; + _status = await StatusClient.GetStatusAsync(); + var entries = await HostsClient.GetHostsAsync(); + _entries = entries?.ToList() ?? new List(); } catch (Exception ex) { @@ -90,18 +105,11 @@ else _message = null; try { - var response = await Http.PutAsJsonAsync("api/hosts", _entries); - if (!response.IsSuccessStatusCode) - { - var body = await response.Content.ReadAsStringAsync(); - _error = body; - return; - } - var result = await response.Content.ReadFromJsonAsync(); - if (result?.reload?.success == true) + var result = await HostsClient.SaveHostsAsync(_entries); + if (result.Reload.Success) _message = "Saved and dnsmasq reloaded."; - else if (result?.reload != null && !string.IsNullOrEmpty(result.reload.stdErr)) - _error = "Saved but dnsmasq reload failed: " + result.reload.stdErr; + else if (!string.IsNullOrEmpty(result.Reload.StdErr)) + _error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr; else _message = "Saved."; } @@ -114,11 +122,4 @@ else _saving = false; } } - - private class SaveResultDto - { - public bool saved { get; set; } - public ReloadDto? reload { get; set; } - } - private class ReloadDto { public bool success { get; set; } public string? stdErr { get; set; } } } diff --git a/src/DnsmasqWebUI/Components/Pages/Leases.razor b/src/DnsmasqWebUI/Components/Pages/Leases.razor deleted file mode 100644 index 0be4e08..0000000 --- a/src/DnsmasqWebUI/Components/Pages/Leases.razor +++ /dev/null @@ -1,117 +0,0 @@ -@page "/leases" -@rendermode InteractiveServer -@inject HttpClient Http - -DHCP Leases - -

DHCP Leases

- -@if (_status == null) -{ -

Loading...

-} -else if (!_status.leasesPathConfigured) -{ -

Leases not configured.

-} -else -{ - @if (_error != null) - { -
@_error
- } - @if (!_status.leasesPathExists) - { -

Leases file not found: @_status.leasesPath. DHCP may be disabled or path may be wrong.

- } - else if (_leases == null) - { -

Could not read leases file.

- } - else - { - - - - - - @foreach (var e in _leases) - { - - - - - - - - } - -
ExpiryMACAddressNameClient ID
@e.timestamp.ToString("yyyy-MM-dd HH:mm:ss")@e.mac@e.address@e.name@e.clientId
-

Path: @_status.leasesPath (read-only). Cached and refreshed when the file changes; use Refresh to refetch now.

- - } -} - -@code { - private StatusDto? _status; - private List? _leases; - private string? _error; - private bool _loading; - - private class StatusDto - { - public string? leasesPath { get; set; } - public bool leasesPathConfigured { get; set; } - public bool leasesPathExists { get; set; } - } - - private class LeaseDto - { - public long epoch { get; set; } - public string? mac { get; set; } - public string? address { get; set; } - public string? name { get; set; } - public string? clientId { get; set; } - public DateTime timestamp => DateTimeOffset.FromUnixTimeSeconds(epoch).DateTime; - } - - private class LeasesResponse - { - public bool available { get; set; } - public List? entries { get; set; } - } - - protected override async Task OnInitializedAsync() - { - try - { - _status = await Http.GetFromJsonAsync("api/status"); - await LoadLeases(); - } - catch (Exception ex) - { - _error = ex.Message; - } - } - - private async Task LoadLeases() - { - if (_status == null || !_status.leasesPathConfigured) return; - _loading = true; - _error = null; - try - { - var resp = await Http.GetFromJsonAsync("api/leases"); - if (resp?.entries != null) - _leases = resp.entries; - } - catch (Exception ex) - { - _error = ex.Message; - } - finally - { - _loading = false; - } - } -} diff --git a/src/DnsmasqWebUI/Components/_Imports.razor b/src/DnsmasqWebUI/Components/_Imports.razor index 2f8bd6c..5ce44e3 100644 --- a/src/DnsmasqWebUI/Components/_Imports.razor +++ b/src/DnsmasqWebUI/Components/_Imports.razor @@ -1,4 +1,4 @@ -@using System.Net.Http +@using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @@ -8,3 +8,6 @@ @using Microsoft.JSInterop @using DnsmasqWebUI @using DnsmasqWebUI.Components +@using DnsmasqWebUI.Models +@using DnsmasqWebUI.Http.Clients +@using DnsmasqWebUI.Services.Abstractions diff --git a/src/DnsmasqWebUI/Controllers/ConfigController.cs b/src/DnsmasqWebUI/Controllers/ConfigController.cs index e86c185..d33aed6 100644 --- a/src/DnsmasqWebUI/Controllers/ConfigController.cs +++ b/src/DnsmasqWebUI/Controllers/ConfigController.cs @@ -1,4 +1,5 @@ using DnsmasqWebUI.Models; +using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Services.Abstractions; using Microsoft.AspNetCore.Mvc; @@ -49,7 +50,7 @@ public class ConfigController : ControllerBase } } - /// Writes the full managed config file (ensures one addn-hosts line = HostsPath), then optionally triggers reload. + /// Writes the full managed config file, then optionally triggers reload. When SystemHostsPath is configured, the managed file is updated so it contains exactly one addn-hosts line pointing to that path (so dnsmasq loads the editable hosts file); other config files may have additional addn-hosts lines. [HttpPut("managed")] public async Task> PutManaged([FromBody] List? lines, CancellationToken ct) { diff --git a/src/DnsmasqWebUI/Controllers/DhcpController.cs b/src/DnsmasqWebUI/Controllers/DhcpController.cs index 0fe785c..9a1eb52 100644 --- a/src/DnsmasqWebUI/Controllers/DhcpController.cs +++ b/src/DnsmasqWebUI/Controllers/DhcpController.cs @@ -32,7 +32,7 @@ public class DhcpController : ControllerBase } [HttpPut("hosts")] - public async Task> PutHosts([FromBody] List? entries, CancellationToken ct) + public async Task> PutHosts([FromBody] List? entries, CancellationToken ct) { if (entries == null) return BadRequest(new { error = "Body required" }); @@ -40,7 +40,7 @@ public class DhcpController : ControllerBase { await _configService.WriteDhcpHostsAsync(entries, ct); var reload = await _reloadService.ReloadAsync(ct); - return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } }); + return Ok(new SaveWithReloadResult(true, reload)); } catch (Exception ex) { diff --git a/src/DnsmasqWebUI/Controllers/HostsController.cs b/src/DnsmasqWebUI/Controllers/HostsController.cs index f88ad93..4534215 100644 --- a/src/DnsmasqWebUI/Controllers/HostsController.cs +++ b/src/DnsmasqWebUI/Controllers/HostsController.cs @@ -1,4 +1,5 @@ using DnsmasqWebUI.Models; +using DnsmasqWebUI.Options; using DnsmasqWebUI.Services.Abstractions; using Microsoft.AspNetCore.Mvc; @@ -10,11 +11,19 @@ public class HostsController : ControllerBase { private readonly IHostsFileService _hostsService; private readonly IReloadService _reloadService; + private readonly IDnsmasqConfigSetService _configSetService; + private readonly DnsmasqOptions _options; - public HostsController(IHostsFileService hostsService, IReloadService reloadService) + public HostsController( + IHostsFileService hostsService, + IReloadService reloadService, + IDnsmasqConfigSetService configSetService, + Microsoft.Extensions.Options.IOptions options) { _hostsService = hostsService; _reloadService = reloadService; + _configSetService = configSetService; + _options = options.Value; } [HttpGet] @@ -32,15 +41,23 @@ public class HostsController : ControllerBase } [HttpPut] - public async Task> Put([FromBody] List? entries, CancellationToken ct) + public async Task> Put([FromBody] List? entries, CancellationToken ct) { if (entries == null) return BadRequest(new { error = "Body required" }); + var effectiveConfig = _configSetService.GetEffectiveConfig(); + if (string.IsNullOrWhiteSpace(_options.SystemHostsPath)) + return BadRequest(new { error = "No system hosts file configured. Set Dnsmasq:SystemHostsPath to enable hosts editing." }); + // When no-hosts is set, dnsmasq only uses addn-hosts files; allow editing only if our path is in that list. + var systemPath = Path.GetFullPath(_options.SystemHostsPath.Trim()); + var pathInAddnHosts = effectiveConfig.AddnHostsPaths?.Any(p => string.Equals(p, systemPath, StringComparison.Ordinal)) == true; + if (effectiveConfig.NoHosts && !pathInAddnHosts) + return BadRequest(new { error = "Hosts are disabled by no-hosts in dnsmasq config, and the configured path is not in addn-hosts, so dnsmasq would not read it." }); try { await _hostsService.WriteAsync(entries, ct); var reload = await _reloadService.ReloadAsync(ct); - return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } }); + return Ok(new SaveWithReloadResult(true, reload)); } catch (Exception ex) { diff --git a/src/DnsmasqWebUI/Controllers/LeasesController.cs b/src/DnsmasqWebUI/Controllers/LeasesController.cs index 72bb623..965c0a0 100644 --- a/src/DnsmasqWebUI/Controllers/LeasesController.cs +++ b/src/DnsmasqWebUI/Controllers/LeasesController.cs @@ -1,3 +1,4 @@ +using DnsmasqWebUI.Models; using DnsmasqWebUI.Services.Abstractions; using Microsoft.AspNetCore.Mvc; @@ -15,16 +16,16 @@ public class LeasesController : ControllerBase } [HttpGet] - public async Task> Get(CancellationToken ct) + public async Task> Get(CancellationToken ct) { try { var (available, entries) = await _leasesService.TryReadAsync(ct); if (!available) - return Ok(new { available = false, entries = (IReadOnlyList?)null, message = "Leases not configured." }); + return Ok(new LeasesResult(false, null, "Leases not configured.")); if (entries == null) - return Ok(new { available = true, entries = Array.Empty(), message = "Leases file not readable." }); - return Ok(new { available = true, entries }); + return Ok(new LeasesResult(true, Array.Empty(), "Leases file not readable.")); + return Ok(new LeasesResult(true, entries, null)); } catch (Exception ex) { diff --git a/src/DnsmasqWebUI/Controllers/StatusController.cs b/src/DnsmasqWebUI/Controllers/StatusController.cs index f323623..e9ad013 100644 --- a/src/DnsmasqWebUI/Controllers/StatusController.cs +++ b/src/DnsmasqWebUI/Controllers/StatusController.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using DnsmasqWebUI.Models; using DnsmasqWebUI.Options; using DnsmasqWebUI.Services.Abstractions; using Microsoft.AspNetCore.Mvc; @@ -12,83 +12,81 @@ public class StatusController : ControllerBase { private readonly DnsmasqOptions _options; private readonly IDnsmasqConfigSetService _configSetService; + private readonly IProcessRunner _processRunner; - public StatusController(IOptions options, IDnsmasqConfigSetService configSetService) + public StatusController( + IOptions options, + IDnsmasqConfigSetService configSetService, + IProcessRunner processRunner) { _options = options.Value; _configSetService = configSetService; + _processRunner = processRunner; } [HttpGet] - public async Task Get(CancellationToken ct) + public async Task> Get(CancellationToken ct) { try { var set = await _configSetService.GetConfigSetAsync(ct); - var (dnsmasqStatus, statusCommandExitCode, statusCommandStdout, statusCommandStderr) = GetDnsmasqServiceStatus(_options.StatusCommand); var effectiveLeasesPath = _configSetService.GetLeasesPath(); - var payload = new Dictionary - { - ["hostsPath"] = _options.HostsPath, - ["mainConfigPath"] = _options.MainConfigPath, - ["managedFilePath"] = set.ManagedFilePath, - ["leasesPath"] = effectiveLeasesPath, - ["hostsPathExists"] = !string.IsNullOrEmpty(_options.HostsPath) && System.IO.File.Exists(_options.HostsPath), - ["mainConfigPathExists"] = !string.IsNullOrEmpty(_options.MainConfigPath) && System.IO.File.Exists(_options.MainConfigPath), - ["managedFilePathExists"] = !string.IsNullOrEmpty(set.ManagedFilePath) && System.IO.File.Exists(set.ManagedFilePath), - ["leasesPathConfigured"] = !string.IsNullOrEmpty(effectiveLeasesPath), - ["leasesPathExists"] = !string.IsNullOrEmpty(effectiveLeasesPath) && System.IO.File.Exists(effectiveLeasesPath), - ["reloadCommandConfigured"] = !string.IsNullOrWhiteSpace(_options.ReloadCommand), - ["statusCommandConfigured"] = !string.IsNullOrWhiteSpace(_options.StatusCommand), - ["dnsmasqStatus"] = dnsmasqStatus - }; - if (dnsmasqStatus != "active" && statusCommandExitCode.HasValue) - payload["statusCommandExitCode"] = statusCommandExitCode.Value; - if (dnsmasqStatus != "active" && !string.IsNullOrEmpty(statusCommandStdout)) - payload["statusCommandStdout"] = statusCommandStdout; - if (dnsmasqStatus != "active" && !string.IsNullOrEmpty(statusCommandStderr)) - payload["statusCommandStderr"] = statusCommandStderr; - return Ok(payload); + var effectiveConfig = _configSetService.GetEffectiveConfig(); + var systemHostsPath = _options.SystemHostsPath?.Trim(); + + var statusResult = await _processRunner.RunAsync(_options.StatusCommand, TimeSpan.FromSeconds(5), ct); + var dnsmasqStatus = statusResult.ExitCode == 0 ? "active" : (statusResult.ExitCode.HasValue ? "inactive" : "unknown"); + var statusCommandStdout = string.IsNullOrWhiteSpace(statusResult.Stdout) ? null : statusResult.Stdout.Trim(); + var statusCommandStderr = string.IsNullOrWhiteSpace(statusResult.Stderr) ? null : statusResult.Stderr.Trim(); + if (statusResult.ExceptionMessage != null) + statusCommandStderr = (statusCommandStderr ?? "") + (statusCommandStderr != null ? "\n" : "") + statusResult.ExceptionMessage; + + var showTask = string.IsNullOrWhiteSpace(_options.StatusShowCommand) + ? Task.FromResult(new ProcessRunResult(null, "", "", false)) + : _processRunner.RunAsync(_options.StatusShowCommand, TimeSpan.FromSeconds(5), ct); + var logsTask = string.IsNullOrWhiteSpace(_options.LogsCommand) + ? Task.FromResult(new ProcessRunResult(null, "", "", false)) + : _processRunner.RunAsync(_options.LogsCommand, TimeSpan.FromSeconds(10), ct); + await Task.WhenAll(showTask, logsTask); + + var showResult = await showTask; + var logsResult = await logsTask; + var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand) + ? showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : "") + : null; + var logsOutput = !string.IsNullOrWhiteSpace(_options.LogsCommand) + ? logsResult.Stdout + (logsResult.TimedOut ? "\n(Command timed out.)" : "") + : null; + + var status = new DnsmasqServiceStatus( + SystemHostsPath: string.IsNullOrEmpty(systemHostsPath) ? null : systemHostsPath, + SystemHostsPathExists: !string.IsNullOrEmpty(systemHostsPath) && System.IO.File.Exists(systemHostsPath), + NoHosts: effectiveConfig.NoHosts, + AddnHostsPaths: effectiveConfig.AddnHostsPaths, + EffectiveConfig: effectiveConfig, + MainConfigPath: _options.MainConfigPath, + ManagedFilePath: set.ManagedFilePath, + LeasesPath: effectiveLeasesPath, + MainConfigPathExists: !string.IsNullOrEmpty(_options.MainConfigPath) && System.IO.File.Exists(_options.MainConfigPath), + ManagedFilePathExists: !string.IsNullOrEmpty(set.ManagedFilePath) && System.IO.File.Exists(set.ManagedFilePath), + LeasesPathConfigured: !string.IsNullOrEmpty(effectiveLeasesPath), + LeasesPathExists: !string.IsNullOrEmpty(effectiveLeasesPath) && System.IO.File.Exists(effectiveLeasesPath), + ReloadCommandConfigured: !string.IsNullOrWhiteSpace(_options.ReloadCommand), + StatusCommandConfigured: !string.IsNullOrWhiteSpace(_options.StatusCommand), + StatusShowConfigured: !string.IsNullOrWhiteSpace(_options.StatusShowCommand), + LogsConfigured: !string.IsNullOrWhiteSpace(_options.LogsCommand), + DnsmasqStatus: dnsmasqStatus, + StatusCommandExitCode: dnsmasqStatus != "active" && statusResult.ExitCode.HasValue ? statusResult.ExitCode.Value : null, + StatusCommandStdout: dnsmasqStatus != "active" ? statusCommandStdout : null, + StatusCommandStderr: dnsmasqStatus != "active" ? statusCommandStderr : null, + StatusShowOutput: statusShowOutput, + LogsOutput: logsOutput + ); + return Ok(status); } catch (Exception ex) { return StatusCode(500, new { error = ex.Message }); } } - - /// Runs StatusCommand if configured; returns status and optional exit code/stdout/stderr for UI. - private static (string Status, int? ExitCode, string? Stdout, string? Stderr) GetDnsmasqServiceStatus(string? statusCommand) - { - if (string.IsNullOrWhiteSpace(statusCommand)) - return ("notConfigured", null, null, null); - - try - { - using var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = "/bin/sh", - Arguments = "-c \"" + statusCommand.Replace("\"", "\\\"") + "\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - process.Start(); - var stdout = process.StandardOutput.ReadToEnd(); - var stderr = process.StandardError.ReadToEnd(); - process.WaitForExit(TimeSpan.FromSeconds(5)); - var exitCode = process.HasExited ? process.ExitCode : -1; - var status = exitCode == 0 ? "active" : "inactive"; - return (status, exitCode, - string.IsNullOrWhiteSpace(stdout) ? null : stdout.Trim(), - string.IsNullOrWhiteSpace(stderr) ? null : stderr.Trim()); - } - catch (Exception ex) - { - return ("unknown", null, null, ex.Message); - } - } } diff --git a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs index 8a92b7e..8b24c2e 100644 --- a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs +++ b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs @@ -12,13 +12,14 @@ public static class ServiceCollectionExtensions static readonly (Type MarkerInterface, RegisterService Register)[] ApplicationRegistrations = [ (typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)), + (typeof(IApplicationScopedConcrete), (s, i, impl) => s.AddScoped(impl, impl)), (typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)), (typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl)), ]; /// /// Scans the assembly for types implementing application marker interfaces - /// (, , , etc.) + /// (, , , , etc.) /// and registers each with the configured lifetime. Hosted services are registered via AddHostedService<T>. /// Skips open generics; requires exactly one public implementation per interface (scoped/singleton). /// diff --git a/src/DnsmasqWebUI/Http/Clients/ConfigSetClient.cs b/src/DnsmasqWebUI/Http/Clients/ConfigSetClient.cs new file mode 100644 index 0000000..72bbd8b --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/ConfigSetClient.cs @@ -0,0 +1,15 @@ +using DnsmasqWebUI.Models; +using System.Net.Http.Json; + +namespace DnsmasqWebUI.Http.Clients; + +public sealed class ConfigSetClient : IConfigSetClient +{ + private readonly HttpClient _http; + + public ConfigSetClient(HttpClient http) => _http = http; + + public async Task GetConfigSetAsync(CancellationToken ct = default) => + await _http.GetFromJsonAsync("api/config/set", ct) + ?? throw new InvalidOperationException("Unexpected null from api/config/set."); +} diff --git a/src/DnsmasqWebUI/Http/Clients/DhcpHostsClient.cs b/src/DnsmasqWebUI/Http/Clients/DhcpHostsClient.cs new file mode 100644 index 0000000..aed61a6 --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/DhcpHostsClient.cs @@ -0,0 +1,25 @@ +using DnsmasqWebUI.Models; +using System.Net.Http.Json; + +namespace DnsmasqWebUI.Http.Clients; + +public sealed class DhcpHostsClient : IDhcpHostsClient +{ + private readonly HttpClient _http; + + public DhcpHostsClient(HttpClient http) => _http = http; + + public async Task> GetDhcpHostsAsync(CancellationToken ct = default) + { + var list = await _http.GetFromJsonAsync>("api/dhcp/hosts", ct); + return list ?? new List(); + } + + public async Task SaveDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default) + { + var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts."); + } +} diff --git a/src/DnsmasqWebUI/Http/Clients/HostsClient.cs b/src/DnsmasqWebUI/Http/Clients/HostsClient.cs new file mode 100644 index 0000000..7b4187a --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/HostsClient.cs @@ -0,0 +1,25 @@ +using DnsmasqWebUI.Models; +using System.Net.Http.Json; + +namespace DnsmasqWebUI.Http.Clients; + +public sealed class HostsClient : IHostsClient +{ + private readonly HttpClient _http; + + public HostsClient(HttpClient http) => _http = http; + + public async Task> GetHostsAsync(CancellationToken ct = default) + { + var list = await _http.GetFromJsonAsync>("api/hosts", ct); + return list ?? new List(); + } + + public async Task SaveHostsAsync(IReadOnlyList entries, CancellationToken ct = default) + { + var response = await _http.PutAsJsonAsync("api/hosts", entries, ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Unexpected null from api/hosts."); + } +} diff --git a/src/DnsmasqWebUI/Http/Clients/IConfigSetClient.cs b/src/DnsmasqWebUI/Http/Clients/IConfigSetClient.cs new file mode 100644 index 0000000..a874b09 --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/IConfigSetClient.cs @@ -0,0 +1,9 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Http.Clients; + +/// Typed client for GET api/config/set. +public interface IConfigSetClient +{ + Task GetConfigSetAsync(CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Http/Clients/IDhcpHostsClient.cs b/src/DnsmasqWebUI/Http/Clients/IDhcpHostsClient.cs new file mode 100644 index 0000000..913231e --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/IDhcpHostsClient.cs @@ -0,0 +1,10 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Http.Clients; + +/// Typed client for GET/PUT api/dhcp/hosts. +public interface IDhcpHostsClient +{ + Task> GetDhcpHostsAsync(CancellationToken ct = default); + Task SaveDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Http/Clients/IHostsClient.cs b/src/DnsmasqWebUI/Http/Clients/IHostsClient.cs new file mode 100644 index 0000000..a6f6cf0 --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/IHostsClient.cs @@ -0,0 +1,10 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Http.Clients; + +/// Typed client for GET/PUT api/hosts. +public interface IHostsClient +{ + Task> GetHostsAsync(CancellationToken ct = default); + Task SaveHostsAsync(IReadOnlyList entries, CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Http/Clients/ILeasesClient.cs b/src/DnsmasqWebUI/Http/Clients/ILeasesClient.cs new file mode 100644 index 0000000..a4a1a1e --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/ILeasesClient.cs @@ -0,0 +1,9 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Http.Clients; + +/// Typed client for GET api/leases. +public interface ILeasesClient +{ + Task GetLeasesAsync(CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Http/Clients/IReloadClient.cs b/src/DnsmasqWebUI/Http/Clients/IReloadClient.cs new file mode 100644 index 0000000..1d6eead --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/IReloadClient.cs @@ -0,0 +1,9 @@ +using DnsmasqWebUI.Services.Abstractions; + +namespace DnsmasqWebUI.Http.Clients; + +/// Typed client for POST api/reload. +public interface IReloadClient +{ + Task ReloadAsync(CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Http/Clients/IStatusClient.cs b/src/DnsmasqWebUI/Http/Clients/IStatusClient.cs new file mode 100644 index 0000000..54f794b --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/IStatusClient.cs @@ -0,0 +1,9 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Http.Clients; + +/// Typed client for GET api/status. +public interface IStatusClient +{ + Task GetStatusAsync(CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Http/Clients/LeasesClient.cs b/src/DnsmasqWebUI/Http/Clients/LeasesClient.cs new file mode 100644 index 0000000..7ec6510 --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/LeasesClient.cs @@ -0,0 +1,15 @@ +using DnsmasqWebUI.Models; +using System.Net.Http.Json; + +namespace DnsmasqWebUI.Http.Clients; + +public sealed class LeasesClient : ILeasesClient +{ + private readonly HttpClient _http; + + public LeasesClient(HttpClient http) => _http = http; + + public async Task GetLeasesAsync(CancellationToken ct = default) => + await _http.GetFromJsonAsync("api/leases", ct) + ?? throw new InvalidOperationException("Unexpected null from api/leases."); +} diff --git a/src/DnsmasqWebUI/Http/Clients/ReloadClient.cs b/src/DnsmasqWebUI/Http/Clients/ReloadClient.cs new file mode 100644 index 0000000..3c0ca3b --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/ReloadClient.cs @@ -0,0 +1,19 @@ +using DnsmasqWebUI.Services.Abstractions; +using System.Net.Http.Json; + +namespace DnsmasqWebUI.Http.Clients; + +public sealed class ReloadClient : IReloadClient +{ + private readonly HttpClient _http; + + public ReloadClient(HttpClient http) => _http = http; + + public async Task ReloadAsync(CancellationToken ct = default) + { + var response = await _http.PostAsync("api/reload", null, ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Unexpected null from api/reload."); + } +} diff --git a/src/DnsmasqWebUI/Http/Clients/StatusClient.cs b/src/DnsmasqWebUI/Http/Clients/StatusClient.cs new file mode 100644 index 0000000..0c26363 --- /dev/null +++ b/src/DnsmasqWebUI/Http/Clients/StatusClient.cs @@ -0,0 +1,15 @@ +using DnsmasqWebUI.Models; +using System.Net.Http.Json; + +namespace DnsmasqWebUI.Http.Clients; + +public sealed class StatusClient : IStatusClient +{ + private readonly HttpClient _http; + + public StatusClient(HttpClient http) => _http = http; + + public async Task GetStatusAsync(CancellationToken ct = default) => + await _http.GetFromJsonAsync("api/status", ct) + ?? throw new InvalidOperationException("Unexpected null from api/status."); +} diff --git a/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs b/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs index b26049a..f865058 100644 --- a/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs +++ b/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs @@ -1,4 +1,5 @@ using System.Net; +using DnsmasqWebUI.Services.Abstractions; namespace DnsmasqWebUI.Http; @@ -8,17 +9,17 @@ namespace DnsmasqWebUI.Http; /// Resolved in the same scope as the code that requested the HttpClient (e.g. Blazor component), /// so IHttpContextAccessor has the current request when available. /// -public sealed class SameHostBaseAddressHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler +public sealed class SameHostBaseAddressHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler, IApplicationScopedConcrete { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if (request.RequestUri is { IsAbsoluteUri: false }) + var context = httpContextAccessor.HttpContext; + if (context != null && request.RequestUri != null) { - var context = httpContextAccessor.HttpContext; - var baseUri = context != null - ? new Uri($"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value ?? ""}") - : new Uri("http://localhost", UriKind.Absolute); - request.RequestUri = new Uri(baseUri, request.RequestUri); + var baseUri = new Uri($"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value ?? ""}"); + request.RequestUri = request.RequestUri.IsAbsoluteUri + ? new Uri(baseUri, request.RequestUri.PathAndQuery) + : new Uri(baseUri, request.RequestUri); } return base.SendAsync(request, cancellationToken); diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfLine.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqConfLine.cs similarity index 96% rename from src/DnsmasqWebUI/Models/DnsmasqConfLine.cs rename to src/DnsmasqWebUI/Models/Config/DnsmasqConfLine.cs index a6bbc62..2b8dce8 100644 --- a/src/DnsmasqWebUI/Models/DnsmasqConfLine.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqConfLine.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; +using DnsmasqWebUI.Models; -namespace DnsmasqWebUI.Models; +namespace DnsmasqWebUI.Models.Config; /// Kind of line in a dnsmasq .conf file. One option per line, key=value (no leading --), # for comments. public enum DnsmasqConfLineKind diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfigOptions.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs similarity index 98% rename from src/DnsmasqWebUI/Models/DnsmasqConfigOptions.cs rename to src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs index f2f3a52..11b7ad9 100644 --- a/src/DnsmasqWebUI/Models/DnsmasqConfigOptions.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs @@ -1,4 +1,4 @@ -namespace DnsmasqWebUI.Models; +namespace DnsmasqWebUI.Models.Config; /// /// Registry of dnsmasq .conf option names to their . diff --git a/src/DnsmasqWebUI/Models/DnsmasqOptionKind.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionKind.cs similarity index 93% rename from src/DnsmasqWebUI/Models/DnsmasqOptionKind.cs rename to src/DnsmasqWebUI/Models/Config/DnsmasqOptionKind.cs index 66669a3..103a60a 100644 --- a/src/DnsmasqWebUI/Models/DnsmasqOptionKind.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionKind.cs @@ -1,4 +1,4 @@ -namespace DnsmasqWebUI.Models; +namespace DnsmasqWebUI.Models.Config; /// /// Kind of dnsmasq .conf option. Each kind has a dedicated parser and backing model @@ -35,7 +35,7 @@ public enum DnsmasqOptionKind // --- DHCP (structured) --- /// dhcp-range=... → (complex) DhcpRange, - /// dhcp-host=... → (already have parser) + /// dhcp-host=... → DhcpHostEntry (in DnsmasqWebUI.Models) DhcpHost, /// dhcp-option=... (complex) DhcpOption, diff --git a/src/DnsmasqWebUI/Models/DnsmasqOptionModels.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionModels.cs similarity index 98% rename from src/DnsmasqWebUI/Models/DnsmasqOptionModels.cs rename to src/DnsmasqWebUI/Models/Config/DnsmasqOptionModels.cs index a656472..3a13a51 100644 --- a/src/DnsmasqWebUI/Models/DnsmasqOptionModels.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionModels.cs @@ -1,4 +1,4 @@ -namespace DnsmasqWebUI.Models; +namespace DnsmasqWebUI.Models.Config; /// Backing model for conf-file=path. Parsed from main config only. public record ConfFileOption(string Path, int LineNumber, string SourceFilePath); diff --git a/src/DnsmasqWebUI/Models/ParsedDirective.cs b/src/DnsmasqWebUI/Models/Config/ParsedDirective.cs similarity index 93% rename from src/DnsmasqWebUI/Models/ParsedDirective.cs rename to src/DnsmasqWebUI/Models/Config/ParsedDirective.cs index fb3b298..b4cbcaa 100644 --- a/src/DnsmasqWebUI/Models/ParsedDirective.cs +++ b/src/DnsmasqWebUI/Models/Config/ParsedDirective.cs @@ -1,4 +1,4 @@ -namespace DnsmasqWebUI.Models; +namespace DnsmasqWebUI.Models.Config; /// /// One parsed directive from a dnsmasq .conf file. Kind tells which option type; diff --git a/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs b/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs new file mode 100644 index 0000000..0950039 --- /dev/null +++ b/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs @@ -0,0 +1,27 @@ +namespace DnsmasqWebUI.Models; + +/// Dnsmasq service and config paths status returned by api/status. +public record DnsmasqServiceStatus( + string? SystemHostsPath, + bool SystemHostsPathExists, + bool NoHosts, + IReadOnlyList AddnHostsPaths, + EffectiveDnsmasqConfig? EffectiveConfig, + string? MainConfigPath, + string? ManagedFilePath, + string? LeasesPath, + bool MainConfigPathExists, + bool ManagedFilePathExists, + bool LeasesPathConfigured, + bool LeasesPathExists, + bool ReloadCommandConfigured, + bool StatusCommandConfigured, + bool StatusShowConfigured, + bool LogsConfigured, + string DnsmasqStatus, + int? StatusCommandExitCode, + string? StatusCommandStdout, + string? StatusCommandStderr, + string? StatusShowOutput, + string? LogsOutput +); diff --git a/src/DnsmasqWebUI/Models/EffectiveDnsmasqConfig.cs b/src/DnsmasqWebUI/Models/EffectiveDnsmasqConfig.cs new file mode 100644 index 0000000..2fc3213 --- /dev/null +++ b/src/DnsmasqWebUI/Models/EffectiveDnsmasqConfig.cs @@ -0,0 +1,43 @@ +namespace DnsmasqWebUI.Models; + +/// +/// Effective dnsmasq config after reading all config files (main + conf-file + conf-dir). +/// Represents the final values that dnsmasq uses. Only includes options that are +/// single-value (last wins) or boolean flags, so they can be overridden by writing +/// to our managed file. Multi-value options (e.g. addn-hosts, server=) are either +/// listed read-only or omitted from this model. +/// Based on dnsmasq option.c: ARG_ONE = last occurrence wins; flag options = set if any. +/// +public record EffectiveDnsmasqConfig( + // --- Hosts (already used by Hosts UI) --- + bool NoHosts, + IReadOnlyList AddnHostsPaths, + + // --- Boolean flags (set if any file contains the option) --- + bool ExpandHosts, + bool BogusPriv, + bool StrictOrder, + bool NoResolv, + bool DomainNeeded, + bool NoPoll, + bool BindInterfaces, + bool NoNegcache, + bool DhcpAuthoritative, + bool LeasefileRo, + + // --- Single-value options (last occurrence wins; null = not set, dnsmasq default) --- + string? DhcpLeaseFilePath, + int? CacheSize, + int? Port, + int? LocalTtl, + string? PidFilePath, + string? User, + string? Group, + string? LogFacility, + int? DhcpLeaseMax, + int? NegTtl, + int? MaxTtl, + int? MaxCacheTtl, + int? MinCacheTtl, + int? DhcpTtl +); diff --git a/src/DnsmasqWebUI/Models/LeasesResult.cs b/src/DnsmasqWebUI/Models/LeasesResult.cs new file mode 100644 index 0000000..41f5ca6 --- /dev/null +++ b/src/DnsmasqWebUI/Models/LeasesResult.cs @@ -0,0 +1,4 @@ +namespace DnsmasqWebUI.Models; + +/// Result of GET api/leases: whether leases are available and the list of entries. +public record LeasesResult(bool Available, IReadOnlyList? Entries, string? Message); diff --git a/src/DnsmasqWebUI/Models/SaveWithReloadResult.cs b/src/DnsmasqWebUI/Models/SaveWithReloadResult.cs new file mode 100644 index 0000000..3307a4a --- /dev/null +++ b/src/DnsmasqWebUI/Models/SaveWithReloadResult.cs @@ -0,0 +1,6 @@ +using DnsmasqWebUI.Services.Abstractions; + +namespace DnsmasqWebUI.Models; + +/// Result of a save operation that triggers a dnsmasq reload (e.g. PUT api/hosts, PUT api/dhcp/hosts). +public record SaveWithReloadResult(bool Saved, ReloadResult Reload); diff --git a/src/DnsmasqWebUI/Options/DnsmasqOptions.cs b/src/DnsmasqWebUI/Options/DnsmasqOptions.cs index f955328..889dbea 100644 --- a/src/DnsmasqWebUI/Options/DnsmasqOptions.cs +++ b/src/DnsmasqWebUI/Options/DnsmasqOptions.cs @@ -3,7 +3,7 @@ namespace DnsmasqWebUI.Options; /// /// Configuration for dnsmasq paths and reload/status commands. /// File permissions: the app must be able to read MainConfigPath and the conf-dir (or conf-file) target, -/// and to create/update the managed config file and HostsPath. In the Docker image (Dockerfile) +/// and to create/update the managed config file and SystemHostsPath. In the Docker image (Dockerfile) /// app and dnsmasq run in one container as root, so this works. When the UI runs in a container and /// dnsmasq is on the host, bind-mount the host config dir (e.g. /etc/dnsmasq.d) into the container; /// the container process typically needs to run as root (or the host dir must be writable by the @@ -23,12 +23,18 @@ public class DnsmasqOptions /// Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in <main-config-dir>/dnsmasq.d/ and included via conf-file= at the end of the main config so it loads last. Managed file content parsed with DnsmasqConfFileLineParser. public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf"; - /// Path we write as addn-hosts= in the managed file; HostsFileService reads/writes this path. Process must have read/write access. - public string HostsPath { get; set; } = ""; + /// Optional path to the hosts file the app can edit (e.g. /etc/hosts). When set, the app can read/write it. When no-hosts is not set, the app adds addn-hosts= in the managed file so dnsmasq loads it; when no-hosts is set, dnsmasq only uses addn-hosts files, so this path must be one of the effective addn-hosts paths for editing to take effect. When unset, hosts UI is disabled. + public string? SystemHostsPath { get; set; } /// Command to run after config changes (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). Runs in the same environment as the app; if app is in a container and dnsmasq is on the host, this runs in the container and will not reload host dnsmasq unless you use a host-side relay. public string? ReloadCommand { get; set; } /// Optional command to check dnsmasq service state (e.g. "systemctl is-active dnsmasq" or "pgrep -x dnsmasq"). Runs in the same environment as the app; if app is in a container, this checks for dnsmasq in the container, not on the host. public string? StatusCommand { get; set; } + + /// Optional command for full service status (e.g. "systemctl status dnsmasq --no-pager"). Output shown on Overview. + public string? StatusShowCommand { get; set; } + + /// Optional command for recent logs (e.g. "journalctl -u dnsmasq -n 100 --no-pager"). Output shown on Overview. + public string? LogsCommand { get; set; } } diff --git a/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs b/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs index ad79a7d..16c71e1 100644 --- a/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs +++ b/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs @@ -28,18 +28,9 @@ public sealed class DnsmasqOptionsValidator : IValidateOptions } } - if (string.IsNullOrWhiteSpace(options.HostsPath)) - { - failures.Add("Dnsmasq:HostsPath is required. Set it in appsettings.json (e.g. \"HostsPath\": \"/etc/hosts\") or via the Dnsmasq__HostsPath environment variable."); - } - else - { - var hostsPath = Path.GetFullPath(options.HostsPath.Trim()); - if (!File.Exists(hostsPath)) - { - failures.Add($"Hosts file not found: {hostsPath}. Ensure Dnsmasq:HostsPath points to an existing hosts file (e.g. /etc/hosts). Override with Dnsmasq__HostsPath if using a different path."); - } - } + // SystemHostsPath is optional. When set, the app can edit that hosts file. Hosts UI is disabled when + // SystemHostsPath is unset, or when no-hosts is set and SystemHostsPath is not in the effective addn-hosts + // list (dnsmasq only uses addn-hosts when no-hosts is set, so the path must be in addn-hosts for editing to take effect). if (failures.Count == 0) return ValidateOptionsResult.Success; diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs index c1dd96c..945e2f1 100644 --- a/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs +++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs @@ -1,4 +1,5 @@ using DnsmasqWebUI.Models; +using DnsmasqWebUI.Models.Config; using Superpower; using Superpower.Model; using Superpower.Parsers; diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs index d1362e1..c073511 100644 --- a/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs +++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfFileLineParser.cs @@ -1,4 +1,5 @@ using DnsmasqWebUI.Models; +using DnsmasqWebUI.Models.Config; using Superpower; using Superpower.Model; using Superpower.Parsers; diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs index 870ee1a..ccff5ab 100644 --- a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs +++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs @@ -94,9 +94,82 @@ public static class DnsmasqConfIncludeParser return null; } + /// + /// Returns true if any config file contains the given option as a flag (no value or empty value). + /// Used for options like no-hosts, expand-hosts, bogus-priv (dnsmasq: once set, set). + /// + public static bool GetFlagFromConfigFiles(IReadOnlyList configFilePathsInOrder, string optionKey) + { + var key = optionKey.Trim(); + if (string.IsNullOrEmpty(key)) + return false; + foreach (var configPath in configFilePathsInOrder) + { + if (!File.Exists(configPath)) + continue; + foreach (var line in File.ReadAllLines(configPath)) + { + var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line); + if (kv == null) + continue; + var (k, v) = kv.Value; + if (!k.Equals(key, StringComparison.OrdinalIgnoreCase)) + continue; + return true; + } + } + return false; + } + + /// + /// Returns the last value for the given option key across config files, and the directory of the file + /// that contained it (for resolving relative paths). Keys are matched case-insensitively. + /// + public static (string? Value, string? ConfigFileDir) GetLastValueFromConfigFiles(IReadOnlyList configFilePathsInOrder, string optionKey) + { + var key = optionKey.Trim(); + if (string.IsNullOrEmpty(key)) + return (null, null); + string? lastValue = null; + string? lastDir = null; + foreach (var configPath in configFilePathsInOrder) + { + if (!File.Exists(configPath)) + continue; + var dir = Path.GetDirectoryName(configPath) ?? ""; + foreach (var line in File.ReadAllLines(configPath)) + { + var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line); + if (kv == null) + continue; + var (k, value) = kv.Value; + if (!k.Equals(key, StringComparison.OrdinalIgnoreCase)) + continue; + var trimmed = value.Trim(); + lastValue = trimmed; + lastDir = dir; + } + } + return (lastValue, lastDir); + } + + /// + /// Resolves a path value against the config file directory. If value is null/empty or already absolute, returns as-is (or null). + /// + public static string? ResolvePath(string? value, string? configFileDir) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + if (Path.IsPathRooted(value)) + return value; + if (string.IsNullOrEmpty(configFileDir)) + return Path.GetFullPath(value); + return Path.GetFullPath(Path.Combine(configFileDir, value)); + } + /// /// Reads the given config files in order and returns the last dhcp-leasefile= or dhcp-lease-file= path. - /// Relative paths are resolved against the config file's directory. Used so the app monitors the same leases file dnsmasq uses. + /// Both option names are the same in dnsmasq (last occurrence of either wins). Relative paths resolved against config file dir. /// public static string? GetDhcpLeaseFilePathFromConfigFiles(IReadOnlyList configFilePathsInOrder) { @@ -117,12 +190,19 @@ public static class DnsmasqConfIncludeParser continue; var path = value.Trim(); if (!string.IsNullOrEmpty(path)) - result = Path.GetFullPath(Path.Combine(dir, path)); + result = ResolvePath(path, dir) ?? result; } } return result; } + /// + /// Reads the given config files in order and returns true if no-hosts appears in any file. + /// When true, dnsmasq does not read /etc/hosts; only addn-hosts= files are used (if any). + /// + public static bool GetNoHostsFromConfigFiles(IReadOnlyList configFilePathsInOrder) => + GetFlagFromConfigFiles(configFilePathsInOrder, "no-hosts"); + /// /// Reads the given config files in order and returns all addn-hosts= paths (cumulative; dnsmasq loads each in order). /// Relative paths are resolved against the config file's directory. Used so the app can show which hosts files dnsmasq loads. diff --git a/src/DnsmasqWebUI/Program.cs b/src/DnsmasqWebUI/Program.cs index e4e8110..3850f84 100644 --- a/src/DnsmasqWebUI/Program.cs +++ b/src/DnsmasqWebUI/Program.cs @@ -1,38 +1,48 @@ using System.Net; using DnsmasqWebUI.Components; using DnsmasqWebUI.Extensions; +using DnsmasqWebUI.Http; +using DnsmasqWebUI.Http.Clients; using DnsmasqWebUI.Options; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Options; -// CreateBuilder(args) loads config in order: appsettings.json, appsettings.{Environment}.json, -// env vars; override via e.g. Dnsmasq__ReloadCommand= or ForwardedHeaders__Enabled=true. var builder = WebApplication.CreateBuilder(args); +// ---- Dnsmasq options (required paths validated at startup) ---- builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName)) .ValidateOnStart(); -builder.Services.AddSingleton, DnsmasqOptionsValidator>(); +builder.Services.AddSingleton, DnsmasqOptionsValidator>(); +// ---- Application services ---- builder.Services.AddApplicationServices(); - builder.Services.AddHttpContextAccessor(); -// Same-host HttpClient via IHttpClientFactory: relative URIs (e.g. /api/status) are rewritten to the -// current request's scheme/host/path base by SameHostBaseAddressHandler. Components keep @inject HttpClient. -// Options.DefaultName is the "default" client name (empty string) used when you inject HttpClient or call CreateClient() with no name. -builder.Services.AddHttpClient(Microsoft.Extensions.Options.Options.DefaultName) - .AddHttpMessageHandler(); -// Same app hosts both: -// - API: AddControllers() + MapControllers() → routes like /api/status, /api/hosts, /api/reload. -// - Blazor: AddRazorComponents + AddInteractiveServerComponents + MapRazorComponents() → pages like /, /hosts, /dhcp, /leases. +const string ApiClientName = "DnsmasqWebUI.Api"; +builder.Services.AddHttpClient(ApiClientName, client => +{ + client.BaseAddress = new Uri("http://localhost/", UriKind.Absolute); +}) +.AddHttpMessageHandler(); + +builder.Services.AddHttpClient(ApiClientName); +builder.Services.AddHttpClient(ApiClientName); +builder.Services.AddHttpClient(ApiClientName); +builder.Services.AddHttpClient(ApiClientName); +builder.Services.AddHttpClient(ApiClientName); +builder.Services.AddHttpClient(ApiClientName); + builder.Services.AddControllers() - .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter())); + .AddJsonOptions(o => + { + o.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + o.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); + }); builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); -// CORS: only added when Cors:Enabled is true. Configure origins/methods/headers in appsettings or -// env (e.g. Cors__Enabled=true, Cors__AllowedOrigins__0=https://app.example.com). +// ---- CORS (optional; Cors:Enabled in appsettings or env) ---- var corsEnabled = builder.Configuration.GetValue("Cors:Enabled"); if (corsEnabled) { @@ -64,34 +74,22 @@ if (corsEnabled) var app = builder.Build(); -// Trigger Dnsmasq options validation and print a clear error to stderr if it fails (before any stack trace). +// ---- Fail fast: validate Dnsmasq options and exit with clear message if invalid ---- try { _ = app.Services.GetRequiredService>().Value; } catch (OptionsValidationException ex) { - var err = Console.Error; - err.WriteLine(); - err.WriteLine("*** DNSMASQ-WEBUI CONFIGURATION ERROR ***"); - err.WriteLine(); - foreach (var failure in ex.Failures) - err.WriteLine(" • " + failure); - err.WriteLine(); - err.WriteLine("Fix the configuration (appsettings.json or Dnsmasq__* environment variables) and restart."); - err.WriteLine(); + WriteValidationFailure(ex); Environment.Exit(1); } +// ---- Middleware ---- if (!app.Environment.IsDevelopment()) app.UseExceptionHandler("/Error", createScopeForErrors: true); -// HTTPS: Kestrel can serve HTTPS via config (no code needed). Set ASPNETCORE_URLS=https://*:5001 and -// ASPNETCORE_Kestrel__Certificates__Default__Path (and __Password) for the cert. When behind a -// reverse proxy that terminates TLS, set ForwardedHeaders__Enabled=true so X-Forwarded-Proto is used; -// set Https__UseRedirectAndHsts=true to redirect HTTP→HTTPS and send HSTS. Both default false. -var forwardedEnabled = builder.Configuration.GetValue("ForwardedHeaders:Enabled"); -if (forwardedEnabled) +if (builder.Configuration.GetValue("ForwardedHeaders:Enabled")) { app.UseForwardedHeaders(new ForwardedHeadersOptions { @@ -107,17 +105,27 @@ if (builder.Configuration.GetValue("Https:UseRedirectAndHsts")) } if (corsEnabled) -{ - var policyName = builder.Configuration.GetValue("Cors:PolicyName") ?? "Default"; - app.UseCors(policyName); -} + app.UseCors(builder.Configuration.GetValue("Cors:PolicyName") ?? "Default"); app.UseAntiforgery(); -// Map API controllers first, then static assets, then Blazor (catch-all for SPA-style routes). +// ---- Endpoints ---- app.MapControllers(); app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); app.Run(); + +static void WriteValidationFailure(OptionsValidationException ex) +{ + var err = Console.Error; + err.WriteLine(); + err.WriteLine("*** DNSMASQ-WEBUI CONFIGURATION ERROR ***"); + err.WriteLine(); + foreach (var failure in ex.Failures) + err.WriteLine(" • " + failure); + err.WriteLine(); + err.WriteLine("Fix the configuration (appsettings.json or Dnsmasq__* environment variables) and restart."); + err.WriteLine(); +} diff --git a/src/DnsmasqWebUI/Services/Abstractions/IApplicationScopedConcrete.cs b/src/DnsmasqWebUI/Services/Abstractions/IApplicationScopedConcrete.cs new file mode 100644 index 0000000..ffe1125 --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/IApplicationScopedConcrete.cs @@ -0,0 +1,9 @@ +namespace DnsmasqWebUI.Services.Abstractions; + +/// +/// Marker for types that are registered as their own concrete type with scoped lifetime. +/// Used by assembly scanning (e.g. HTTP message handlers for HttpClient). +/// +public interface IApplicationScopedConcrete +{ +} diff --git a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs index 21dd4fa..b767323 100644 --- a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs +++ b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigService.cs @@ -1,4 +1,5 @@ using DnsmasqWebUI.Models; +using DnsmasqWebUI.Models.Config; namespace DnsmasqWebUI.Services.Abstractions; diff --git a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs index 7dcdeb4..920e101 100644 --- a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs +++ b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs @@ -10,6 +10,9 @@ public interface IDnsmasqConfigSetService : IApplicationSingleton /// Leases path discovered from the config set (dhcp-leasefile= / dhcp-lease-file=; last wins). Null if not found. string? GetLeasesPath(); + /// Effective hosts-related config after reading all config files: no-hosts flag and addn-hosts= paths (cumulative). + EffectiveDnsmasqConfig GetEffectiveConfig(); + /// Additional hosts paths discovered from the config set (addn-hosts=; cumulative, all in order). Empty if none. IReadOnlyList GetAddnHostsPaths(); } diff --git a/src/DnsmasqWebUI/Services/Abstractions/IProcessRunner.cs b/src/DnsmasqWebUI/Services/Abstractions/IProcessRunner.cs new file mode 100644 index 0000000..aaee890 --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/IProcessRunner.cs @@ -0,0 +1,8 @@ +namespace DnsmasqWebUI.Services.Abstractions; + +/// Runs a shell command with timeout and cancellation. Used for status, reload, status show, and logs commands. +public interface IProcessRunner : IApplicationScopedService +{ + /// Runs a shell command with timeout and cancellation. Returns exit code (null if timed out or failed to start), stdout, stderr, and whether the run timed out. + Task RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Services/Abstractions/ProcessRunResult.cs b/src/DnsmasqWebUI/Services/Abstractions/ProcessRunResult.cs new file mode 100644 index 0000000..9aef926 --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/ProcessRunResult.cs @@ -0,0 +1,15 @@ +namespace DnsmasqWebUI.Services.Abstractions; + +/// Result of running a shell command via . +/// Process exit code, or null if timed out or failed to start. +/// Standard output. +/// Standard error. +/// True if the command was killed due to timeout. +/// If run failed with an exception, the message; otherwise null. +public record ProcessRunResult( + int? ExitCode, + string Stdout, + string Stderr, + bool TimedOut, + string? ExceptionMessage = null +); diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs index e100cd5..8c24da7 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs @@ -1,5 +1,6 @@ using System.Text; using DnsmasqWebUI.Models; +using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Options; using DnsmasqWebUI.Parsers; using DnsmasqWebUI.Services.Abstractions; @@ -16,7 +17,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, IOptions options, ILogger logger) { _configSetService = configSetService; - _hostsPath = options.Value.HostsPath ?? ""; + _hostsPath = options.Value.SystemHostsPath?.Trim() ?? ""; _logger = logger; } @@ -67,7 +68,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService return "line:" + e.LineNumber; } - /// Ensures exactly one AddnHosts line with path = hostsPath. Replaces first AddnHosts or inserts at start. + /// When hostsPath is set, ensures the managed file has exactly one addn-hosts line pointing to it (replaces the first AddnHosts line in the list or inserts at start). So dnsmasq loads that file; other config files may have other addn-hosts lines. private static void EnsureOneAddnHostsLine(List configLines, string hostsPath) { if (string.IsNullOrEmpty(hostsPath)) diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs index b1ce5d5..70d8e8b 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs @@ -28,14 +28,96 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService return DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths); } - /// Additional hosts paths discovered from the config set (addn-hosts=; cumulative). Empty list if main config missing or no addn-hosts. - public IReadOnlyList GetAddnHostsPaths() + /// Effective config from the config set (single-value and flag options; last/any wins). + public EffectiveDnsmasqConfig GetEffectiveConfig() { var set = GetConfigSet(); if (set.Files.Count == 0) - return Array.Empty(); + return CreateDefaultEffectiveConfig(); var paths = set.Files.Select(f => f.Path).ToList(); - return DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths); + + var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths); + var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths); + + var expandHosts = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "expand-hosts"); + var bogusPriv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "bogus-priv"); + var strictOrder = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "strict-order"); + var noResolv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "no-resolv"); + var domainNeeded = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "domain-needed"); + var noPoll = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "no-poll"); + var bindInterfaces = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "bind-interfaces"); + var noNegcache = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "no-negcache"); + var dhcpAuthoritative = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "dhcp-authoritative"); + var leasefileRo = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "leasefile-ro"); + + var dhcpLeaseFilePath = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths); + + var (cacheVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "cache-size"); + var cacheSize = TryParseInt(cacheVal); + + var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "port"); + var port = TryParseInt(portVal); + + var (localTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "local-ttl"); + var localTtl = TryParseInt(localTtlVal); + + var (pidVal, pidDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "pid-file"); + var pidFilePath = DnsmasqConfIncludeParser.ResolvePath(pidVal, pidDir); + + var (userVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "user"); + var user = string.IsNullOrWhiteSpace(userVal) ? null : userVal.Trim(); + + var (groupVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "group"); + var group = string.IsNullOrWhiteSpace(groupVal) ? null : groupVal.Trim(); + + var (logFacVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "log-facility"); + var logFacility = string.IsNullOrWhiteSpace(logFacVal) ? null : logFacVal.Trim(); + + var (leaseMaxVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "dhcp-lease-max"); + var dhcpLeaseMax = TryParseInt(leaseMaxVal); + + var (negTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "neg-ttl"); + var negTtl = TryParseInt(negTtlVal); + + var (maxTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "max-ttl"); + var maxTtl = TryParseInt(maxTtlVal); + + var (maxCacheTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "max-cache-ttl"); + var maxCacheTtl = TryParseInt(maxCacheTtlVal); + + var (minCacheTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "min-cache-ttl"); + var minCacheTtl = TryParseInt(minCacheTtlVal); + + var (dhcpTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "dhcp-ttl"); + var dhcpTtl = TryParseInt(dhcpTtlVal); + + return new EffectiveDnsmasqConfig( + noHosts, addnHosts, + expandHosts, bogusPriv, strictOrder, noResolv, domainNeeded, noPoll, bindInterfaces, noNegcache, dhcpAuthoritative, leasefileRo, + dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, dhcpLeaseMax, + negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl + ); + } + + private static EffectiveDnsmasqConfig CreateDefaultEffectiveConfig() => + new( + NoHosts: false, AddnHostsPaths: Array.Empty(), + ExpandHosts: false, BogusPriv: false, StrictOrder: false, NoResolv: false, DomainNeeded: false, NoPoll: false, + BindInterfaces: false, NoNegcache: false, DhcpAuthoritative: false, LeasefileRo: false, + DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null, + LogFacility: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null + ); + + private static int? TryParseInt(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + return int.TryParse(value.Trim(), out var n) ? n : null; + } + + /// Additional hosts paths discovered from the config set (addn-hosts=; cumulative). Empty list if main config missing or no addn-hosts. + public IReadOnlyList GetAddnHostsPaths() + { + return GetEffectiveConfig().AddnHostsPaths; } private DnsmasqConfigSet GetConfigSet() diff --git a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs index dead341..2848397 100644 --- a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs +++ b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs @@ -1,5 +1,6 @@ using System.Text; using DnsmasqWebUI.Models; +using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Options; using DnsmasqWebUI.Services.Abstractions; using Microsoft.Extensions.Options; diff --git a/src/DnsmasqWebUI/Services/HostsFileService.cs b/src/DnsmasqWebUI/Services/HostsFileService.cs index 42a9e36..46c7a3d 100644 --- a/src/DnsmasqWebUI/Services/HostsFileService.cs +++ b/src/DnsmasqWebUI/Services/HostsFileService.cs @@ -14,12 +14,14 @@ public class HostsFileService : IHostsFileService public HostsFileService(IOptions options, ILogger logger) { - _path = options.Value.HostsPath; + _path = options.Value.SystemHostsPath?.Trim() ?? ""; _logger = logger; } public async Task> ReadAsync(CancellationToken ct = default) { + if (string.IsNullOrEmpty(_path)) + return Array.Empty(); if (!File.Exists(_path)) { _logger.LogWarning("Hosts file not found: {Path}", _path); @@ -46,6 +48,8 @@ public class HostsFileService : IHostsFileService public async Task WriteAsync(IReadOnlyList entries, CancellationToken ct = default) { + if (string.IsNullOrEmpty(_path)) + throw new InvalidOperationException("No system hosts file configured. Set Dnsmasq:SystemHostsPath to enable hosts editing."); var dir = Path.GetDirectoryName(_path); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); diff --git a/src/DnsmasqWebUI/Services/ProcessRunner.cs b/src/DnsmasqWebUI/Services/ProcessRunner.cs new file mode 100644 index 0000000..3c7436f --- /dev/null +++ b/src/DnsmasqWebUI/Services/ProcessRunner.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using System.Text; +using DnsmasqWebUI.Services.Abstractions; +using Microsoft.Extensions.Logging; + +namespace DnsmasqWebUI.Services; + +/// Runs shell commands via /bin/sh with async output capture and timeout. Used by StatusController and ReloadService. +public sealed class ProcessRunner : IProcessRunner +{ + private readonly ILogger _logger; + + public ProcessRunner(ILogger logger) => _logger = logger; + + public async Task RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(command)) + return new ProcessRunResult(null, "", "", false); + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "/bin/sh", + Arguments = "-c \"" + command.Replace("\"", "\\\"") + "\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + + try + { + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(timeout); + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + _logger.LogWarning("Command timed out after {Timeout}s", timeout.TotalSeconds); + try { process.Kill(); } catch { /* best effort */ } + var err = stderr.ToString(); + if (!string.IsNullOrEmpty(err)) err += "\n"; + err += $"Command timed out after {timeout.TotalSeconds} seconds."; + return new ProcessRunResult(null, stdout.ToString(), err, true); + } + + return new ProcessRunResult( + process.HasExited ? process.ExitCode : -1, + stdout.ToString(), + stderr.ToString(), + false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to run command"); + return new ProcessRunResult(null, "", "", false, ex.Message); + } + } +} diff --git a/src/DnsmasqWebUI/Services/ReloadService.cs b/src/DnsmasqWebUI/Services/ReloadService.cs index 16d1d01..d8f02b2 100644 --- a/src/DnsmasqWebUI/Services/ReloadService.cs +++ b/src/DnsmasqWebUI/Services/ReloadService.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using DnsmasqWebUI.Options; using DnsmasqWebUI.Services.Abstractions; using Microsoft.Extensions.Options; @@ -7,19 +6,21 @@ namespace DnsmasqWebUI.Services; public class ReloadService : IReloadService { - private readonly string? _command; + private readonly DnsmasqOptions _options; + private readonly IProcessRunner _processRunner; private readonly ILogger _logger; private readonly SemaphoreSlim _reloadLock = new(1, 1); - public ReloadService(IOptions options, ILogger logger) + public ReloadService(IProcessRunner processRunner, IOptions options, ILogger logger) { - _command = options.Value.ReloadCommand; + _processRunner = processRunner; + _options = options.Value; _logger = logger; } public async Task ReloadAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_command)) + if (string.IsNullOrWhiteSpace(_options.ReloadCommand)) { _logger.LogDebug("Reload command not configured"); return new ReloadResult(true, 0, null, "Reload command not configured"); @@ -33,47 +34,23 @@ public class ReloadService : IReloadService try { - using var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = "/bin/sh", - Arguments = "-c \"" + _command.Replace("\"", "\\\"") + "\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - process.Start(); - - var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); - var stderrTask = process.StandardError.ReadToEndAsync(ct); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); - - var timedOut = false; - try - { - await process.WaitForExitAsync(timeoutCts.Token); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) - { - timedOut = true; - _logger.LogWarning("Reload command timed out after 30 seconds"); - try { process.Kill(); } catch { /* best effort */ } - } - - var stdout = await stdoutTask; - var stderr = await stderrTask; - var exitCode = process.HasExited ? process.ExitCode : -1; - if (timedOut) + var result = await _processRunner.RunAsync(_options.ReloadCommand, TimeSpan.FromSeconds(30), ct); + var stderr = result.Stderr; + if (result.TimedOut) stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + "Reload command timed out after 30 seconds."; - if (exitCode != 0) - _logger.LogWarning("Reload command exited with {ExitCode}: {Stderr}", exitCode, stderr); - else + if (result.ExceptionMessage != null) + stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + result.ExceptionMessage; + + if (result.ExitCode != 0 && result.ExitCode.HasValue) + _logger.LogWarning("Reload command exited with {ExitCode}: {Stderr}", result.ExitCode.Value, stderr); + else if (result.ExitCode == 0) _logger.LogInformation("Reload command succeeded"); - return new ReloadResult(exitCode == 0, exitCode, stdout, stderr); + + return new ReloadResult( + result.ExitCode == 0, + result.ExitCode ?? -1, + result.Stdout, + stderr); } catch (Exception ex) { diff --git a/src/DnsmasqWebUI/appsettings.json b/src/DnsmasqWebUI/appsettings.json index 7328db6..c91ff3f 100644 --- a/src/DnsmasqWebUI/appsettings.json +++ b/src/DnsmasqWebUI/appsettings.json @@ -23,8 +23,10 @@ "Dnsmasq": { "MainConfigPath": "/etc/dnsmasq.conf", "ManagedFileName": "zz-dnsmasq-webui.conf", - "HostsPath": "/etc/hosts", + "SystemHostsPath": "/etc/hosts", "ReloadCommand": "systemctl reload dnsmasq", - "StatusCommand": "systemctl is-active dnsmasq" + "StatusCommand": "systemctl is-active dnsmasq", + "StatusShowCommand": "systemctl status dnsmasq --no-pager", + "LogsCommand": "journalctl -u dnsmasq -n 100 --no-pager" } } diff --git a/testdata/dnsmasq-test.conf b/testdata/dnsmasq-test.conf index 92663ad..540a6c4 100644 --- a/testdata/dnsmasq-test.conf +++ b/testdata/dnsmasq-test.conf @@ -7,3 +7,6 @@ dhcp-range=172.28.0.10,172.28.0.50,255.255.255.0,12h dhcp-leasefile=/data/leases conf-dir=/data/dnsmasq.d addn-hosts=/data/hosts +# Log to file so the harness can show real logs (LogsCommand tails this) +log-facility=/data/dnsmasq.log +log-queries