From a0863a663e46fed5f718e3a0c7544813eac72c04 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Mon, 2 Feb 2026 23:56:20 +1000 Subject: [PATCH] Hosts: managed file, read-only system/addn-hosts, HostsFileSection UI - Managed hosts file (zz-dnsmasq-webui.hosts): app reads/writes only this; addn-hosts in managed config loads it. SystemHostsPath is read-only display. - Hosts page: editable managed file; then 'Other hosts (read-only)' (system hosts when configured and !no-hosts, plus addn-hosts excluding managed). - GET api/hosts/readonly for read-only files; no-hosts hides system hosts. - HostsFileSection.razor component; fix space before read-only badge. - Test harness: show container /etc/hosts (default SystemHostsPath). - Config parsing/sources, tests and client updates for new hosts model. --- docker-compose.test.yml | 2 +- .../DnsmasqConfDhcpHostLineParserTests.cs | 2 +- .../DnsmasqConfFileLineParserTests.cs | 7 +- .../HostsFileLineParserTests.cs | 11 ++- .../Client/Http/Abstractions/IHostsClient.cs | 1 + src/DnsmasqWebUI/Client/Http/HostsClient.cs | 6 ++ .../Components/Hosts/HostsFileSection.razor | 51 ++++++++++ .../Components/Hosts/_Imports.razor | 1 + .../Components/Pages/Dnsmasq.razor | 6 +- src/DnsmasqWebUI/Components/Pages/Hosts.razor | 69 ++++--------- src/DnsmasqWebUI/Components/_Imports.razor | 1 + .../Configuration/DnsmasqOptions.cs | 5 +- .../Configuration/DnsmasqOptionsValidator.cs | 6 +- .../Controllers/HostsController.cs | 98 ++++++++++++++++--- .../Controllers/StatusController.cs | 2 + src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs | 3 +- .../Models/DnsmasqServiceStatus.cs | 6 +- src/DnsmasqWebUI/Models/ReadOnlyHostsFile.cs | 4 + .../Services/DnsmasqConfigService.cs | 34 ++++--- .../Services/DnsmasqConfigSetService.cs | 8 +- src/DnsmasqWebUI/Services/HostsFileService.cs | 32 +++--- src/DnsmasqWebUI/appsettings.json | 1 + 22 files changed, 249 insertions(+), 107 deletions(-) create mode 100644 src/DnsmasqWebUI/Components/Hosts/HostsFileSection.razor create mode 100644 src/DnsmasqWebUI/Components/Hosts/_Imports.razor create mode 100644 src/DnsmasqWebUI/Models/ReadOnlyHostsFile.cs diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 2bf16f2..2c6d6e0 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -21,7 +21,7 @@ services: DNSMASQ_CONF: /data/dnsmasq-test.conf Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf - Dnsmasq__SystemHostsPath: /data/hosts + # System hosts: app default /etc/hosts shows the container's hosts file (read-only). 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) diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs index 6e0c7cb..2664b37 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs @@ -177,7 +177,7 @@ public class DnsmasqConfDhcpHostLineParserTests } Assert.Single(dhcpEntries); Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpEntries[0].MacAddresses[0]); - Assert.Equal("192.168.1.10", dhcpEntries[0].Address); + Assert.Equal("172.28.0.100", dhcpEntries[0].Address); Assert.Equal("testpc", dhcpEntries[0].Name); Assert.Equal("infinite", dhcpEntries[0].Lease); } diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs index a926ea7..cf236bd 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs @@ -166,11 +166,12 @@ public class DnsmasqConfFileLineParserTests { var input = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf"); var lines = DnsmasqConfFileLineParser.ParseFile(input); - Assert.Equal(2, lines.Count); + Assert.Equal(3, lines.Count); Assert.IsType(lines[0]); - var dhcpLine = Assert.IsType(lines[1]); + Assert.IsType(lines[1]); + var dhcpLine = Assert.IsType(lines[2]); Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]); - Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address); + Assert.Equal("172.28.0.100", dhcpLine.DhcpHost.Address); Assert.Equal("testpc", dhcpLine.DhcpHost.Name); } diff --git a/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs b/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs index bb5b562..bacd01c 100644 --- a/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs +++ b/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs @@ -179,12 +179,21 @@ public class HostsFileLineParserTests entries.Add(HostsFileLineParser.ParseLine(lines[i], i + 1)); var dataEntries = entries.Where(e => e != null && !e.IsPassthrough).ToList(); - Assert.Equal(2, dataEntries.Count); + Assert.Equal(5, dataEntries.Count); Assert.Equal("127.0.0.1", dataEntries[0]!.Address); Assert.Single(dataEntries[0]!.Names); Assert.Equal("localhost", dataEntries[0]!.Names[0]); Assert.Equal("::1", dataEntries[1]!.Address); Assert.Single(dataEntries[1]!.Names); Assert.Equal("localhost", dataEntries[1]!.Names[0]); + Assert.Equal("192.168.1.1", dataEntries[2]!.Address); + Assert.Single(dataEntries[2]!.Names); + Assert.Equal("router", dataEntries[2]!.Names[0]); + Assert.Equal("192.168.1.10", dataEntries[3]!.Address); + Assert.Single(dataEntries[3]!.Names); + Assert.Equal("testpc", dataEntries[3]!.Names[0]); + Assert.Equal("172.28.0.2", dataEntries[4]!.Address); + Assert.Single(dataEntries[4]!.Names); + Assert.Equal("dnsmasq-webui", dataEntries[4]!.Names[0]); } } diff --git a/src/DnsmasqWebUI/Client/Http/Abstractions/IHostsClient.cs b/src/DnsmasqWebUI/Client/Http/Abstractions/IHostsClient.cs index 4dc5c5f..5886774 100644 --- a/src/DnsmasqWebUI/Client/Http/Abstractions/IHostsClient.cs +++ b/src/DnsmasqWebUI/Client/Http/Abstractions/IHostsClient.cs @@ -6,5 +6,6 @@ namespace DnsmasqWebUI.Client.Http.Abstractions; public interface IHostsClient { Task> GetHostsAsync(CancellationToken ct = default); + Task> GetReadOnlyHostsAsync(CancellationToken ct = default); Task SaveHostsAsync(IReadOnlyList entries, CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Client/Http/HostsClient.cs b/src/DnsmasqWebUI/Client/Http/HostsClient.cs index 49d9114..1d5c02c 100644 --- a/src/DnsmasqWebUI/Client/Http/HostsClient.cs +++ b/src/DnsmasqWebUI/Client/Http/HostsClient.cs @@ -16,6 +16,12 @@ public sealed class HostsClient : IHostsClient return list ?? new List(); } + public async Task> GetReadOnlyHostsAsync(CancellationToken ct = default) + { + var list = await _http.GetFromJsonAsync>("api/hosts/readonly", ct); + return list ?? new List(); + } + public async Task SaveHostsAsync(IReadOnlyList entries, CancellationToken ct = default) { var response = await _http.PutAsJsonAsync("api/hosts", entries, ct); diff --git a/src/DnsmasqWebUI/Components/Hosts/HostsFileSection.razor b/src/DnsmasqWebUI/Components/Hosts/HostsFileSection.razor new file mode 100644 index 0000000..223aaee --- /dev/null +++ b/src/DnsmasqWebUI/Components/Hosts/HostsFileSection.razor @@ -0,0 +1,51 @@ +
+

