Files
dnsmasq-webui/src/DnsmasqWebUI/Controllers/HostsController.cs
T
alex a8ddcdec4e Dnsmasq/Hosts UI tweaks, test harness: down before redeploy, exclude leases from mount
- Dnsmasq page: Config (Main config, Managed file, Config set) under Service configuration; remove Paths/Leases (in Effective config DHCP); EffectiveConfigSection collapsible groups; StatusSection flex-wrap
- Hosts: HostsFileSection always shows table; 'No host entries' in tbody when empty
- Models: reorganize into sub-namespaces (Config, EffectiveConfig, Hosts, Dhcp, Status); move EffectiveConfig/Status/Logs components under Dnsmasq
- Test harness: prepare-test-mount.sh runs 'docker compose down' before mount+up so all containers (including DHCP clients) restart; exclude testdata/leases from sync (unit tests only); testdata/README
2026-02-03 01:35:26 +10:00

140 lines
5.3 KiB
C#

using System.Text;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Models.Status;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class HostsController : ControllerBase
{
private readonly IHostsFileService _hostsService;
private readonly IReloadService _reloadService;
private readonly IDnsmasqConfigSetService _configSetService;
private readonly DnsmasqOptions _options;
public HostsController(IHostsFileService hostsService, IReloadService reloadService, IDnsmasqConfigSetService configSetService, IOptions<DnsmasqOptions> options)
{
_hostsService = hostsService;
_reloadService = reloadService;
_configSetService = configSetService;
_options = options.Value;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<HostEntry>>> Get(CancellationToken ct)
{
try
{
var entries = await _hostsService.ReadAsync(ct);
return Ok(entries);
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message });
}
}
/// <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]
public async Task<ActionResult<SaveWithReloadResult>> Put([FromBody] List<HostEntry>? entries, CancellationToken ct)
{
if (entries == null)
return BadRequest(new { error = "Body required" });
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 });
}
}
}