diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 580812a..2bf16f2 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,4 +1,5 @@ -# Run the app with real dnsmasq and a DHCP client so hosts/leases are real. +# Run the app with real dnsmasq and several DHCP clients so hosts/leases are real. +# Some clients also make periodic DNS requests so the DNS cache and logs show activity. # 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). # @@ -31,11 +32,58 @@ services: networks: testnet: ipv4_address: 172.28.0.2 - # no depends_on: app is the only service the client needs + # DHCP only; keeps running with lease. dhcp-client: image: alpine:3.20 - command: ["sh", "-c", "sleep 5 && udhcpc -i eth0 -f -n"] + command: ["sh", "-c", "sleep 5 && exec udhcpc -i eth0 -f -n"] + networks: + testnet: {} + depends_on: + - app + + # DHCP + periodic DNS lookups (every 20s) so DNS cache/logs show activity. + dhcp-client-dns-a: + image: alpine:3.20 + command: + - sh + - -c + - | + udhcpc -i eth0 -f -n & + sleep 10 + while true; do + nslookup google.com 2>/dev/null || true + nslookup cloudflare.com 2>/dev/null || true + sleep 20 + done + networks: + testnet: {} + depends_on: + - app + + # DHCP + periodic DNS lookups (every 45s); different interval for variety. + dhcp-client-dns-b: + image: alpine:3.20 + command: + - sh + - -c + - | + udhcpc -i eth0 -f -n & + sleep 10 + while true; do + nslookup example.com 2>/dev/null || true + nslookup github.com 2>/dev/null || true + sleep 45 + done + networks: + testnet: {} + depends_on: + - app + + # Third DHCP-only client so the leases table has more entries. + dhcp-client-2: + image: alpine:3.20 + command: ["sh", "-c", "sleep 8 && exec udhcpc -i eth0 -f -n"] networks: testnet: {} depends_on: diff --git a/scripts/dnsmasq-status.sh b/scripts/dnsmasq-status.sh index 3a0fb11..0f4f7cc 100755 --- a/scripts/dnsmasq-status.sh +++ b/scripts/dnsmasq-status.sh @@ -1,6 +1,6 @@ #!/bin/sh # Simulates "systemctl status dnsmasq" output when run in a container without systemd. -# Uses ps/pgrep. Memory is RSS; the time shown is process uptime (elapsed), not CPU usage. +# Uses ps/pgrep. Memory is RSS. Uptime line omitted; Active line has break after semicolon with continuation indented. pid=$(pgrep -x dnsmasq) if [ -z "$pid" ]; then @@ -49,10 +49,11 @@ function fmt_since(s, n, a, mon_num, day_pad) { rss_mb = (rss+0) / 1024 if (rss_mb < 0.1) rss_mb = 0.0 + # Active line: break after semicolon; continuation (e.g. "4 min ago") on next line, indented to align after "Active: " if (lstart != "") { since = fmt_since(lstart) ago = etime_ago(etime) - active_line = " Active: active (running) since " since "; " ago + active_line = " Active: active (running) since " since ";\n " ago } else { active_line = " Active: active (running) since container start" } @@ -62,7 +63,6 @@ function fmt_since(s, n, a, mon_num, day_pad) { printf "%s\n", active_line printf " Main PID: %s (dnsmasq)\n", pid printf " Memory: %.1fM\n", rss_mb - printf " Uptime: %s\n", etime printf " CGroup: (container)\n" printf " └─%s %s\n", pid, args }' diff --git a/src/DnsmasqWebUI/Client/Http/DhcpHostsClient.cs b/src/DnsmasqWebUI/Client/Http/DhcpHostsClient.cs index 6a5a8c1..aba7a57 100644 --- a/src/DnsmasqWebUI/Client/Http/DhcpHostsClient.cs +++ b/src/DnsmasqWebUI/Client/Http/DhcpHostsClient.cs @@ -1,6 +1,7 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; using DnsmasqWebUI.Client.Http.Abstractions; using DnsmasqWebUI.Models; -using System.Net.Http.Json; namespace DnsmasqWebUI.Client.Http; @@ -19,8 +20,25 @@ public sealed class DhcpHostsClient : IDhcpHostsClient public async Task SaveDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default) { var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ct); - response.EnsureSuccessStatusCode(); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(ct); + var msg = response.ReasonPhrase ?? response.StatusCode.ToString(); + if (!string.IsNullOrEmpty(body) && body.TrimStart().StartsWith("{")) + { + try + { + var err = System.Text.Json.JsonSerializer.Deserialize(body); + if (!string.IsNullOrEmpty(err?.Error)) + msg = err.Error; + } + catch { /* use msg as-is */ } + } + throw new HttpRequestException(msg); + } return await response.Content.ReadFromJsonAsync(ct) ?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts."); } + + private sealed class JsonError { [JsonPropertyName("error")] public string? Error { get; set; } } } diff --git a/src/DnsmasqWebUI/Client/Models/ClientSettings.cs b/src/DnsmasqWebUI/Client/Models/ClientSettings.cs index e49cb2f..6b4b59d 100644 --- a/src/DnsmasqWebUI/Client/Models/ClientSettings.cs +++ b/src/DnsmasqWebUI/Client/Models/ClientSettings.cs @@ -10,4 +10,7 @@ public class ClientSettings /// Polling interval for the recent logs block (seconds). public int RecentLogsPollingIntervalSeconds { get; set; } = 5; + + /// Polling interval for the DHCP leases list (seconds). + public int LeasesPollingIntervalSeconds { get; set; } = 5; } diff --git a/src/DnsmasqWebUI/Client/Models/SettingsModalContext.cs b/src/DnsmasqWebUI/Client/Models/SettingsModalContext.cs index 429bb8a..9dcd389 100644 --- a/src/DnsmasqWebUI/Client/Models/SettingsModalContext.cs +++ b/src/DnsmasqWebUI/Client/Models/SettingsModalContext.cs @@ -12,5 +12,8 @@ public enum SettingsModalContext ServicePolling, /// Show only recent logs polling interval. - LogsPolling + LogsPolling, + + /// Show only DHCP leases refresh interval. + LeasesPolling } diff --git a/src/DnsmasqWebUI/Components/App.razor b/src/DnsmasqWebUI/Components/App.razor index 20137ab..7530f58 100644 --- a/src/DnsmasqWebUI/Components/App.razor +++ b/src/DnsmasqWebUI/Components/App.razor @@ -1,3 +1,4 @@ +@inject IOptions AppOptions @@ -11,7 +12,7 @@ - Local DNS + @_appTitle @@ -21,3 +22,6 @@ +@code { + private string _appTitle => AppOptions!.Value!.EffectiveTitle; +} diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor index 11f54ce..f044b76 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor @@ -1,10 +1,11 @@ @namespace DnsmasqWebUI.Components.Layout +@inject IOptions AppOptions @@ -31,3 +32,6 @@ +@code { + private string AppTitle => AppOptions!.Value!.EffectiveTitle; +} diff --git a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor index 16448eb..5d608ba 100644 --- a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor +++ b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor @@ -38,6 +38,15 @@ @bind="_editingSettings.RecentLogsPollingIntervalSeconds" /> } + @if (ShouldShowSection("Leases")) + { +
+
DHCP leases
+ + +
+ } +
+ + + @if (_status.ReloadCommandConfigured) + { + + }
- - - @if (_status.ReloadCommandConfigured) - { - - } -

