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.
This commit is contained in:
2026-02-02 23:56:20 +10:00
parent 888bc980cf
commit a0863a663e
22 changed files with 249 additions and 107 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ services:
DNSMASQ_CONF: /data/dnsmasq-test.conf DNSMASQ_CONF: /data/dnsmasq-test.conf
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.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__ReloadCommand: "pkill -HUP -x dnsmasq"
Dnsmasq__StatusCommand: "pgrep -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) # Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
@@ -177,7 +177,7 @@ public class DnsmasqConfDhcpHostLineParserTests
} }
Assert.Single(dhcpEntries); Assert.Single(dhcpEntries);
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpEntries[0].MacAddresses[0]); 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("testpc", dhcpEntries[0].Name);
Assert.Equal("infinite", dhcpEntries[0].Lease); Assert.Equal("infinite", dhcpEntries[0].Lease);
} }
@@ -166,11 +166,12 @@ public class DnsmasqConfFileLineParserTests
{ {
var input = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf"); var input = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf");
var lines = DnsmasqConfFileLineParser.ParseFile(input); var lines = DnsmasqConfFileLineParser.ParseFile(input);
Assert.Equal(2, lines.Count); Assert.Equal(3, lines.Count);
Assert.IsType<CommentLine>(lines[0]); Assert.IsType<CommentLine>(lines[0]);
var dhcpLine = Assert.IsType<DhcpHostLine>(lines[1]); Assert.IsType<CommentLine>(lines[1]);
var dhcpLine = Assert.IsType<DhcpHostLine>(lines[2]);
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]); 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); Assert.Equal("testpc", dhcpLine.DhcpHost.Name);
} }
@@ -179,12 +179,21 @@ public class HostsFileLineParserTests
entries.Add(HostsFileLineParser.ParseLine(lines[i], i + 1)); entries.Add(HostsFileLineParser.ParseLine(lines[i], i + 1));
var dataEntries = entries.Where(e => e != null && !e.IsPassthrough).ToList(); 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.Equal("127.0.0.1", dataEntries[0]!.Address);
Assert.Single(dataEntries[0]!.Names); Assert.Single(dataEntries[0]!.Names);
Assert.Equal("localhost", dataEntries[0]!.Names[0]); Assert.Equal("localhost", dataEntries[0]!.Names[0]);
Assert.Equal("::1", dataEntries[1]!.Address); Assert.Equal("::1", dataEntries[1]!.Address);
Assert.Single(dataEntries[1]!.Names); Assert.Single(dataEntries[1]!.Names);
Assert.Equal("localhost", dataEntries[1]!.Names[0]); 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]);
} }
} }
@@ -6,5 +6,6 @@ namespace DnsmasqWebUI.Client.Http.Abstractions;
public interface IHostsClient public interface IHostsClient
{ {
Task<IReadOnlyList<HostEntry>> GetHostsAsync(CancellationToken ct = default); Task<IReadOnlyList<HostEntry>> GetHostsAsync(CancellationToken ct = default);
Task<IReadOnlyList<ReadOnlyHostsFile>> GetReadOnlyHostsAsync(CancellationToken ct = default);
Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default); Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default);
} }
@@ -16,6 +16,12 @@ public sealed class HostsClient : IHostsClient
return list ?? new List<HostEntry>(); return list ?? new List<HostEntry>();
} }
public async Task<IReadOnlyList<ReadOnlyHostsFile>> GetReadOnlyHostsAsync(CancellationToken ct = default)
{
var list = await _http.GetFromJsonAsync<List<ReadOnlyHostsFile>>("api/hosts/readonly", ct);
return list ?? new List<ReadOnlyHostsFile>();
}
public async Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default) public async Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
{ {
var response = await _http.PutAsJsonAsync("api/hosts", entries, ct); var response = await _http.PutAsJsonAsync("api/hosts", entries, ct);
@@ -0,0 +1,51 @@
<div class="hosts-file-section">
<h3 class="h6 @(IsFirst ? "mb-1" : "mt-3 mb-1") @(IsEditable ? "" : "text-muted")">
@Path
@if (IsEditable)
{
<span class="badge bg-primary ms-1">editable</span>
}
else
{
<span class="badge bg-secondary ms-1"> read-only</span>
}
</h3>
<div class="table-responsive-cards">
<div class="d-none d-md-block">
<table class="table table-bordered @(IsEditable ? "" : "table-sm")">
<thead>
<tr><th>#</th><th>Address</th><th>Names</th></tr>
</thead>
<tbody>
@foreach (var e in (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough))
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
<td>@e.LineNumber</td>
<td>@e.Address</td>
<td>@string.Join(", ", e.Names)</td>
</tr>
}
</tbody>
</table>
</div>
<div class="d-md-none table-cards-mobile">
@foreach (var e in (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough))
{
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
<div class="card-body py-2">
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
<div class="table-card-row"><span class="table-card-label">Address</span> @e.Address</div>
<div class="table-card-row"><span class="table-card-label">Names</span> @string.Join(", ", e.Names)</div>
</div>
</div>
}
</div>
</div>
</div>
@code {
[Parameter] public string Path { get; set; } = "";
[Parameter] public IReadOnlyList<HostEntry>? Entries { get; set; }
[Parameter] public bool IsEditable { get; set; }
[Parameter] public bool IsFirst { get; set; }
}
@@ -0,0 +1 @@
@namespace DnsmasqWebUI.Components.Hosts
@@ -92,9 +92,13 @@ else if (_status != null)
<span class="text-muted">(none)</span> <span class="text-muted">(none)</span>
} }
</li> </li>
@if (_status.ManagedHostsFilePath != null)
{
<li><strong>Managed hosts file (editable):</strong> @_status.ManagedHostsFilePath @(_status.ManagedHostsPathExists ? "✓" : "(not created yet)")</li>
}
@if (_status.SystemHostsPath != null) @if (_status.SystemHostsPath != null)
{ {
<li><strong>System hosts file (editable):</strong> @_status.SystemHostsPath @(_status.SystemHostsPathExists ? "✓" : "(missing)")</li> <li><strong>System hosts (read-only):</strong> @_status.SystemHostsPath @(_status.SystemHostsPathExists ? "✓" : "(missing)")</li>
} }
</ul> </ul>
@if (_status.EffectiveConfig != null) @if (_status.EffectiveConfig != null)
+18 -51
View File
@@ -15,26 +15,14 @@
Loading... Loading...
</p> </p>
} }
else if (_status.NoHosts && !_pathInAddnHosts) else if (string.IsNullOrEmpty(_status.ManagedHostsFilePath))
{ {
<p class="text-muted">Hosts editing is disabled: <strong>no-hosts</strong> is set in dnsmasq config (dnsmasq only uses addn-hosts files) and the configured path is not in the effective addn-hosts list, so dnsmasq would not read the file.</p> <p class="text-muted">Hosts editing is unavailable: managed hosts path is not configured. Set <strong>Dnsmasq:MainConfigPath</strong> (and optionally <strong>Dnsmasq:ManagedHostsFileName</strong>) so the app can create and edit the managed hosts file.</p>
@if (_status.AddnHostsPaths?.Count > 0)
{
<p class="text-muted small">Effective addn-hosts paths: @RenderAddnHostsList()</p>
}
}
else if (string.IsNullOrEmpty(_status.SystemHostsPath))
{
<p class="text-muted">No system hosts file configured. Set <strong>Dnsmasq:SystemHostsPath</strong> (e.g. /etc/hosts) to edit a hosts file from this app.</p>
@if (_status.AddnHostsPaths?.Count > 0) @if (_status.AddnHostsPaths?.Count > 0)
{ {
<p class="text-muted small">Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()</p> <p class="text-muted small">Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()</p>
} }
} }
else if (!_status.SystemHostsPathExists)
{
<p class="text-warning">System hosts file not found: @_status.SystemHostsPath</p>
}
else else
{ {
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown") @if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
@@ -49,54 +37,31 @@ else
{ {
<div class="alert alert-success">@_message</div> <div class="alert alert-success">@_message</div>
} }
<div class="table-responsive-cards">
<div class="d-none d-md-block"> <p class="text-muted small mb-2">Each hosts file below is rendered with the same component; only the managed file is editable. System hosts is excluded when <strong>no-hosts</strong> is set (dnsmasq does not read it then).</p>
<table class="table table-bordered">
<thead> <HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
<tr><th>#</th><th>Address</th><th>Names</th></tr> <p class="text-muted small">@_status.ManagedHostsFilePath @(!_status.ManagedHostsPathExists ? "(not created yet; will be created on first save)" : "")</p>
</thead>
<tbody>
@foreach (var e in _entries.Where(x => !x.IsPassthrough))
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<td>@e.LineNumber</td>
<td>@e.Address</td>
<td>@string.Join(", ", e.Names)</td>
</tr>
}
</tbody>
</table>
</div>
<div class="d-md-none table-cards-mobile">
@foreach (var e in _entries.Where(x => !x.IsPassthrough))
{
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<div class="card-body py-2">
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
<div class="table-card-row"><span class="table-card-label">Address</span> @e.Address</div>
<div class="table-card-row"><span class="table-card-label">Names</span> @string.Join(", ", e.Names)</div>
</div>
</div>
}
</div>
</div>
<p class="text-muted small">Path: @_status.SystemHostsPath</p>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button> <button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
@if (_readOnlyHosts?.Count > 0)
{
@foreach (var ro in _readOnlyHosts)
{
<HostsFileSection Path="@ro.Path" Entries="@ro.Entries" IsEditable="false" IsFirst="false" @key="@ro.Path" />
}
}
} }
@code { @code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle; private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private DnsmasqServiceStatus? _status; private DnsmasqServiceStatus? _status;
private List<HostEntry> _entries = new(); private List<HostEntry> _entries = new();
private IReadOnlyList<ReadOnlyHostsFile>? _readOnlyHosts;
private string? _error; private string? _error;
private string? _message; private string? _message;
private bool _saving; private bool _saving;
/// <summary>True when SystemHostsPath is in the effective addn-hosts list (editing allowed when no-hosts is set).</summary>
private bool _pathInAddnHosts => _status != null
&& !string.IsNullOrEmpty(_status.SystemHostsPath)
&& _status.AddnHostsPaths?.Any(p => string.Equals(p, Path.GetFullPath(_status.SystemHostsPath!.Trim()), StringComparison.Ordinal)) == true;
/// <summary>Returns MarkupString so Blazor renders raw HTML (path + source label).</summary> /// <summary>Returns MarkupString so Blazor renders raw HTML (path + source label).</summary>
private MarkupString RenderAddnHostsList() private MarkupString RenderAddnHostsList()
{ {
@@ -130,6 +95,7 @@ else
_status = await StatusClient.GetStatusAsync(); _status = await StatusClient.GetStatusAsync();
var entries = await HostsClient.GetHostsAsync(); var entries = await HostsClient.GetHostsAsync();
_entries = entries?.ToList() ?? new List<HostEntry>(); _entries = entries?.ToList() ?? new List<HostEntry>();
_readOnlyHosts = await HostsClient.GetReadOnlyHostsAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -151,6 +117,7 @@ else
_error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr; _error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr;
else else
_message = "Saved."; _message = "Saved.";
_status = await StatusClient.GetStatusAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -12,6 +12,7 @@
@using DnsmasqWebUI.Components @using DnsmasqWebUI.Components
@using DnsmasqWebUI.Components.Modals @using DnsmasqWebUI.Components.Modals
@using DnsmasqWebUI.Components.EffectiveConfig @using DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Components.Hosts
@using DnsmasqWebUI.Components.Dnsmasq @using DnsmasqWebUI.Components.Dnsmasq
@using DnsmasqWebUI.Models @using DnsmasqWebUI.Models
@using DnsmasqWebUI.Configuration @using DnsmasqWebUI.Configuration
@@ -23,7 +23,10 @@ public class DnsmasqOptions
/// <summary>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.</summary> /// <summary>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.</summary>
public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf"; public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf";
/// <summary>Optional path to the hosts file the app can edit (e.g. /etc/hosts). When set, the app can read/write it. When no-hosts is not set, the app adds addn-hosts= in the managed file so dnsmasq loads it; when no-hosts is set, dnsmasq only uses addn-hosts files, so this path must be one of the effective addn-hosts paths for editing to take effect. When unset, hosts UI is disabled.</summary> /// <summary>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=&lt;this path&gt; in the managed config so dnsmasq loads it last. This is the only hosts file the app writes to.</summary>
public string ManagedHostsFileName { get; set; } = "zz-dnsmasq-webui.hosts";
/// <summary>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.</summary>
public string? SystemHostsPath { get; set; } public string? SystemHostsPath { get; set; }
/// <summary>Command to run after config changes (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). Runs in the same environment as the app; if app is in a container and dnsmasq is on the host, this runs in the container and will not reload host dnsmasq unless you use a host-side relay.</summary> /// <summary>Command to run after config changes (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). Runs in the same environment as the app; if app is in a container and dnsmasq is on the host, this runs in the container and will not reload host dnsmasq unless you use a host-side relay.</summary>
@@ -28,9 +28,9 @@ public sealed class DnsmasqOptionsValidator : IValidateOptions<DnsmasqOptions>
} }
} }
// SystemHostsPath is optional. When set, the app can edit that hosts file. Hosts UI is disabled when // SystemHostsPath is optional. When set, the UI shows system hosts as read-only. The app only writes to the
// SystemHostsPath is unset, or when no-hosts is set and SystemHostsPath is not in the effective addn-hosts // managed hosts file (ManagedHostsFileName in the same directory as main config). Hosts UI is available when
// list (dnsmasq only uses addn-hosts when no-hosts is set, so the path must be in addn-hosts for editing to take effect). // MainConfigPath is set (managed hosts path is then derived from it and ManagedHostsFileName).
if (failures.Count == 0) if (failures.Count == 0)
return ValidateOptionsResult.Success; return ValidateOptionsResult.Success;
+84 -14
View File
@@ -1,7 +1,10 @@
using DnsmasqWebUI.Models; using System.Text;
using DnsmasqWebUI.Configuration; using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions; using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Controllers; namespace DnsmasqWebUI.Controllers;
@@ -14,11 +17,7 @@ public class HostsController : ControllerBase
private readonly IDnsmasqConfigSetService _configSetService; private readonly IDnsmasqConfigSetService _configSetService;
private readonly DnsmasqOptions _options; private readonly DnsmasqOptions _options;
public HostsController( public HostsController(IHostsFileService hostsService, IReloadService reloadService, IDnsmasqConfigSetService configSetService, IOptions<DnsmasqOptions> options)
IHostsFileService hostsService,
IReloadService reloadService,
IDnsmasqConfigSetService configSetService,
Microsoft.Extensions.Options.IOptions<DnsmasqOptions> options)
{ {
_hostsService = hostsService; _hostsService = hostsService;
_reloadService = reloadService; _reloadService = reloadService;
@@ -40,25 +39,96 @@ public class HostsController : ControllerBase
} }
} }
/// <summary>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.</summary>
[HttpGet("readonly")]
public async Task<ActionResult<IReadOnlyList<ReadOnlyHostsFile>>> GetReadOnly(CancellationToken ct)
{
try
{
var set = await _configSetService.GetConfigSetAsync(ct);
var effectiveConfig = _configSetService.GetEffectiveConfig();
var addnPaths = effectiveConfig.AddnHostsPaths ?? Array.Empty<string>();
var managedPath = set.ManagedHostsFilePath != null ? Path.GetFullPath(set.ManagedHostsFilePath) : null;
var result = new List<ReadOnlyHostsFile>();
// 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<HostEntry>();
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<HostEntry>();
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] [HttpPut]
public async Task<ActionResult<SaveWithReloadResult>> Put([FromBody] List<HostEntry>? entries, CancellationToken ct) public async Task<ActionResult<SaveWithReloadResult>> Put([FromBody] List<HostEntry>? entries, CancellationToken ct)
{ {
if (entries == null) if (entries == null)
return BadRequest(new { error = "Body required" }); 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 try
{ {
await _hostsService.WriteAsync(entries, ct); await _hostsService.WriteAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(ct); var reload = await _reloadService.ReloadAsync(ct);
return Ok(new SaveWithReloadResult(true, reload)); return Ok(new SaveWithReloadResult(true, reload));
} }
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (Exception ex) catch (Exception ex)
{ {
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
@@ -62,6 +62,8 @@ public class StatusController : ControllerBase
var status = new DnsmasqServiceStatus( var status = new DnsmasqServiceStatus(
SystemHostsPath: string.IsNullOrEmpty(systemHostsPath) ? null : systemHostsPath, SystemHostsPath: string.IsNullOrEmpty(systemHostsPath) ? null : systemHostsPath,
SystemHostsPathExists: !string.IsNullOrEmpty(systemHostsPath) && System.IO.File.Exists(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, NoHosts: effectiveConfig.NoHosts,
AddnHostsPaths: effectiveConfig.AddnHostsPaths, AddnHostsPaths: effectiveConfig.AddnHostsPaths,
EffectiveConfig: effectiveConfig, EffectiveConfig: effectiveConfig,
+2 -1
View File
@@ -1,8 +1,9 @@
namespace DnsmasqWebUI.Models; namespace DnsmasqWebUI.Models;
/// <summary>Ordered set of dnsmasq config files (main + conf-file + conf-dir). ManagedFilePath is the single file we read/write.</summary> /// <summary>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.</summary>
public record DnsmasqConfigSet( public record DnsmasqConfigSet(
string MainConfigPath, string MainConfigPath,
string ManagedFilePath, string ManagedFilePath,
string? ManagedHostsFilePath,
IReadOnlyList<DnsmasqConfigSetEntry> Files IReadOnlyList<DnsmasqConfigSetEntry> Files
); );
@@ -4,8 +4,10 @@ namespace DnsmasqWebUI.Models;
/// Dnsmasq service and config status returned by GET api/status. /// Dnsmasq service and config status returned by GET api/status.
/// Includes paths from config, command configuration flags, and live output from status/logs commands. /// Includes paths from config, command configuration flags, and live output from status/logs commands.
/// </summary> /// </summary>
/// <param name="SystemHostsPath">Path to the hosts file the app can edit (e.g. /etc/hosts). Null when hosts UI is disabled.</param> /// <param name="SystemHostsPath">Optional path to the system hosts file (e.g. /etc/hosts). When set, shown in UI as read-only. Null when not configured.</param>
/// <param name="SystemHostsPathExists">True if <paramref name="SystemHostsPath"/> is set and the file exists on disk.</param> /// <param name="SystemHostsPathExists">True if <paramref name="SystemHostsPath"/> is set and the file exists on disk.</param>
/// <param name="ManagedHostsFilePath">Path to the app's managed hosts file (the only hosts file the app writes to). Null when main config path is empty.</param>
/// <param name="ManagedHostsPathExists">True if <paramref name="ManagedHostsFilePath"/> is set and the file exists on disk.</param>
/// <param name="NoHosts">True when dnsmasq has no-hosts set (hosts files disabled).</param> /// <param name="NoHosts">True when dnsmasq has no-hosts set (hosts files disabled).</param>
/// <param name="AddnHostsPaths">Effective addn-hosts paths dnsmasq loads. Empty when none configured.</param> /// <param name="AddnHostsPaths">Effective addn-hosts paths dnsmasq loads. Empty when none configured.</param>
/// <param name="EffectiveConfig">Effective dnsmasq config (single-value, flags, multi-value) after parsing all config files.</param> /// <param name="EffectiveConfig">Effective dnsmasq config (single-value, flags, multi-value) after parsing all config files.</param>
@@ -35,6 +37,8 @@ namespace DnsmasqWebUI.Models;
public record DnsmasqServiceStatus( public record DnsmasqServiceStatus(
string? SystemHostsPath, string? SystemHostsPath,
bool SystemHostsPathExists, bool SystemHostsPathExists,
string? ManagedHostsFilePath,
bool ManagedHostsPathExists,
bool NoHosts, bool NoHosts,
IReadOnlyList<string> AddnHostsPaths, IReadOnlyList<string> AddnHostsPaths,
EffectiveDnsmasqConfig? EffectiveConfig, EffectiveDnsmasqConfig? EffectiveConfig,
@@ -0,0 +1,4 @@
namespace DnsmasqWebUI.Models;
/// <summary>Path and parsed entries for a read-only addn-hosts file (not the managed hosts file).</summary>
public record ReadOnlyHostsFile(string Path, IReadOnlyList<HostEntry> Entries);
@@ -1,23 +1,19 @@
using System.Text; using System.Text;
using DnsmasqWebUI.Models; using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers; using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions; using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services; namespace DnsmasqWebUI.Services;
public class DnsmasqConfigService : IDnsmasqConfigService public class DnsmasqConfigService : IDnsmasqConfigService
{ {
private readonly IDnsmasqConfigSetService _configSetService; private readonly IDnsmasqConfigSetService _configSetService;
private readonly string _hostsPath;
private readonly ILogger<DnsmasqConfigService> _logger; private readonly ILogger<DnsmasqConfigService> _logger;
public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, IOptions<DnsmasqOptions> options, ILogger<DnsmasqConfigService> logger) public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, ILogger<DnsmasqConfigService> logger)
{ {
_configSetService = configSetService; _configSetService = configSetService;
_hostsPath = options.Value.SystemHostsPath?.Trim() ?? "";
_logger = logger; _logger = logger;
} }
@@ -75,20 +71,31 @@ public class DnsmasqConfigService : IDnsmasqConfigService
return "line:" + e.LineNumber; return "line:" + e.LineNumber;
} }
/// <summary>When hostsPath is set, ensures the managed file has exactly one addn-hosts line pointing to it (replaces the first AddnHosts line in the list or inserts at start). So dnsmasq loads that file; other config files may have other addn-hosts lines.</summary> /// <summary>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.</summary>
private static void EnsureOneAddnHostsLine(List<DnsmasqConfLine> configLines, string hostsPath) private static void EnsureOneAddnHostsLine(List<DnsmasqConfLine> configLines, string? managedHostsPath)
{ {
if (string.IsNullOrEmpty(hostsPath)) if (string.IsNullOrEmpty(managedHostsPath))
return; return;
var idx = configLines.FindIndex(c => c.Kind == DnsmasqConfLineKind.AddnHosts); var idx = configLines.FindIndex(c => c.Kind == DnsmasqConfLineKind.AddnHosts);
var lineNumber = idx >= 0 ? configLines[idx].LineNumber : 1; 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) if (idx >= 0)
configLines[idx] = line; configLines[idx] = line;
else else
configLines.Insert(0, line); configLines.Insert(0, line);
} }
/// <summary>Creates the managed hosts file empty if it does not exist, so dnsmasq does not error when we add addn-hosts=&lt;path&gt; to the managed config.</summary>
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<DhcpHostEntry> entries, CancellationToken ct = default) public async Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
{ {
var set = await _configSetService.GetConfigSetAsync(ct); var set = await _configSetService.GetConfigSetAsync(ct);
@@ -119,7 +126,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
rawLines = Array.Empty<string>(); rawLines = Array.Empty<string>();
var configLines = DnsmasqConfFileLineParser.ParseFile(rawLines).ToList(); var configLines = DnsmasqConfFileLineParser.ParseFile(rawLines).ToList();
EnsureOneAddnHostsLine(configLines, _hostsPath); EnsureOneAddnHostsLine(configLines, set.ManagedHostsFilePath);
var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList(); var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
AssignStableIds(fileEntries); AssignStableIds(fileEntries);
@@ -142,6 +149,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var tmpPath = path + ".tmp"; var tmpPath = path + ".tmp";
await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct); await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct);
File.Move(tmpPath, path, overwrite: true); File.Move(tmpPath, path, overwrite: true);
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
_logger.LogInformation("Wrote managed config file: {Path}", path); _logger.LogInformation("Wrote managed config file: {Path}", path);
} }
@@ -188,7 +196,8 @@ public class DnsmasqConfigService : IDnsmasqConfigService
public async Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default) public async Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default)
{ {
var path = await GetManagedFilePathAsync(ct); var set = await _configSetService.GetConfigSetAsync(ct);
var path = set.ManagedFilePath;
if (string.IsNullOrEmpty(path)) if (string.IsNullOrEmpty(path))
throw new InvalidOperationException("No managed file path (main config has no conf-dir). Cannot write managed config."); 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); Directory.CreateDirectory(dir);
var list = lines.ToList(); var list = lines.ToList();
EnsureOneAddnHostsLine(list, _hostsPath); EnsureOneAddnHostsLine(list, set.ManagedHostsFilePath);
var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList(); var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList();
var tmpPath = path + ".tmp"; var tmpPath = path + ".tmp";
await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct); await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct);
File.Move(tmpPath, path, overwrite: true); File.Move(tmpPath, path, overwrite: true);
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
_logger.LogInformation("Wrote managed config file: {Path}", path); _logger.LogInformation("Wrote managed config file: {Path}", path);
} }
} }
@@ -251,11 +251,15 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
{ {
var mainPath = _options.MainConfigPath; var mainPath = _options.MainConfigPath;
if (string.IsNullOrEmpty(mainPath)) if (string.IsNullOrEmpty(mainPath))
return new DnsmasqConfigSet("", "", Array.Empty<DnsmasqConfigSetEntry>()); return new DnsmasqConfigSet("", "", null, Array.Empty<DnsmasqConfigSetEntry>());
var mainFull = Path.GetFullPath(mainPath); var mainFull = Path.GetFullPath(mainPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? ""; var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var managedFilePath = Path.Combine(mainDir, _options.ManagedFileName); 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 withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(mainPath);
var files = withSource.Select(p => new DnsmasqConfigSetEntry( 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))) if (files.All(e => !string.Equals(e.Path, managedFilePath, StringComparison.Ordinal)))
files.Add(new DnsmasqConfigSetEntry(managedFilePath, Path.GetFileName(managedFilePath), DnsmasqConfFileSource.ConfFile, IsManaged: true)); 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);
} }
} }
+17 -15
View File
@@ -1,33 +1,33 @@
using System.Text; using System.Text;
using DnsmasqWebUI.Models; using DnsmasqWebUI.Models;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers; using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions; using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services; namespace DnsmasqWebUI.Services;
public class HostsFileService : IHostsFileService public class HostsFileService : IHostsFileService
{ {
private readonly string _path; private readonly IDnsmasqConfigSetService _configSetService;
private readonly ILogger<HostsFileService> _logger; private readonly ILogger<HostsFileService> _logger;
public HostsFileService(IOptions<DnsmasqOptions> options, ILogger<HostsFileService> logger) public HostsFileService(IDnsmasqConfigSetService configSetService, ILogger<HostsFileService> logger)
{ {
_path = options.Value.SystemHostsPath?.Trim() ?? ""; _configSetService = configSetService;
_logger = logger; _logger = logger;
} }
public async Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) public async Task<IReadOnlyList<HostEntry>> 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<HostEntry>(); return Array.Empty<HostEntry>();
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<HostEntry>(); return Array.Empty<HostEntry>();
} }
var lines = await File.ReadAllLinesAsync(_path, Encoding.UTF8, ct); var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct);
var entries = new List<HostEntry>(); var entries = new List<HostEntry>();
var seenContentIds = new HashSet<string>(StringComparer.Ordinal); var seenContentIds = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < lines.Length; i++) for (var i = 0; i < lines.Length; i++)
@@ -48,16 +48,18 @@ public class HostsFileService : IHostsFileService
public async Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default) public async Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
{ {
if (string.IsNullOrEmpty(_path)) var set = await _configSetService.GetConfigSetAsync(ct);
throw new InvalidOperationException("No system hosts file configured. Set Dnsmasq:SystemHostsPath to enable hosts editing."); var path = set.ManagedHostsFilePath;
var dir = Path.GetDirectoryName(_path); 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)) if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
var tmpPath = _path + ".tmp"; var tmpPath = path + ".tmp";
var lines = entries.Select(HostsFileLineParser.ToLine).ToList(); var lines = entries.Select(HostsFileLineParser.ToLine).ToList();
await File.WriteAllLinesAsync(tmpPath, lines, Encoding.UTF8, ct); await File.WriteAllLinesAsync(tmpPath, lines, Encoding.UTF8, ct);
File.Move(tmpPath, _path, overwrite: true); File.Move(tmpPath, path, overwrite: true);
_logger.LogInformation("Wrote hosts file: {Path}", _path); _logger.LogInformation("Wrote managed hosts file: {Path}", path);
} }
} }
+1
View File
@@ -26,6 +26,7 @@
"Dnsmasq": { "Dnsmasq": {
"MainConfigPath": "/etc/dnsmasq.conf", "MainConfigPath": "/etc/dnsmasq.conf",
"ManagedFileName": "zz-dnsmasq-webui.conf", "ManagedFileName": "zz-dnsmasq-webui.conf",
"ManagedHostsFileName": "zz-dnsmasq-webui.hosts",
"SystemHostsPath": "/etc/hosts", "SystemHostsPath": "/etc/hosts",
"ReloadCommand": "systemctl reload dnsmasq", "ReloadCommand": "systemctl reload dnsmasq",
"StatusCommand": "systemctl is-active dnsmasq", "StatusCommand": "systemctl is-active dnsmasq",