+ @Path + @if (IsEditable) + { + editable + } + else + { + read-only + } +

+
+
+ + + + + + @foreach (var e in (Entries ?? Array.Empty()).Where(x => !x.IsPassthrough)) + { + + + + + + } + +
#AddressNames
@e.LineNumber@e.Address@string.Join(", ", e.Names)
+
+
+ @foreach (var e in (Entries ?? Array.Empty()).Where(x => !x.IsPassthrough)) + { +
+
+
# @e.LineNumber
+
Address @e.Address
+
Names @string.Join(", ", e.Names)
+
+
+ } +
+
+
+ +@code { + [Parameter] public string Path { get; set; } = ""; + [Parameter] public IReadOnlyList? Entries { get; set; } + [Parameter] public bool IsEditable { get; set; } + [Parameter] public bool IsFirst { get; set; } +} diff --git a/src/DnsmasqWebUI/Components/Hosts/_Imports.razor b/src/DnsmasqWebUI/Components/Hosts/_Imports.razor new file mode 100644 index 0000000..9246dd6 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Hosts/_Imports.razor @@ -0,0 +1 @@ +@namespace DnsmasqWebUI.Components.Hosts diff --git a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor index 09aa284..cb95622 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor @@ -92,9 +92,13 @@ else if (_status != null) (none) } + @if (_status.ManagedHostsFilePath != null) + { +
  • Managed hosts file (editable): @_status.ManagedHostsFilePath @(_status.ManagedHostsPathExists ? "✓" : "(not created yet)")
  • + } @if (_status.SystemHostsPath != null) { -
  • System hosts file (editable): @_status.SystemHostsPath @(_status.SystemHostsPathExists ? "✓" : "(missing)")
  • +
  • System hosts (read-only): @_status.SystemHostsPath @(_status.SystemHostsPathExists ? "✓" : "(missing)")
  • } @if (_status.EffectiveConfig != null) diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor index b8ca5cf..1310192 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor @@ -15,26 +15,14 @@ Loading...

    } -else if (_status.NoHosts && !_pathInAddnHosts) +else if (string.IsNullOrEmpty(_status.ManagedHostsFilePath)) { -

    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: @RenderAddnHostsList()

    - } -} -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.

    +

    Hosts editing is unavailable: managed hosts path is not configured. Set Dnsmasq:MainConfigPath (and optionally Dnsmasq:ManagedHostsFileName) so the app can create and edit the managed hosts file.

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

    Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()

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

    System hosts file not found: @_status.SystemHostsPath

    -} else { @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") @@ -49,54 +37,31 @@ 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)) - { -
    -
    -
    # @e.LineNumber
    -
    Address @e.Address
    -
    Names @string.Join(", ", e.Names)
    -
    -
    - } -
    -
    -

    Path: @_status.SystemHostsPath

    + +

    Each hosts file below is rendered with the same component; only the managed file is editable. System hosts is excluded when no-hosts is set (dnsmasq does not read it then).

    + + +

    @_status.ManagedHostsFilePath @(!_status.ManagedHostsPathExists ? "(not created yet; will be created on first save)" : "")

    + + @if (_readOnlyHosts?.Count > 0) + { + @foreach (var ro in _readOnlyHosts) + { + + } + } } @code { private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; private List _entries = new(); + private IReadOnlyList? _readOnlyHosts; private string? _error; private string? _message; private bool _saving; - /// 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; - /// Returns MarkupString so Blazor renders raw HTML (path + source label). private MarkupString RenderAddnHostsList() { @@ -130,6 +95,7 @@ else _status = await StatusClient.GetStatusAsync(); var entries = await HostsClient.GetHostsAsync(); _entries = entries?.ToList() ?? new List(); + _readOnlyHosts = await HostsClient.GetReadOnlyHostsAsync(); } catch (Exception ex) { @@ -151,6 +117,7 @@ else _error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr; else _message = "Saved."; + _status = await StatusClient.GetStatusAsync(); } catch (Exception ex) { diff --git a/src/DnsmasqWebUI/Components/_Imports.razor b/src/DnsmasqWebUI/Components/_Imports.razor index c823be8..5bdbf7a 100644 --- a/src/DnsmasqWebUI/Components/_Imports.razor +++ b/src/DnsmasqWebUI/Components/_Imports.razor @@ -12,6 +12,7 @@ @using DnsmasqWebUI.Components @using DnsmasqWebUI.Components.Modals @using DnsmasqWebUI.Components.EffectiveConfig +@using DnsmasqWebUI.Components.Hosts @using DnsmasqWebUI.Components.Dnsmasq @using DnsmasqWebUI.Models @using DnsmasqWebUI.Configuration diff --git a/src/DnsmasqWebUI/Configuration/DnsmasqOptions.cs b/src/DnsmasqWebUI/Configuration/DnsmasqOptions.cs index da97008..05d5c58 100644 --- a/src/DnsmasqWebUI/Configuration/DnsmasqOptions.cs +++ b/src/DnsmasqWebUI/Configuration/DnsmasqOptions.cs @@ -23,7 +23,10 @@ public class DnsmasqOptions /// Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in the same directory as the main config and included only via a conf-file= directive as the last line of the main config. Managed file content parsed with DnsmasqConfFileLineParser. public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf"; - /// 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. + /// Filename of the managed hosts file (e.g. zz-dnsmasq-webui.hosts), created in the same directory as the main config. The app adds addn-hosts=<this path> in the managed config so dnsmasq loads it last. This is the only hosts file the app writes to. + public string ManagedHostsFileName { get; set; } = "zz-dnsmasq-webui.hosts"; + + /// Optional path to the system hosts file (e.g. /etc/hosts). When set, shown in the UI as read-only so users can see those entries. The app never writes to it; editing is via the managed hosts file only. When unset, the system hosts row is not shown. 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. diff --git a/src/DnsmasqWebUI/Configuration/DnsmasqOptionsValidator.cs b/src/DnsmasqWebUI/Configuration/DnsmasqOptionsValidator.cs index 3027af3..3c2a31e 100644 --- a/src/DnsmasqWebUI/Configuration/DnsmasqOptionsValidator.cs +++ b/src/DnsmasqWebUI/Configuration/DnsmasqOptionsValidator.cs @@ -28,9 +28,9 @@ public sealed class DnsmasqOptionsValidator : IValidateOptions } } - // 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). + // SystemHostsPath is optional. When set, the UI shows system hosts as read-only. The app only writes to the + // managed hosts file (ManagedHostsFileName in the same directory as main config). Hosts UI is available when + // MainConfigPath is set (managed hosts path is then derived from it and ManagedHostsFileName). if (failures.Count == 0) return ValidateOptionsResult.Success; diff --git a/src/DnsmasqWebUI/Controllers/HostsController.cs b/src/DnsmasqWebUI/Controllers/HostsController.cs index 3c50322..a2d6232 100644 --- a/src/DnsmasqWebUI/Controllers/HostsController.cs +++ b/src/DnsmasqWebUI/Controllers/HostsController.cs @@ -1,7 +1,10 @@ -using DnsmasqWebUI.Models; +using System.Text; using DnsmasqWebUI.Configuration; +using DnsmasqWebUI.Models; +using DnsmasqWebUI.Parsers; using DnsmasqWebUI.Services.Abstractions; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; namespace DnsmasqWebUI.Controllers; @@ -14,11 +17,7 @@ public class HostsController : ControllerBase private readonly IDnsmasqConfigSetService _configSetService; private readonly DnsmasqOptions _options; - public HostsController( - IHostsFileService hostsService, - IReloadService reloadService, - IDnsmasqConfigSetService configSetService, - Microsoft.Extensions.Options.IOptions options) + public HostsController(IHostsFileService hostsService, IReloadService reloadService, IDnsmasqConfigSetService configSetService, IOptions options) { _hostsService = hostsService; _reloadService = reloadService; @@ -40,25 +39,96 @@ public class HostsController : ControllerBase } } + /// Returns entries from each read-only hosts file: system hosts (when SystemHostsPath set and no-hosts is false), then addn-hosts that are not the managed file. System hosts is excluded when no-hosts is set because dnsmasq does not read it then. + [HttpGet("readonly")] + public async Task>> GetReadOnly(CancellationToken ct) + { + try + { + var set = await _configSetService.GetConfigSetAsync(ct); + var effectiveConfig = _configSetService.GetEffectiveConfig(); + var addnPaths = effectiveConfig.AddnHostsPaths ?? Array.Empty(); + var managedPath = set.ManagedHostsFilePath != null ? Path.GetFullPath(set.ManagedHostsFilePath) : null; + + var result = new List(); + + // System hosts: only when configured and no-hosts is false (dnsmasq reads it then). + var systemPath = _options.SystemHostsPath?.Trim(); + if (!string.IsNullOrEmpty(systemPath) && !effectiveConfig.NoHosts) + { + var fullPath = Path.GetFullPath(systemPath); + if (System.IO.File.Exists(fullPath)) + { + try + { + var lines = await System.IO.File.ReadAllLinesAsync(fullPath, Encoding.UTF8, ct); + var entries = new List(); + for (var i = 0; i < lines.Length; i++) + { + var entry = HostsFileLineParser.ParseLine(lines[i], i + 1); + if (entry != null) + entries.Add(entry); + } + result.Add(new ReadOnlyHostsFile(fullPath, entries)); + } + catch + { + // Skip unreadable + } + } + } + + var systemPathFull = !string.IsNullOrEmpty(systemPath) ? Path.GetFullPath(systemPath) : null; + + foreach (var p in addnPaths) + { + var fullPath = Path.GetFullPath(p); + if (managedPath != null && string.Equals(fullPath, managedPath, StringComparison.Ordinal)) + continue; + if (systemPathFull != null && string.Equals(fullPath, systemPathFull, StringComparison.Ordinal)) + continue; + if (!System.IO.File.Exists(fullPath)) + continue; + try + { + var lines = await System.IO.File.ReadAllLinesAsync(fullPath, Encoding.UTF8, ct); + var entries = new List(); + for (var i = 0; i < lines.Length; i++) + { + var entry = HostsFileLineParser.ParseLine(lines[i], i + 1); + if (entry != null) + entries.Add(entry); + } + result.Add(new ReadOnlyHostsFile(fullPath, entries)); + } + catch + { + // Skip unreadable files + } + } + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(500, new { error = ex.Message }); + } + } + [HttpPut] 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 SaveWithReloadResult(true, reload)); } + catch (InvalidOperationException 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 94c71ec..9fff292 100644 --- a/src/DnsmasqWebUI/Controllers/StatusController.cs +++ b/src/DnsmasqWebUI/Controllers/StatusController.cs @@ -62,6 +62,8 @@ public class StatusController : ControllerBase var status = new DnsmasqServiceStatus( SystemHostsPath: string.IsNullOrEmpty(systemHostsPath) ? null : systemHostsPath, SystemHostsPathExists: !string.IsNullOrEmpty(systemHostsPath) && System.IO.File.Exists(systemHostsPath), + ManagedHostsFilePath: set.ManagedHostsFilePath, + ManagedHostsPathExists: !string.IsNullOrEmpty(set.ManagedHostsFilePath) && System.IO.File.Exists(set.ManagedHostsFilePath), NoHosts: effectiveConfig.NoHosts, AddnHostsPaths: effectiveConfig.AddnHostsPaths, EffectiveConfig: effectiveConfig, diff --git a/src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs b/src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs index 0624fec..97776ff 100644 --- a/src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs +++ b/src/DnsmasqWebUI/Models/DnsmasqConfigSet.cs @@ -1,8 +1,9 @@ namespace DnsmasqWebUI.Models; -/// Ordered set of dnsmasq config files (main + conf-file + conf-dir). ManagedFilePath is the single file we read/write. +/// Ordered set of dnsmasq config files (main + conf-file + conf-dir). ManagedFilePath is the single config file we read/write; ManagedHostsFilePath is the single hosts file we read/write. public record DnsmasqConfigSet( string MainConfigPath, string ManagedFilePath, + string? ManagedHostsFilePath, IReadOnlyList Files ); diff --git a/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs b/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs index 0da0345..0700129 100644 --- a/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs +++ b/src/DnsmasqWebUI/Models/DnsmasqServiceStatus.cs @@ -4,8 +4,10 @@ namespace DnsmasqWebUI.Models; /// Dnsmasq service and config status returned by GET api/status. /// Includes paths from config, command configuration flags, and live output from status/logs commands. /// -/// Path to the hosts file the app can edit (e.g. /etc/hosts). Null when hosts UI is disabled. +/// Optional path to the system hosts file (e.g. /etc/hosts). When set, shown in UI as read-only. Null when not configured. /// True if is set and the file exists on disk. +/// Path to the app's managed hosts file (the only hosts file the app writes to). Null when main config path is empty. +/// True if is set and the file exists on disk. /// True when dnsmasq has no-hosts set (hosts files disabled). /// Effective addn-hosts paths dnsmasq loads. Empty when none configured. /// Effective dnsmasq config (single-value, flags, multi-value) after parsing all config files. @@ -35,6 +37,8 @@ namespace DnsmasqWebUI.Models; public record DnsmasqServiceStatus( string? SystemHostsPath, bool SystemHostsPathExists, + string? ManagedHostsFilePath, + bool ManagedHostsPathExists, bool NoHosts, IReadOnlyList AddnHostsPaths, EffectiveDnsmasqConfig? EffectiveConfig, diff --git a/src/DnsmasqWebUI/Models/ReadOnlyHostsFile.cs b/src/DnsmasqWebUI/Models/ReadOnlyHostsFile.cs new file mode 100644 index 0000000..3f065d5 --- /dev/null +++ b/src/DnsmasqWebUI/Models/ReadOnlyHostsFile.cs @@ -0,0 +1,4 @@ +namespace DnsmasqWebUI.Models; + +/// Path and parsed entries for a read-only addn-hosts file (not the managed hosts file). +public record ReadOnlyHostsFile(string Path, IReadOnlyList Entries); diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs index 7828ab3..c710b11 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs @@ -1,23 +1,19 @@ using System.Text; using DnsmasqWebUI.Models; using DnsmasqWebUI.Models.Config; -using DnsmasqWebUI.Configuration; using DnsmasqWebUI.Parsers; using DnsmasqWebUI.Services.Abstractions; -using Microsoft.Extensions.Options; namespace DnsmasqWebUI.Services; public class DnsmasqConfigService : IDnsmasqConfigService { private readonly IDnsmasqConfigSetService _configSetService; - private readonly string _hostsPath; private readonly ILogger _logger; - public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, IOptions options, ILogger logger) + public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, ILogger logger) { _configSetService = configSetService; - _hostsPath = options.Value.SystemHostsPath?.Trim() ?? ""; _logger = logger; } @@ -75,20 +71,31 @@ public class DnsmasqConfigService : IDnsmasqConfigService return "line:" + e.LineNumber; } - /// 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) + /// When managedHostsPath is set, ensures the managed config has exactly one addn-hosts line pointing to it (replaces the first AddnHosts line or inserts at start). So dnsmasq loads our managed hosts file last. + private static void EnsureOneAddnHostsLine(List configLines, string? managedHostsPath) { - if (string.IsNullOrEmpty(hostsPath)) + if (string.IsNullOrEmpty(managedHostsPath)) return; var idx = configLines.FindIndex(c => c.Kind == DnsmasqConfLineKind.AddnHosts); var lineNumber = idx >= 0 ? configLines[idx].LineNumber : 1; - var line = new AddnHostsLine { LineNumber = lineNumber, AddnHostsPath = hostsPath }; + var line = new AddnHostsLine { LineNumber = lineNumber, AddnHostsPath = managedHostsPath }; if (idx >= 0) configLines[idx] = line; else configLines.Insert(0, line); } + /// Creates the managed hosts file empty if it does not exist, so dnsmasq does not error when we add addn-hosts=<path> to the managed config. + private static void EnsureManagedHostsFileExists(string? managedHostsPath) + { + if (string.IsNullOrEmpty(managedHostsPath) || File.Exists(managedHostsPath)) + return; + var dir = Path.GetDirectoryName(managedHostsPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(managedHostsPath, ""); + } + public async Task WriteDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default) { var set = await _configSetService.GetConfigSetAsync(ct); @@ -119,7 +126,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService rawLines = Array.Empty(); var configLines = DnsmasqConfFileLineParser.ParseFile(rawLines).ToList(); - EnsureOneAddnHostsLine(configLines, _hostsPath); + EnsureOneAddnHostsLine(configLines, set.ManagedHostsFilePath); var fileEntries = configLines.OfType().Select(c => c.DhcpHost).ToList(); AssignStableIds(fileEntries); @@ -142,6 +149,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService var tmpPath = path + ".tmp"; await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct); File.Move(tmpPath, path, overwrite: true); + EnsureManagedHostsFileExists(set.ManagedHostsFilePath); _logger.LogInformation("Wrote managed config file: {Path}", path); } @@ -188,7 +196,8 @@ public class DnsmasqConfigService : IDnsmasqConfigService public async Task WriteManagedConfigAsync(IReadOnlyList lines, 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 managed config."); @@ -197,12 +206,13 @@ public class DnsmasqConfigService : IDnsmasqConfigService Directory.CreateDirectory(dir); var list = lines.ToList(); - EnsureOneAddnHostsLine(list, _hostsPath); + EnsureOneAddnHostsLine(list, set.ManagedHostsFilePath); var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList(); var tmpPath = path + ".tmp"; await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct); File.Move(tmpPath, path, overwrite: true); + EnsureManagedHostsFileExists(set.ManagedHostsFilePath); _logger.LogInformation("Wrote managed config file: {Path}", path); } } diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs index 0a47387..3419394 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs @@ -251,11 +251,15 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService { var mainPath = _options.MainConfigPath; if (string.IsNullOrEmpty(mainPath)) - return new DnsmasqConfigSet("", "", Array.Empty()); + return new DnsmasqConfigSet("", "", null, Array.Empty()); var mainFull = Path.GetFullPath(mainPath); var mainDir = Path.GetDirectoryName(mainFull) ?? ""; var managedFilePath = Path.Combine(mainDir, _options.ManagedFileName); + var managedHostsFileName = _options.ManagedHostsFileName?.Trim(); + if (string.IsNullOrEmpty(managedHostsFileName)) + managedHostsFileName = "zz-dnsmasq-webui.hosts"; + var managedHostsFilePath = Path.Combine(mainDir, managedHostsFileName); var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(mainPath); var files = withSource.Select(p => new DnsmasqConfigSetEntry( @@ -268,6 +272,6 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService if (files.All(e => !string.Equals(e.Path, managedFilePath, StringComparison.Ordinal))) files.Add(new DnsmasqConfigSetEntry(managedFilePath, Path.GetFileName(managedFilePath), DnsmasqConfFileSource.ConfFile, IsManaged: true)); - return new DnsmasqConfigSet(mainFull, managedFilePath, files); + return new DnsmasqConfigSet(mainFull, managedFilePath, managedHostsFilePath, files); } } diff --git a/src/DnsmasqWebUI/Services/HostsFileService.cs b/src/DnsmasqWebUI/Services/HostsFileService.cs index 86737f1..7ad69fe 100644 --- a/src/DnsmasqWebUI/Services/HostsFileService.cs +++ b/src/DnsmasqWebUI/Services/HostsFileService.cs @@ -1,33 +1,33 @@ using System.Text; using DnsmasqWebUI.Models; -using DnsmasqWebUI.Configuration; using DnsmasqWebUI.Parsers; using DnsmasqWebUI.Services.Abstractions; -using Microsoft.Extensions.Options; namespace DnsmasqWebUI.Services; public class HostsFileService : IHostsFileService { - private readonly string _path; + private readonly IDnsmasqConfigSetService _configSetService; private readonly ILogger _logger; - public HostsFileService(IOptions options, ILogger logger) + public HostsFileService(IDnsmasqConfigSetService configSetService, ILogger logger) { - _path = options.Value.SystemHostsPath?.Trim() ?? ""; + _configSetService = configSetService; _logger = logger; } public async Task> ReadAsync(CancellationToken ct = default) { - if (string.IsNullOrEmpty(_path)) + var set = await _configSetService.GetConfigSetAsync(ct); + var path = set.ManagedHostsFilePath; + if (string.IsNullOrEmpty(path)) return Array.Empty(); - if (!File.Exists(_path)) + if (!File.Exists(path)) { - _logger.LogWarning("Hosts file not found: {Path}", _path); + _logger.LogDebug("Managed hosts file not found: {Path}", path); return Array.Empty(); } - var lines = await File.ReadAllLinesAsync(_path, Encoding.UTF8, ct); + var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct); var entries = new List(); var seenContentIds = new HashSet(StringComparer.Ordinal); for (var i = 0; i < lines.Length; i++) @@ -48,16 +48,18 @@ 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); + var set = await _configSetService.GetConfigSetAsync(ct); + var path = set.ManagedHostsFilePath; + if (string.IsNullOrEmpty(path)) + throw new InvalidOperationException("No managed hosts path configured. Set Dnsmasq:MainConfigPath and Dnsmasq:ManagedHostsFileName to enable hosts editing."); + var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - var tmpPath = _path + ".tmp"; + var tmpPath = path + ".tmp"; var lines = entries.Select(HostsFileLineParser.ToLine).ToList(); await File.WriteAllLinesAsync(tmpPath, lines, Encoding.UTF8, ct); - File.Move(tmpPath, _path, overwrite: true); - _logger.LogInformation("Wrote hosts file: {Path}", _path); + File.Move(tmpPath, path, overwrite: true); + _logger.LogInformation("Wrote managed hosts file: {Path}", path); } } diff --git a/src/DnsmasqWebUI/appsettings.json b/src/DnsmasqWebUI/appsettings.json index ee33647..e43a338 100644 --- a/src/DnsmasqWebUI/appsettings.json +++ b/src/DnsmasqWebUI/appsettings.json @@ -26,6 +26,7 @@ "Dnsmasq": { "MainConfigPath": "/etc/dnsmasq.conf", "ManagedFileName": "zz-dnsmasq-webui.conf", + "ManagedHostsFileName": "zz-dnsmasq-webui.hosts", "SystemHostsPath": "/etc/hosts", "ReloadCommand": "systemctl reload dnsmasq", "StatusCommand": "systemctl is-active dnsmasq",