mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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=<this path> 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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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=<path> 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user