Managed file: @_status.ManagedFilePath

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

Leases

+
+

Leases

+ @if (_status.LeasesPathConfigured) + { + + } +
+ @if (_status.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath)) + { +

Path: @_status.LeasesPath (read-only).

+ } @if (!_status.LeasesPathConfigured) {

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

@@ -94,45 +251,157 @@ else 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
+
+ + + + + + @{ var leaseIndex = 0; } + @foreach (var e in _visibleLeases) + { + var idx = ++leaseIndex; + + + + + + + + + + } + +
#ExpiryMACAddressNameClient IDControl
@idx@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")@e.Mac@e.Address@e.Name@e.ClientId +
+ + +
+
+
+
+ @{ leaseIndex = 0; } + @foreach (var e in _visibleLeases) + { + var idx = ++leaseIndex; +
+
+
# @idx
+
Expiry @e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")
+
MAC @e.Mac
+
Address @e.Address
+
Name @e.Name
+
Client ID @e.ClientId
+
+ + +
+
+
+ } +
+
+
+
-

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

- } +
} @code { + private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; private List _entries = new(); private IReadOnlyList? _leases; + private string _filterKeyword = ""; + private string _leaseFilterKeyword = ""; private bool _leasesLoading; private string? _error; + private Timer? _leasesRefreshTimer; + private int _leasesRefreshIntervalSeconds = 5; private string? _message; + private bool _settingsModalOpen; + private string _settingsModalTitle = "Client settings"; + private SettingsModalContext _settingsModalContext = SettingsModalContext.All; private bool _saving; + private IEnumerable _visibleStaticEntries => + _entries.Where(x => !x.IsDeleted && StaticEntryMatchesKeyword(x, _filterKeyword)); + + private static bool IsEditable(DhcpHostEntry e) => e.IsEditable; + + private static bool StaticEntryMatchesKeyword(DhcpHostEntry e, string keyword) + { + if (string.IsNullOrWhiteSpace(keyword)) return true; + var k = keyword.Trim(); + var mac = string.Join(" ", e.MacAddresses); + return Contains(mac, k) || Contains(e.Name, k) || Contains(e.Address, k) || Contains(e.Lease, k) || Contains(e.Comment, k); + } + + private static bool Contains(string? value, string keyword) => + !string.IsNullOrEmpty(value) && value.Contains(keyword, StringComparison.OrdinalIgnoreCase); + + private IEnumerable _visibleLeases => + _leases?.Where(e => LeaseEntryMatchesKeyword(e, _leaseFilterKeyword)) ?? Array.Empty(); + + private static bool LeaseEntryMatchesKeyword(LeaseEntry e, string keyword) + { + if (string.IsNullOrWhiteSpace(keyword)) return true; + var k = keyword.Trim(); + return Contains(e.Mac, k) || Contains(e.Address, k) || Contains(e.Name, k) || Contains(e.ClientId, k); + } + + private static string LeaseKey(LeaseEntry e) => $"{e.Mac}|{e.Address}"; + + private void OnMacInput(DhcpHostEntry e, string? value) + { + SetMac(e, value); + TryFillFromLeaseByMac(e, value); + } + + private void TryFillFromLeaseByMac(DhcpHostEntry e, string? value) + { + if (string.IsNullOrWhiteSpace(value) || _leases == null) return; + var mac = value.Trim(); + if (mac.Contains(',')) return; + var lease = _visibleLeases.FirstOrDefault(l => string.Equals(l.Mac, mac, StringComparison.OrdinalIgnoreCase)); + if (lease != null) + { + e.Name = lease.Name ?? ""; + e.Address = lease.Address ?? ""; + e.Lease = "infinite"; + } + StateHasChanged(); + } + + private static readonly (string Value, string Label)[] LeaseDatalistOptions = + { + ("", "(none)"), + ("infinite", "Infinite"), + ("3600", "1 hour"), + ("21600", "6 hours"), + ("43200", "12 hours"), + ("86400", "24 hours"), + ("604800", "7 days"), + ("2592000", "30 days"), + }; + protected override async Task OnInitializedAsync() { await Load(); @@ -144,10 +413,14 @@ else _message = null; try { + var clientSettings = await ClientSettingsService.LoadSettingsAsync(); + _leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300); _status = await StatusClient.GetStatusAsync(); var entries = await DhcpHostsClient.GetDhcpHostsAsync(); _entries = entries?.ToList() ?? new List(); - await LoadLeases(); + await LoadLeases(showLoading: true); + if (_status != null && _status.LeasesPathConfigured && _leasesRefreshTimer == null) + StartLeasesRefreshTimer(); } catch (Exception ex) { @@ -155,10 +428,43 @@ else } } - private async Task LoadLeases() + private void OpenLeasesSettings() + { + _settingsModalContext = SettingsModalContext.LeasesPolling; + _settingsModalTitle = "Leases refresh interval"; + _settingsModalOpen = true; + } + + private async Task CloseSettingsModal() + { + _settingsModalOpen = false; + var clientSettings = await ClientSettingsService.LoadSettingsAsync(); + _leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300); + if (_status != null && _status.LeasesPathConfigured) + StartLeasesRefreshTimer(); + } + + private void StartLeasesRefreshTimer() + { + _leasesRefreshTimer?.Dispose(); + _leasesRefreshTimer = new Timer( + _ => _ = InvokeAsync(OnLeasesRefreshTick), + null, + TimeSpan.FromSeconds(_leasesRefreshIntervalSeconds), + TimeSpan.FromSeconds(_leasesRefreshIntervalSeconds)); + } + + private async Task OnLeasesRefreshTick() { if (_status == null || !_status.LeasesPathConfigured) return; - _leasesLoading = true; + await LoadLeases(showLoading: false); + await InvokeAsync(StateHasChanged); + } + + private async Task LoadLeases(bool showLoading = true) + { + if (_status == null || !_status.LeasesPathConfigured) return; + if (showLoading) _leasesLoading = true; try { var resp = await LeasesClient.GetLeasesAsync(); @@ -166,16 +472,70 @@ else } finally { - _leasesLoading = false; + if (showLoading) _leasesLoading = false; } } private void Add() { - _entries.Add(new DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, MacAddresses = new List(), Extra = new List() }); + var nextAddress = GetNextAvailableAddress(); + _entries.Add(new DhcpHostEntry + { + Id = "new:" + Guid.NewGuid().ToString("N"), + LineNumber = 0, + MacAddresses = new List(), + Extra = new List(), + Name = "*", + Address = nextAddress, + IsEditable = true + }); StateHasChanged(); } + private string? GetNextAvailableAddress() + { + if (_status == null || string.IsNullOrEmpty(_status.DhcpRangeStart) || string.IsNullOrEmpty(_status.DhcpRangeEnd)) + return null; + if (!System.Net.IPAddress.TryParse(_status.DhcpRangeStart, out var startIp) || startIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + return null; + if (!System.Net.IPAddress.TryParse(_status.DhcpRangeEnd, out var endIp) || endIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + return null; + var used = new HashSet(); + foreach (var e in _entries.Where(x => !x.IsDeleted && !string.IsNullOrEmpty(x.Address))) + { + if (System.Net.IPAddress.TryParse(e.Address, out var ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + used.Add(ToUint(ip)); + } + if (_leases != null) + { + foreach (var l in _leases) + { + if (!string.IsNullOrEmpty(l.Address) && System.Net.IPAddress.TryParse(l.Address, out var ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + used.Add(ToUint(ip)); + } + } + var start = ToUint(startIp); + var end = ToUint(endIp); + if (start > end) return null; + for (var u = start; u <= end; u++) + { + if (!used.Contains(u)) + return FromUint(u); + } + return null; + } + + private static uint ToUint(System.Net.IPAddress ip) + { + var b = ip.GetAddressBytes(); + return ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3]; + } + + private static string FromUint(uint u) + { + return $"{(u >> 24) & 0xff}.{(u >> 16) & 0xff}.{(u >> 8) & 0xff}.{u & 0xff}"; + } + private void Delete(DhcpHostEntry e) { e.IsDeleted = true; @@ -200,6 +560,35 @@ else StateHasChanged(); } + private void AddStaticFromLease(LeaseEntry lease) + { + AddStaticFromLeaseCore(lease, ignore: false); + } + + private void AddStaticFromLeaseWithIgnore(LeaseEntry lease) + { + AddStaticFromLeaseCore(lease, ignore: true); + } + + private void AddStaticFromLeaseCore(LeaseEntry lease, bool ignore) + { + _entries.Add(new DhcpHostEntry + { + Id = "new:" + Guid.NewGuid().ToString("N"), + LineNumber = 0, + IsComment = false, + IsDeleted = false, + Ignore = ignore, + MacAddresses = string.IsNullOrEmpty(lease.Mac) ? new List() : new List { lease.Mac }, + Name = lease.Name ?? "", + Address = lease.Address ?? "", + Lease = "infinite", + Extra = new List(), + Comment = null + }); + StateHasChanged(); + } + private async Task Save() { _saving = true; @@ -248,4 +637,10 @@ else _saving = false; } } + + public void Dispose() + { + _leasesRefreshTimer?.Dispose(); + _leasesRefreshTimer = null; + } } diff --git a/src/DnsmasqWebUI/Components/Pages/Error.razor b/src/DnsmasqWebUI/Components/Pages/Error.razor index 9d4ec7f..bb9a138 100644 --- a/src/DnsmasqWebUI/Components/Pages/Error.razor +++ b/src/DnsmasqWebUI/Components/Pages/Error.razor @@ -1,8 +1,9 @@ @page "/Error" @namespace DnsmasqWebUI.Components.Pages @using System.Diagnostics +@inject IOptions AppOptions -Error | Local DNS +Error | @AppTitle

Error

An error occurred while processing your request.

@@ -29,6 +30,7 @@ [CascadingParameter] private HttpContext? HttpContext { get; set; } + private string AppTitle => AppOptions!.Value!.EffectiveTitle; private string? RequestId { get; set; } private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); diff --git a/src/DnsmasqWebUI/Components/Pages/Home.razor b/src/DnsmasqWebUI/Components/Pages/Home.razor index fc8a34b..b06cbeb 100644 --- a/src/DnsmasqWebUI/Components/Pages/Home.razor +++ b/src/DnsmasqWebUI/Components/Pages/Home.razor @@ -5,9 +5,10 @@ @inject IConfigSetClient ConfigSetClient @inject IReloadClient ReloadClient @inject IClientSettingsService ClientSettingsService +@inject IOptions AppOptions @implements IDisposable -Overview | Local DNS +Overview | @AppTitle @if (_loading) { @@ -156,6 +157,7 @@ else if (_status != null) } @code { + private string AppTitle => AppOptions!.Value!.EffectiveTitle; private int _serviceStatusIntervalSeconds = 15; private int _recentLogsIntervalSeconds = 15; private bool _settingsModalOpen; diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor index 9b10b5e..19c55df 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor @@ -3,8 +3,8 @@ @namespace DnsmasqWebUI.Components.Pages @inject IStatusClient StatusClient @inject IHostsClient HostsClient - -Hosts | Local DNS +@inject IOptions AppOptions +Hosts | @AppTitle

Hosts file

@@ -50,27 +50,42 @@ else
@_message
}
- - - - - - @foreach (var e in _entries.Where(x => !x.IsPassthrough)) - { - - - - - - } - -
#AddressNames
@e.LineNumber@e.Address@string.Join(", ", e.Names)
+
+ + + + + + @foreach (var e in _entries.Where(x => !x.IsPassthrough)) + { + + + + + + } + +
#AddressNames
@e.LineNumber@e.Address@string.Join(", ", e.Names)
+
+
+ @foreach (var e in _entries.Where(x => !x.IsPassthrough)) + { +
+
+
# @e.LineNumber
+
Address @e.Address
+
Names @string.Join(", ", e.Names)
+
+
+ } +

Path: @_status.SystemHostsPath

} @code { + private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; private List _entries = new(); private string? _error; diff --git a/src/DnsmasqWebUI/Components/_Imports.razor b/src/DnsmasqWebUI/Components/_Imports.razor index e0e5d9a..e58d041 100644 --- a/src/DnsmasqWebUI/Components/_Imports.razor +++ b/src/DnsmasqWebUI/Components/_Imports.razor @@ -13,5 +13,7 @@ @using DnsmasqWebUI.Components.Modals @using DnsmasqWebUI.Components.Home @using DnsmasqWebUI.Models +@using DnsmasqWebUI.Configuration @using DnsmasqWebUI.Client.Http.Abstractions @using DnsmasqWebUI.Services.Abstractions +@using Microsoft.Extensions.Options diff --git a/src/DnsmasqWebUI/Configuration/ApplicationOptions.cs b/src/DnsmasqWebUI/Configuration/ApplicationOptions.cs new file mode 100644 index 0000000..4714f2a --- /dev/null +++ b/src/DnsmasqWebUI/Configuration/ApplicationOptions.cs @@ -0,0 +1,20 @@ +namespace DnsmasqWebUI.Configuration; + +/// +/// Application-level settings (title, etc.). Bound from "Application" config section +/// (appsettings.json or Application__* environment variables). +/// +public class ApplicationOptions +{ + /// Configuration section name (e.g. "Application" for appsettings and Application__* env vars). + public const string SectionName = "Application"; + + /// Default application title when not configured. + public const string DefaultTitle = "Local DNS"; + + /// Application title shown in the navbar brand, page titles, and browser tab. + public string ApplicationTitle { get; set; } = DefaultTitle; + + /// Title to use in UI (never null/empty). + public string EffectiveTitle => string.IsNullOrWhiteSpace(ApplicationTitle) ? DefaultTitle : ApplicationTitle; +} diff --git a/src/DnsmasqWebUI/Controllers/DhcpController.cs b/src/DnsmasqWebUI/Controllers/DhcpController.cs index 9a1eb52..c88e563 100644 --- a/src/DnsmasqWebUI/Controllers/DhcpController.cs +++ b/src/DnsmasqWebUI/Controllers/DhcpController.cs @@ -42,6 +42,10 @@ public class DhcpController : ControllerBase var reload = await _reloadService.ReloadAsync(ct); return Ok(new SaveWithReloadResult(true, reload)); } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } catch (Exception ex) { return StatusCode(500, new { error = ex.Message }); diff --git a/src/DnsmasqWebUI/Controllers/StatusController.cs b/src/DnsmasqWebUI/Controllers/StatusController.cs index 609938a..ba5654b 100644 --- a/src/DnsmasqWebUI/Controllers/StatusController.cs +++ b/src/DnsmasqWebUI/Controllers/StatusController.cs @@ -32,6 +32,7 @@ public class StatusController : ControllerBase var set = await _configSetService.GetConfigSetAsync(ct); var effectiveLeasesPath = _configSetService.GetLeasesPath(); var effectiveConfig = _configSetService.GetEffectiveConfig(); + var (dhcpRangeStart, dhcpRangeEnd) = _configSetService.GetDhcpRange(); var systemHostsPath = _options.SystemHostsPath?.Trim(); var statusResult = await _processRunner.RunAsync(_options.StatusCommand, TimeSpan.FromSeconds(5), ct); @@ -52,7 +53,7 @@ public class StatusController : ControllerBase var showResult = await showTask; var logsResult = await logsTask; var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand) - ? showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : "") + ? FormatStatusShowOutput(showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : "")) : null; var logsOutput = !string.IsNullOrWhiteSpace(_options.LogsCommand) ? logsResult.Stdout + (logsResult.TimedOut ? "\n(Command timed out.)" : "") @@ -83,7 +84,9 @@ public class StatusController : ControllerBase StatusCommandStdout: dnsmasqStatus != "active" ? statusCommandStdout : null, StatusCommandStderr: dnsmasqStatus != "active" ? statusCommandStderr : null, StatusShowOutput: statusShowOutput, - LogsOutput: logsOutput + LogsOutput: logsOutput, + DhcpRangeStart: dhcpRangeStart, + DhcpRangeEnd: dhcpRangeEnd ); return Ok(status); } @@ -93,6 +96,33 @@ public class StatusController : ControllerBase } } + /// + /// Reformats StatusShow output: Active line gets a line break after the semicolon; + /// continuation (e.g. "4 min ago") is indented to align with the value after "Active: ". + /// + private static string FormatStatusShowOutput(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return raw; + var lines = raw.Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var activeIdx = line.IndexOf("Active:", StringComparison.Ordinal); + if (activeIdx < 0) continue; + var semiIdx = line.IndexOf(';', activeIdx); + if (semiIdx < 0) continue; + var afterSemi = semiIdx + 1; + while (afterSemi < line.Length && line[afterSemi] == ' ') afterSemi++; + var continuation = afterSemi < line.Length ? line[afterSemi..].TrimEnd() : ""; + var indentLength = activeIdx + "Active: ".Length; + var indent = new string(' ', indentLength); + lines[i] = continuation.Length > 0 + ? line[..(semiIdx + 1)] + "\n" + indent + continuation + : line[..(semiIdx + 1)]; + } + return string.Join("\n", lines); + } + /// Returns the full log file from the path in effective config (log-facility). Untruncated. [HttpGet("logs/download")] public async Task GetLogsDownload(CancellationToken ct) diff --git a/src/DnsmasqWebUI/Models/DhcpHostEntry.cs b/src/DnsmasqWebUI/Models/DhcpHostEntry.cs index 20722fd..82ce104 100644 --- a/src/DnsmasqWebUI/Models/DhcpHostEntry.cs +++ b/src/DnsmasqWebUI/Models/DhcpHostEntry.cs @@ -17,4 +17,10 @@ public class DhcpHostEntry public string? Lease { get; set; } public List Extra { get; set; } = new(); public string? Comment { get; set; } + + /// True when this entry is from the managed file (editable). False when from main config or another file (read-only). Set by server on GET; new entries created in the UI should set this to true. + public bool IsEditable { get; set; } + + /// Path to the config file this entry comes from. Set by server on GET; null for new entries not yet saved. + public string? SourcePath { get; set; } } diff --git a/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs b/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs index 45e8f4e..5f67daf 100644 --- a/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs +++ b/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs @@ -29,6 +29,8 @@ namespace DnsmasqWebUI.Models; /// Stderr of StatusCommand when dnsmasq is not active. Null when active or not configured. /// Output of StatusShowCommand (full service status). Null when not configured or command produced no output. /// Output of LogsCommand (recent logs preview). Null when not configured or command produced no output. +/// Start IP of the first dhcp-range= (e.g. 172.28.0.10). Null when not set or unparseable. +/// End IP of the first dhcp-range= (e.g. 172.28.0.50). Null when not set or unparseable. public record DnsmasqServiceStatus( string? SystemHostsPath, bool SystemHostsPathExists, @@ -54,5 +56,7 @@ public record DnsmasqServiceStatus( string? StatusCommandStdout, string? StatusCommandStderr, string? StatusShowOutput, - string? LogsOutput + string? LogsOutput, + string? DhcpRangeStart, + string? DhcpRangeEnd ); diff --git a/src/DnsmasqWebUI/Program.cs b/src/DnsmasqWebUI/Program.cs index 669ee61..3474ea7 100644 --- a/src/DnsmasqWebUI/Program.cs +++ b/src/DnsmasqWebUI/Program.cs @@ -5,6 +5,10 @@ using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); +// ---- Application options (title, etc.) ---- +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ApplicationOptions.SectionName)); + // ---- Dnsmasq options (required paths validated at startup) ---- builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName)) diff --git a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs index 920e101..f9e2d7f 100644 --- a/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs +++ b/src/DnsmasqWebUI/Services/Abstractions/IDnsmasqConfigSetService.cs @@ -15,4 +15,7 @@ public interface IDnsmasqConfigSetService : IApplicationSingleton /// Additional hosts paths discovered from the config set (addn-hosts=; cumulative, all in order). Empty if none. IReadOnlyList GetAddnHostsPaths(); + + /// Start and end IP of the last dhcp-range= (e.g. 172.28.0.10,172.28.0.50). Parsed from the raw value; (null, null) when not set or unparseable. + (string? Start, string? End) GetDhcpRange(); } diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs index cc99371..7828ab3 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs @@ -29,22 +29,29 @@ public class DnsmasqConfigService : IDnsmasqConfigService public async Task> ReadDhcpHostsAsync(CancellationToken ct = default) { - var path = await GetManagedFilePathAsync(ct); - if (string.IsNullOrEmpty(path)) + var set = await _configSetService.GetConfigSetAsync(ct); + if (string.IsNullOrEmpty(set.ManagedFilePath)) { _logger.LogDebug("No managed file path (no conf-dir in main config); returning empty dhcp hosts"); return Array.Empty(); } - if (!File.Exists(path)) + var allEntries = new List(); + foreach (var file in set.Files) { - _logger.LogWarning("Managed config file not found: {Path}", path); - return Array.Empty(); + if (!File.Exists(file.Path)) + continue; + var lines = await File.ReadAllLinesAsync(file.Path, Encoding.UTF8, ct); + var configLines = DnsmasqConfFileLineParser.ParseFile(lines); + var entries = configLines.OfType().Select(c => c.DhcpHost).ToList(); + foreach (var e in entries) + { + e.SourcePath = file.Path; + e.IsEditable = file.IsManaged; + } + allEntries.AddRange(entries); } - var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct); - var configLines = DnsmasqConfFileLineParser.ParseFile(lines); - var entries = configLines.OfType().Select(c => c.DhcpHost).ToList(); - AssignStableIds(entries); - return entries; + AssignStableIds(allEntries); + return allEntries; } /// Assign stable Ids so we can match by Id on write (reorder-safe). Content-based; ":LineNumber" appended for uniqueness when needed. @@ -84,10 +91,23 @@ public class DnsmasqConfigService : IDnsmasqConfigService public async Task WriteDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default) { - var path = await GetManagedFilePathAsync(ct); + var set = await _configSetService.GetConfigSetAsync(ct); + var path = set.ManagedFilePath; if (string.IsNullOrEmpty(path)) throw new InvalidOperationException("No managed file path (main config has no conf-dir). Cannot write dhcp hosts."); + var managedEntries = entries.Where(e => e.IsEditable).ToList(); + var externalMacs = await GetMacsFromNonManagedFilesAsync(set, ct); + foreach (var e in managedEntries.Where(e => !e.IsDeleted)) + { + foreach (var mac in e.MacAddresses.Where(m => !string.IsNullOrWhiteSpace(m))) + { + var normalized = mac.Trim(); + if (externalMacs.TryGetValue(normalized, out var sourcePath)) + throw new ArgumentException($"MAC {mac} is already defined in {sourcePath}. Remove it from that file or use a different MAC."); + } + } + var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); @@ -103,7 +123,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService var fileEntries = configLines.OfType().Select(c => c.DhcpHost).ToList(); AssignStableIds(fileEntries); - var byId = entries.Where(e => !string.IsNullOrEmpty(e.Id)).GroupBy(e => e.Id, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + var byId = managedEntries.Where(e => !string.IsNullOrEmpty(e.Id)).GroupBy(e => e.Id, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); var matchedIds = new HashSet(StringComparer.Ordinal); for (var i = 0; i < configLines.Count; i++) @@ -114,7 +134,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService configLines[i] = new DhcpHostLine { LineNumber = dhcpLine.LineNumber, DhcpHost = replacement }; } - var appended = entries.Where(e => (string.IsNullOrEmpty(e.Id) || !matchedIds.Contains(e.Id)) && !e.IsDeleted).ToList(); + var appended = managedEntries.Where(e => (string.IsNullOrEmpty(e.Id) || !matchedIds.Contains(e.Id)) && !e.IsDeleted).ToList(); var output = configLines.Select(DnsmasqConfFileLineParser.ToLine).ToList(); foreach (var entry in appended) output.Add(DnsmasqConfDhcpHostLineParser.ToLine(entry)); @@ -125,6 +145,29 @@ public class DnsmasqConfigService : IDnsmasqConfigService _logger.LogInformation("Wrote managed config file: {Path}", path); } + /// Returns MAC -> source file path for all dhcp-host MACs in non-managed config files (for duplicate validation). + private static async Task> GetMacsFromNonManagedFilesAsync(DnsmasqConfigSet set, CancellationToken ct) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var managedPath = set.ManagedFilePath ?? ""; + foreach (var file in set.Files.Where(f => !f.IsManaged && !string.Equals(f.Path, managedPath, StringComparison.Ordinal))) + { + if (!File.Exists(file.Path)) continue; + var lines = await File.ReadAllLinesAsync(file.Path, Encoding.UTF8, ct); + var configLines = DnsmasqConfFileLineParser.ParseFile(lines); + foreach (var dhcp in configLines.OfType().Select(c => c.DhcpHost)) + { + foreach (var mac in dhcp.MacAddresses.Where(m => !string.IsNullOrWhiteSpace(m))) + { + var normalized = mac.Trim(); + if (normalized.Length > 0 && !result.ContainsKey(normalized)) + result[normalized] = file.Path; + } + } + } + return result; + } + public async Task ReadManagedConfigAsync(CancellationToken ct = default) { var path = await GetManagedFilePathAsync(ct); diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs index 6721d21..5082d4d 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs @@ -120,6 +120,36 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService return GetEffectiveConfig().AddnHostsPaths; } + /// + public (string? Start, string? End) GetDhcpRange() + { + var set = GetConfigSet(); + if (set.Files.Count == 0) + return (null, null); + var paths = set.Files.Select(f => f.Path).ToList(); + var (raw, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "dhcp-range"); + return ParseDhcpRangeStartEnd(raw); + } + + /// Parses dhcp-range value to (startIp, endIp). Format is typically start,end,mask,lease or tag:...,start,end,...; finds first two IPv4-looking tokens. + internal static (string? Start, string? End) ParseDhcpRangeStartEnd(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return (null, null); + var parts = raw.Split(','); + string? start = null; + string? end = null; + foreach (var p in parts) + { + var t = p.Trim(); + if (string.IsNullOrEmpty(t)) continue; + if (!System.Net.IPAddress.TryParse(t, out var ip) || ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + continue; + if (start == null) { start = t; continue; } + if (end == null) { end = t; break; } + } + return (start, end); + } + private DnsmasqConfigSet GetConfigSet() { var mainPath = _options.MainConfigPath; diff --git a/src/DnsmasqWebUI/appsettings.json b/src/DnsmasqWebUI/appsettings.json index c91ff3f..ee33647 100644 --- a/src/DnsmasqWebUI/appsettings.json +++ b/src/DnsmasqWebUI/appsettings.json @@ -1,4 +1,7 @@ { + "Application": { + "ApplicationTitle": "Local DNS" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/DnsmasqWebUI/wwwroot/app.css b/src/DnsmasqWebUI/wwwroot/app.css index 99ccc4c..0e3d22b 100644 --- a/src/DnsmasqWebUI/wwwroot/app.css +++ b/src/DnsmasqWebUI/wwwroot/app.css @@ -162,11 +162,49 @@ h1:focus { overflow-wrap: break-word; } +/* Table: desktop shows table, mobile shows card list (via d-none d-md-block / d-md-none in markup) */ .table-responsive-cards { overflow-x: auto; -webkit-overflow-scrolling: touch; } +.table-responsive-cards .d-none.d-md-block { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.table-cards-mobile .card .table-card-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + align-items: baseline; + font-size: 0.9375rem; +} + +.table-cards-mobile .card .table-card-row + .table-card-row { + margin-top: 0.25rem; +} + +.table-card-label { + font-size: 0.8125rem; + color: var(--bs-secondary); + min-width: 4.5rem; + flex-shrink: 0; +} + +.table-cards-mobile .table-card-row .form-control, +.table-cards-mobile .table-card-row .form-select, +.table-cards-mobile .table-card-row .table-card-control { + flex: 1; + min-width: 0; +} + +/* Align checkboxes with other inputs in card mode: control starts at same column */ +.table-cards-mobile .table-card-row .table-card-control { + display: flex; + align-items: center; +} + /* Settings gear icon size (Bootstrap Icons) */ .bi-gear-sm { font-size: 1.125rem; diff --git a/src/DnsmasqWebUI/wwwroot/js/settings-modal.js b/src/DnsmasqWebUI/wwwroot/js/settings-modal.js index d8956d3..109bf7e 100644 --- a/src/DnsmasqWebUI/wwwroot/js/settings-modal.js +++ b/src/DnsmasqWebUI/wwwroot/js/settings-modal.js @@ -7,7 +7,8 @@ export function initDialog(dialogElement, dotNetRef) { if (!dialogElement) return; dialogElement.addEventListener('close', function handler() { - dotNetRef.invokeMethodAsync('OnDialogClosed', dialogElement.returnValue ?? ''); + // Ignore errors on page refresh/unload when the Blazor circuit is already gone + dotNetRef.invokeMethodAsync('OnDialogClosed', dialogElement.returnValue ?? '').catch(() => {}); }); }