Add config set and hosts caches; DnsmasqConfKeys; DHCP hosts from snapshot

- Config set cache: ConfigSetCache, IConfigSetCache, ConfigSetSnapshot. One read of
  main + conf-dir + managed config, build snapshot via parser pathToLines overloads.
  Watchers (main + managed file), 120s staleness, Invalidate(), NotifyWeWroteManagedConfig.
- Parser: use DnsmasqConfKeys in DnsmasqConfIncludeParser and ConfigSetCache; add
  DnsmasqConfKeys.cs and use in DnsmasqConfOptionRegistry.
- ConfigSetSnapshot: add DhcpHostEntries (built from pathToLines). ReadDhcpHostsAsync
  and ReadManagedConfigAsync use cache; writes call NotifyWeWroteManagedConfig.
- DnsmasqConfigSetService: use IConfigSetCache only; all data from snapshot.
- Hosts cache: HostsCache, IHostsCache, HostsSnapshot (managed + read-only). Watchers
  on managed and system hosts path, staleness, NotifyWeWroteManagedHosts.
- HostsFileService and HostsController: use IHostsCache for ReadAsync and GetReadOnly.
- Tests: DnsmasqConfigSetServiceTests use ConfigSetCache with test options.
This commit is contained in:
2026-02-03 20:25:31 +10:00
parent d1268a5fab
commit 368489e5f2
14 changed files with 1167 additions and 386 deletions
@@ -1,6 +1,7 @@
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests;
@@ -14,7 +15,8 @@ public class DnsmasqConfigSetServiceTests
public void GetEffectiveConfigWithSources_NoMainPath_ReturnsDefaultConfigAndDefaultSources()
{
var options = Options.Create(new DnsmasqOptions { MainConfigPath = "" });
var service = new DnsmasqConfigSetService(options);
var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var service = new DnsmasqConfigSetService(cache);
var (config, sources) = service.GetEffectiveConfigWithSources();
Assert.NotNull(config);
@@ -35,7 +37,7 @@ public class DnsmasqConfigSetServiceTests
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-svc-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedPath = Path.Combine(dir, "zz-dnsmasq-webui.conf");
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\ncache-size=500\naddn-hosts=/etc/hosts.extra\n");
@@ -44,7 +46,8 @@ public class DnsmasqConfigSetServiceTests
MainConfigPath = mainPath,
ManagedFileName = "zz-dnsmasq-webui.conf"
});
var service = new DnsmasqConfigSetService(options);
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var service = new DnsmasqConfigSetService(cache);
var (config, sources) = service.GetEffectiveConfigWithSources();
Assert.NotNull(config);
@@ -66,6 +69,7 @@ public class DnsmasqConfigSetServiceTests
}
finally
{
cache?.Dispose();
Directory.Delete(dir, recursive: true);
}
}
@@ -1,12 +1,7 @@
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;
@@ -16,15 +11,13 @@ public class HostsController : ControllerBase
{
private readonly IHostsFileService _hostsService;
private readonly IReloadService _reloadService;
private readonly IDnsmasqConfigSetService _configSetService;
private readonly DnsmasqOptions _options;
private readonly IHostsCache _hostsCache;
public HostsController(IHostsFileService hostsService, IReloadService reloadService, IDnsmasqConfigSetService configSetService, IOptions<DnsmasqOptions> options)
public HostsController(IHostsFileService hostsService, IReloadService reloadService, IHostsCache hostsCache)
{
_hostsService = hostsService;
_reloadService = reloadService;
_configSetService = configSetService;
_options = options.Value;
_hostsCache = hostsCache;
}
[HttpGet]
@@ -47,68 +40,8 @@ public class HostsController : ControllerBase
{
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);
var snapshot = await _hostsCache.GetSnapshotAsync(ct);
return Ok(snapshot.ReadOnlyFiles);
}
catch (Exception ex)
{
@@ -0,0 +1,63 @@
namespace DnsmasqWebUI.Models.Config;
/// <summary>
/// Literal dnsmasq .conf option names (config keys). Use these everywhere we reference option names
/// so references are compile-time checked and easy to find/rename. Values match dnsmasq long-option
/// names without the leading "--". Case-sensitive per dnsmasq.
/// </summary>
public static class DnsmasqConfKeys
{
// --- Include (main config discovery) ---
public const string ConfFile = "conf-file";
public const string ConfDir = "conf-dir";
// --- Hosts ---
public const string NoHosts = "no-hosts";
public const string AddnHosts = "addn-hosts";
// --- DHCP lease file (last wins) ---
public const string DhcpLeasefile = "dhcp-leasefile";
public const string DhcpLease = "dhcp-lease";
// --- Multi-value (ARG_DUP) ---
public const string Server = "server";
public const string Local = "local";
public const string Address = "address";
public const string Interface = "interface";
public const string ListenAddress = "listen-address";
public const string ExceptInterface = "except-interface";
public const string DhcpRange = "dhcp-range";
public const string DhcpHost = "dhcp-host";
public const string DhcpOption = "dhcp-option";
public const string ResolvFile = "resolv-file";
// --- Flags (no value) ---
public const string ExpandHosts = "expand-hosts";
public const string BogusPriv = "bogus-priv";
public const string StrictOrder = "strict-order";
public const string NoResolv = "no-resolv";
public const string DomainNeeded = "domain-needed";
public const string NoPoll = "no-poll";
public const string BindInterfaces = "bind-interfaces";
public const string NoNegcache = "no-negcache";
public const string DhcpAuthoritative = "dhcp-authoritative";
public const string LeasefileRo = "leasefile-ro";
// --- Single-value (last wins) ---
public const string CacheSize = "cache-size";
public const string Port = "port";
public const string LocalTtl = "local-ttl";
public const string PidFile = "pid-file";
public const string User = "user";
public const string Group = "group";
public const string LogFacility = "log-facility";
public const string DhcpLeaseMax = "dhcp-lease-max";
public const string NegTtl = "neg-ttl";
public const string MaxTtl = "max-ttl";
public const string MaxCacheTtl = "max-cache-ttl";
public const string MinCacheTtl = "min-cache-ttl";
public const string DhcpTtl = "dhcp-ttl";
/// <summary>Keys collected for effective config "server/local" multi-value (order preserved).</summary>
public static readonly string[] ServerLocalKeys = { Server, Local };
}
@@ -12,52 +12,52 @@ public static class DnsmasqConfOptionRegistry
new Dictionary<string, DnsmasqOptionKind>(StringComparer.OrdinalIgnoreCase)
{
// Include
{ "conf-file", DnsmasqOptionKind.ConfFile },
{ "conf-dir", DnsmasqOptionKind.ConfDir },
{ DnsmasqConfKeys.ConfFile, DnsmasqOptionKind.ConfFile },
{ DnsmasqConfKeys.ConfDir, DnsmasqOptionKind.ConfDir },
// Path-valued
{ "addn-hosts", DnsmasqOptionKind.AddnHosts },
{ "dhcp-leasefile", DnsmasqOptionKind.DhcpLeaseFile },
{ "dhcp-lease", DnsmasqOptionKind.DhcpLeaseFile },
{ "resolv-file", DnsmasqOptionKind.Path },
{ DnsmasqConfKeys.AddnHosts, DnsmasqOptionKind.AddnHosts },
{ DnsmasqConfKeys.DhcpLeasefile, DnsmasqOptionKind.DhcpLeaseFile },
{ DnsmasqConfKeys.DhcpLease, DnsmasqOptionKind.DhcpLeaseFile },
{ DnsmasqConfKeys.ResolvFile, DnsmasqOptionKind.Path },
{ "dhcp-hostsfile", DnsmasqOptionKind.Path },
{ "dhcp-optsfile", DnsmasqOptionKind.Path },
{ "pid-file", DnsmasqOptionKind.Path },
{ DnsmasqConfKeys.PidFile, DnsmasqOptionKind.Path },
{ "hostsdir", DnsmasqOptionKind.Path },
{ "dhcp-script", DnsmasqOptionKind.Path },
{ "dhcp-lua-script", DnsmasqOptionKind.Path },
{ "read-ethers", DnsmasqOptionKind.Path },
// Simple string / domain
{ "domain", DnsmasqOptionKind.Domain },
{ "interface", DnsmasqOptionKind.String },
{ "listen-address", DnsmasqOptionKind.String },
{ "port", DnsmasqOptionKind.String },
{ "user", DnsmasqOptionKind.String },
{ "group", DnsmasqOptionKind.String },
{ "cache-size", DnsmasqOptionKind.String },
{ "local-ttl", DnsmasqOptionKind.String },
{ "log-facility", DnsmasqOptionKind.String },
{ DnsmasqConfKeys.Interface, DnsmasqOptionKind.String },
{ DnsmasqConfKeys.ListenAddress, DnsmasqOptionKind.String },
{ DnsmasqConfKeys.Port, DnsmasqOptionKind.String },
{ DnsmasqConfKeys.User, DnsmasqOptionKind.String },
{ DnsmasqConfKeys.Group, DnsmasqOptionKind.String },
{ DnsmasqConfKeys.CacheSize, DnsmasqOptionKind.String },
{ DnsmasqConfKeys.LocalTtl, DnsmasqOptionKind.String },
{ DnsmasqConfKeys.LogFacility, DnsmasqOptionKind.String },
{ "dhcp-ignore", DnsmasqOptionKind.String },
{ "min-port", DnsmasqOptionKind.String },
{ "max-port", DnsmasqOptionKind.String },
{ "query-port", DnsmasqOptionKind.String },
{ "edns-packet-max", DnsmasqOptionKind.String },
// DHCP structured
{ "dhcp-range", DnsmasqOptionKind.DhcpRange },
{ "dhcp-host", DnsmasqOptionKind.DhcpHost },
{ "dhcp-option", DnsmasqOptionKind.DhcpOption },
{ DnsmasqConfKeys.DhcpRange, DnsmasqOptionKind.DhcpRange },
{ DnsmasqConfKeys.DhcpHost, DnsmasqOptionKind.DhcpHost },
{ DnsmasqConfKeys.DhcpOption, DnsmasqOptionKind.DhcpOption },
// DNS structured
{ "server", DnsmasqOptionKind.Server },
{ "local", DnsmasqOptionKind.Local },
{ "address", DnsmasqOptionKind.Address },
{ DnsmasqConfKeys.Server, DnsmasqOptionKind.Server },
{ DnsmasqConfKeys.Local, DnsmasqOptionKind.Local },
{ DnsmasqConfKeys.Address, DnsmasqOptionKind.Address },
};
/// <summary>Well-known flag options (no value).</summary>
public static IReadOnlySet<string> FlagOptions { get; } =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"domain-needed", "bogus-priv", "no-hosts", "expand-hosts", "strict-order",
"no-resolv", "no-poll", "bind-interfaces", "no-negcache", "log-queries", "log-dhcp",
"all-servers", "leasefile-ro", "dhcp-authoritative",
DnsmasqConfKeys.DomainNeeded, DnsmasqConfKeys.BogusPriv, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ExpandHosts, DnsmasqConfKeys.StrictOrder,
DnsmasqConfKeys.NoResolv, DnsmasqConfKeys.NoPoll, DnsmasqConfKeys.BindInterfaces, DnsmasqConfKeys.NoNegcache, "log-queries", "log-dhcp",
"all-servers", DnsmasqConfKeys.LeasefileRo, DnsmasqConfKeys.DhcpAuthoritative,
"quiet-dhcp", "quiet-dhcp6", "quiet-ra", "dhcp-broadcast", "dhcp-sequential-ip",
"enable-tftp", "self-resolve", "conntrack",
};
@@ -1,3 +1,4 @@
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.EffectiveConfig;
using Superpower;
using Superpower.Model;
@@ -16,6 +17,10 @@ public static class DnsmasqConfIncludeParser
{
private static readonly StringComparison KeyComparison = StringComparison.Ordinal;
/// <summary>Gets lines for a path from pre-read content; used by config set cache to avoid double read.</summary>
private static string[] GetLines(string path, IReadOnlyDictionary<string, string[]> pathToLines) =>
pathToLines.TryGetValue(path, out var L) ? L : Array.Empty<string>();
/// <summary>
/// Returns the ordered list of absolute config file paths dnsmasq loads, in the exact order
/// dnsmasq reads them (main interleaved with conf-file and conf-dir).
@@ -80,7 +85,7 @@ public static class DnsmasqConfIncludeParser
continue;
var (key, value) = kv.Value;
if (string.Equals(key, "conf-file", KeyComparison))
if (string.Equals(key, DnsmasqConfKeys.ConfFile, KeyComparison))
{
var path = value.Trim();
if (string.IsNullOrEmpty(path)) continue;
@@ -90,7 +95,7 @@ public static class DnsmasqConfIncludeParser
continue;
}
if (string.Equals(key, "conf-dir", KeyComparison))
if (string.Equals(key, DnsmasqConfKeys.ConfDir, KeyComparison))
{
var (directory, matchSuffix, ignoreSuffix) = ParseConfDirValue(value);
if (string.IsNullOrEmpty(directory)) continue;
@@ -192,7 +197,7 @@ public static class DnsmasqConfIncludeParser
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, "conf-dir", KeyComparison))
if (!string.Equals(key, DnsmasqConfKeys.ConfDir, KeyComparison))
continue;
var path = value.Split(',')[0].Trim();
return Path.GetFullPath(Path.Combine(mainDir, path));
@@ -231,6 +236,30 @@ public static class DnsmasqConfIncludeParser
return false;
}
/// <summary>Same as <see cref="GetFlagFromConfigFiles(IReadOnlyList{string}, string)"/> but uses pre-read content (e.g. from config set cache).</summary>
public static bool GetFlagFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, string optionKey)
{
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return false;
foreach (var configPath in configFilePathsInOrder)
{
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, v) = kv.Value;
if (!string.Equals(k, key, KeyComparison))
continue;
if (!string.IsNullOrEmpty(v?.Trim()))
continue;
return true;
}
}
return false;
}
/// <summary>
/// Returns the last value for the given option key across config files, and the directory of the file
/// that contained it (for resolving relative paths). Keys matched case-sensitively per dnsmasq.
@@ -263,6 +292,32 @@ public static class DnsmasqConfIncludeParser
return (lastValue, lastDir);
}
/// <summary>Same as <see cref="GetLastValueFromConfigFiles(IReadOnlyList{string}, string)"/> but uses pre-read content.</summary>
public static (string? Value, string? ConfigFileDir) GetLastValueFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, string optionKey)
{
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return (null, null);
string? lastValue = null;
string? lastDir = null;
foreach (var configPath in configFilePathsInOrder)
{
var dir = Path.GetDirectoryName(configPath) ?? "";
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!string.Equals(k, key, KeyComparison))
continue;
lastValue = value.Trim();
lastDir = dir;
}
}
return (lastValue, lastDir);
}
/// <summary>
/// Resolves a path value against the config file directory. If value is null/empty or already absolute, returns as-is (or null).
/// </summary>
@@ -295,7 +350,30 @@ public static class DnsmasqConfIncludeParser
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, "dhcp-leasefile", KeyComparison) && !string.Equals(key, "dhcp-lease", KeyComparison))
if (!string.Equals(key, DnsmasqConfKeys.DhcpLeasefile, KeyComparison) && !string.Equals(key, DnsmasqConfKeys.DhcpLease, KeyComparison))
continue;
var path = value.Trim();
if (!string.IsNullOrEmpty(path))
result = ResolvePath(path, dir) ?? result;
}
}
return result;
}
/// <summary>Same as <see cref="GetDhcpLeaseFilePathFromConfigFiles(IReadOnlyList{string})"/> but uses pre-read content.</summary>
public static string? GetDhcpLeaseFilePathFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines)
{
string? result = null;
foreach (var configPath in configFilePathsInOrder)
{
var dir = Path.GetDirectoryName(configPath) ?? "";
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, DnsmasqConfKeys.DhcpLeasefile, KeyComparison) && !string.Equals(key, DnsmasqConfKeys.DhcpLease, KeyComparison))
continue;
var path = value.Trim();
if (!string.IsNullOrEmpty(path))
@@ -310,7 +388,11 @@ public static class DnsmasqConfIncludeParser
/// When true, dnsmasq does not read /etc/hosts; only addn-hosts= files are used (if any).
/// </summary>
public static bool GetNoHostsFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder) =>
GetFlagFromConfigFiles(configFilePathsInOrder, "no-hosts");
GetFlagFromConfigFiles(configFilePathsInOrder, DnsmasqConfKeys.NoHosts);
/// <summary>Same as <see cref="GetNoHostsFromConfigFiles(IReadOnlyList{string})"/> but uses pre-read content.</summary>
public static bool GetNoHostsFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines) =>
GetFlagFromConfigFiles(configFilePathsInOrder, pathToLines, DnsmasqConfKeys.NoHosts);
/// <summary>
/// Reads the given config files in order and returns all <c>addn-hosts=</c> paths (cumulative; dnsmasq loads each in order).
@@ -330,7 +412,31 @@ public static class DnsmasqConfIncludeParser
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, "addn-hosts", KeyComparison))
if (!string.Equals(key, DnsmasqConfKeys.AddnHosts, KeyComparison))
continue;
var path = value.Trim();
if (string.IsNullOrEmpty(path))
continue;
result.Add(Path.GetFullPath(Path.Combine(dir, path)));
}
}
return result;
}
/// <summary>Same as <see cref="GetAddnHostsPathsFromConfigFiles(IReadOnlyList{string})"/> but uses pre-read content.</summary>
public static IReadOnlyList<string> GetAddnHostsPathsFromConfigFiles(IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines)
{
var result = new List<string>();
foreach (var configPath in configFilePathsInOrder)
{
var dir = Path.GetDirectoryName(configPath) ?? "";
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, DnsmasqConfKeys.AddnHosts, KeyComparison))
continue;
var path = value.Trim();
if (string.IsNullOrEmpty(path))
@@ -374,6 +480,32 @@ public static class DnsmasqConfIncludeParser
return (lastValue, lastSource);
}
/// <summary>Same as <see cref="GetLastValueFromConfigFilesWithSource(IReadOnlyList{string}, string, string?)"/> but uses pre-read content.</summary>
public static (string? Value, ConfigValueSource? Source) GetLastValueFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, string optionKey, string? managedFilePath)
{
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return (null, null);
string? lastValue = null;
ConfigValueSource? lastSource = null;
foreach (var configPath in configFilePathsInOrder)
{
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!string.Equals(k, key, KeyComparison))
continue;
lastValue = value.Trim();
lastSource = MakeSource(configPath, managedFilePath);
}
}
return (lastValue, lastSource);
}
/// <summary>Like <see cref="GetFlagFromConfigFiles"/> but returns which file set the flag (for readonly: if not managed, user cannot unset from UI).</summary>
public static (bool IsSet, ConfigValueSource? Source) GetFlagFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, string optionKey, string? managedFilePath)
@@ -401,6 +533,31 @@ public static class DnsmasqConfIncludeParser
return (false, null);
}
/// <summary>Same as <see cref="GetFlagFromConfigFilesWithSource(IReadOnlyList{string}, string, string?)"/> but uses pre-read content.</summary>
public static (bool IsSet, ConfigValueSource? Source) GetFlagFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, string optionKey, string? managedFilePath)
{
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return (false, null);
foreach (var configPath in configFilePathsInOrder)
{
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, v) = kv.Value;
if (!string.Equals(k, key, KeyComparison))
continue;
if (!string.IsNullOrEmpty(v?.Trim()))
continue;
return (true, MakeSource(configPath, managedFilePath));
}
}
return (false, null);
}
/// <summary>Like <see cref="GetDhcpLeaseFilePathFromConfigFiles"/> but returns which file set the value.</summary>
public static (string? Path, ConfigValueSource? Source) GetDhcpLeaseFilePathFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, string? managedFilePath)
@@ -418,7 +575,35 @@ public static class DnsmasqConfIncludeParser
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, "dhcp-leasefile", KeyComparison) && !string.Equals(key, "dhcp-lease", KeyComparison))
if (!string.Equals(key, DnsmasqConfKeys.DhcpLeasefile, KeyComparison) && !string.Equals(key, DnsmasqConfKeys.DhcpLease, KeyComparison))
continue;
var path = value.Trim();
if (!string.IsNullOrEmpty(path))
{
result = ResolvePath(path, dir) ?? result;
lastSource = MakeSource(configPath, managedFilePath);
}
}
}
return (result, lastSource);
}
/// <summary>Same as <see cref="GetDhcpLeaseFilePathFromConfigFilesWithSource(IReadOnlyList{string}, string?)"/> but uses pre-read content.</summary>
public static (string? Path, ConfigValueSource? Source) GetDhcpLeaseFilePathFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, string? managedFilePath)
{
string? result = null;
ConfigValueSource? lastSource = null;
foreach (var configPath in configFilePathsInOrder)
{
var dir = Path.GetDirectoryName(configPath) ?? "";
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, DnsmasqConfKeys.DhcpLeasefile, KeyComparison) && !string.Equals(key, DnsmasqConfKeys.DhcpLease, KeyComparison))
continue;
var path = value.Trim();
if (!string.IsNullOrEmpty(path))
@@ -448,7 +633,33 @@ public static class DnsmasqConfIncludeParser
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, "addn-hosts", KeyComparison))
if (!string.Equals(key, DnsmasqConfKeys.AddnHosts, KeyComparison))
continue;
var path = value.Trim();
if (string.IsNullOrEmpty(path))
continue;
result.Add((Path.GetFullPath(Path.Combine(dir, path)), source));
}
}
return result;
}
/// <summary>Same as <see cref="GetAddnHostsPathsFromConfigFilesWithSource(IReadOnlyList{string}, string?)"/> but uses pre-read content.</summary>
public static IReadOnlyList<(string Path, ConfigValueSource Source)> GetAddnHostsPathsFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, string? managedFilePath)
{
var result = new List<(string Path, ConfigValueSource Source)>();
foreach (var configPath in configFilePathsInOrder)
{
var dir = Path.GetDirectoryName(configPath) ?? "";
var source = MakeSource(configPath, managedFilePath);
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (key, value) = kv.Value;
if (!string.Equals(key, DnsmasqConfKeys.AddnHosts, KeyComparison))
continue;
var path = value.Trim();
if (string.IsNullOrEmpty(path))
@@ -485,6 +696,30 @@ public static class DnsmasqConfIncludeParser
return result;
}
/// <summary>Same as <see cref="GetMultiValueFromConfigFiles(IReadOnlyList{string}, string)"/> but uses pre-read content.</summary>
public static IReadOnlyList<string> GetMultiValueFromConfigFiles(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, string optionKey)
{
var result = new List<string>();
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return result;
foreach (var configPath in configFilePathsInOrder)
{
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!string.Equals(k, key, KeyComparison))
continue;
result.Add(value.Trim());
}
}
return result;
}
/// <summary>Collects all values for multiple option keys (e.g. server and local) in file order. Keys matched case-sensitively.</summary>
public static IReadOnlyList<string> GetMultiValueFromConfigFiles(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyList<string> optionKeys)
@@ -511,6 +746,30 @@ public static class DnsmasqConfIncludeParser
return result;
}
/// <summary>Same as <see cref="GetMultiValueFromConfigFiles(IReadOnlyList{string}, IReadOnlyList{string})"/> but uses pre-read content.</summary>
public static IReadOnlyList<string> GetMultiValueFromConfigFiles(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, IReadOnlyList<string> optionKeys)
{
var keys = new HashSet<string>(optionKeys.Select(k => k.Trim()).Where(k => k.Length > 0), StringComparer.Ordinal);
if (keys.Count == 0)
return Array.Empty<string>();
var result = new List<string>();
foreach (var configPath in configFilePathsInOrder)
{
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!keys.Contains(k))
continue;
result.Add(value.Trim());
}
}
return result;
}
/// <summary>Like <see cref="GetMultiValueFromConfigFiles(IReadOnlyList{string}, string)"/> but returns source per value.</summary>
public static IReadOnlyList<(string Value, ConfigValueSource Source)> GetMultiValueFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, string optionKey, string? managedFilePath)
@@ -538,6 +797,31 @@ public static class DnsmasqConfIncludeParser
return result;
}
/// <summary>Same as <see cref="GetMultiValueFromConfigFilesWithSource(IReadOnlyList{string}, string, string?)"/> but uses pre-read content.</summary>
public static IReadOnlyList<(string Value, ConfigValueSource Source)> GetMultiValueFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, string optionKey, string? managedFilePath)
{
var result = new List<(string Value, ConfigValueSource Source)>();
var key = optionKey.Trim();
if (string.IsNullOrEmpty(key))
return result;
foreach (var configPath in configFilePathsInOrder)
{
var source = MakeSource(configPath, managedFilePath);
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!string.Equals(k, key, KeyComparison))
continue;
result.Add((value.Trim(), source));
}
}
return result;
}
/// <summary>Like <see cref="GetMultiValueFromConfigFiles(IReadOnlyList{string}, IReadOnlyList{string})"/> but returns source per value.</summary>
public static IReadOnlyList<(string Value, ConfigValueSource Source)> GetMultiValueFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyList<string> optionKeys, string? managedFilePath)
@@ -564,4 +848,29 @@ public static class DnsmasqConfIncludeParser
}
return result;
}
/// <summary>Same as <see cref="GetMultiValueFromConfigFilesWithSource(IReadOnlyList{string}, IReadOnlyList{string}, string?)"/> but uses pre-read content.</summary>
public static IReadOnlyList<(string Value, ConfigValueSource Source)> GetMultiValueFromConfigFilesWithSource(
IReadOnlyList<string> configFilePathsInOrder, IReadOnlyDictionary<string, string[]> pathToLines, IReadOnlyList<string> optionKeys, string? managedFilePath)
{
var keys = new HashSet<string>(optionKeys.Select(k => k.Trim()).Where(k => k.Length > 0), StringComparer.Ordinal);
if (keys.Count == 0)
return Array.Empty<(string, ConfigValueSource)>();
var result = new List<(string Value, ConfigValueSource Source)>();
foreach (var configPath in configFilePathsInOrder)
{
var source = MakeSource(configPath, managedFilePath);
foreach (var line in GetLines(configPath, pathToLines))
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var (k, value) = kv.Value;
if (!keys.Contains(k))
continue;
result.Add((value.Trim(), source));
}
}
return result;
}
}
@@ -0,0 +1,13 @@
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Services.Abstractions;
/// <summary>Immutable snapshot from the config set cache: config set, effective config, sources, managed file content, and DHCP host entries (one read per refresh).</summary>
public record ConfigSetSnapshot(
DnsmasqConfigSet Set,
EffectiveDnsmasqConfig Config,
EffectiveConfigSources Sources,
ManagedConfigContent ManagedContent,
IReadOnlyList<DhcpHostEntry> DhcpHostEntries
);
@@ -0,0 +1,9 @@
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Services.Abstractions;
/// <summary>Immutable snapshot from the hosts cache: managed hosts file entries and read-only hosts files (system + addn-hosts).</summary>
public record HostsSnapshot(
IReadOnlyList<HostEntry> ManagedEntries,
IReadOnlyList<ReadOnlyHostsFile> ReadOnlyFiles
);
@@ -0,0 +1,17 @@
namespace DnsmasqWebUI.Services.Abstractions;
/// <summary>
/// Singleton cache for the config set (main + conf-dir + managed file). Invalidates on file watchers, staleness, or manual Invalidate.
/// After the app writes the managed config file, call <see cref="NotifyWeWroteManagedConfig"/> so the cache updates in place instead of treating the write as an external change.
/// </summary>
public interface IConfigSetCache : IApplicationSingleton
{
/// <summary>Returns the current snapshot (config set, effective config, sources, managed content), refreshing from disk if dirty or stale.</summary>
Task<ConfigSetSnapshot> GetSnapshotAsync(CancellationToken ct = default);
/// <summary>Forces the next <see cref="GetSnapshotAsync"/> to re-read all config files.</summary>
void Invalidate();
/// <summary>Call after the app writes the managed config file. Updates the cached managed content in place and ignores the next watcher event for that file for a short window.</summary>
void NotifyWeWroteManagedConfig(ManagedConfigContent newContent);
}
@@ -0,0 +1,14 @@
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Services.Abstractions;
/// <summary>
/// Singleton cache for hosts: managed hosts file + read-only (system + addn-hosts). Invalidates on watchers, staleness, or Invalidate.
/// Call <see cref="NotifyWeWroteManagedHosts"/> after the app writes the managed hosts file so the cache updates in place and ignores the next watcher event.
/// </summary>
public interface IHostsCache : IApplicationSingleton
{
Task<HostsSnapshot> GetSnapshotAsync(CancellationToken ct = default);
void Invalidate();
void NotifyWeWroteManagedHosts(IReadOnlyList<HostEntry> entries);
}
+408
View File
@@ -0,0 +1,408 @@
using System.Text;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services;
/// <summary>
/// Singleton cache for the config set (main + conf-dir + managed file). Reads all files once per refresh,
/// builds snapshot from pathToLines via parser overloads. Invalidates on watchers (main + managed file), staleness, or Invalidate.
/// Self-write: NotifyWeWroteManagedConfig updates cached managed content in place and ignores the next managed-file watcher event for a short window.
/// </summary>
public sealed class ConfigSetCache : IConfigSetCache, IDisposable
{
private const int StaleCacheSeconds = 120;
private const double SelfWriteIgnoreSeconds = 1.5;
private readonly DnsmasqOptions _options;
private readonly ILogger<ConfigSetCache> _logger;
private readonly object _lock = new();
private ConfigSetSnapshot? _snapshot;
private DateTime? _lastReadUtc;
private bool _dirty = true;
private DateTime _lastWriteManagedUtc = DateTime.MinValue;
private FileSystemWatcher? _watcherMain;
private FileSystemWatcher? _watcherManaged;
public ConfigSetCache(IOptions<DnsmasqOptions> options, ILogger<ConfigSetCache> logger)
{
_options = options.Value;
_logger = logger;
var (mainPath, managedPath) = GetPaths();
if (!string.IsNullOrEmpty(mainPath))
TryAddWatcher(Path.GetDirectoryName(mainPath), Path.GetFileName(mainPath), ref _watcherMain, "main config");
if (!string.IsNullOrEmpty(managedPath))
TryAddWatcher(Path.GetDirectoryName(managedPath), Path.GetFileName(managedPath), ref _watcherManaged, "managed config");
}
private (string? MainPath, string? ManagedPath) GetPaths()
{
var mainPath = _options.MainConfigPath;
if (string.IsNullOrEmpty(mainPath))
return (null, null);
var mainFull = Path.GetFullPath(mainPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var managedPath = Path.Combine(mainDir, _options.ManagedFileName);
return (mainFull, managedPath);
}
private void TryAddWatcher(string? dir, string? fileName, ref FileSystemWatcher? watcher, string label)
{
if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(fileName))
return;
try
{
if (!Directory.Exists(dir))
{
_logger.LogDebug("{Label} directory does not exist yet: {Dir}", label, dir);
return;
}
watcher = new FileSystemWatcher(dir)
{
Filter = fileName,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size
};
watcher.Changed += OnFileChanged;
watcher.Created += OnFileChanged;
watcher.EnableRaisingEvents = true;
_logger.LogDebug("Watching {Label}: {Path}", label, Path.Combine(dir, fileName));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not create file watcher for {Label}: {Dir}", label, dir);
}
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
var fullPath = Path.GetFullPath(e.FullPath);
var (_, managedPath) = GetPaths();
if (!string.IsNullOrEmpty(managedPath) && string.Equals(fullPath, Path.GetFullPath(managedPath), StringComparison.Ordinal))
{
lock (_lock)
{
var elapsed = (DateTime.UtcNow - _lastWriteManagedUtc).TotalSeconds;
if (elapsed < SelfWriteIgnoreSeconds)
return;
}
}
lock (_lock)
_dirty = true;
}
public void Invalidate()
{
lock (_lock)
_dirty = true;
}
public void NotifyWeWroteManagedConfig(ManagedConfigContent newContent)
{
lock (_lock)
{
_lastWriteManagedUtc = DateTime.UtcNow;
_dirty = true;
}
}
public async Task<ConfigSetSnapshot> GetSnapshotAsync(CancellationToken ct = default)
{
return await Task.Run(() => GetSnapshot(ct), ct);
}
private ConfigSetSnapshot GetSnapshot(CancellationToken ct)
{
var (mainFull, managedPath) = GetPaths();
if (string.IsNullOrEmpty(mainFull))
return CreateDefaultSnapshot();
lock (_lock)
{
if (!_dirty && _snapshot != null && _lastReadUtc.HasValue)
{
var ageSeconds = (DateTime.UtcNow - _lastReadUtc.Value).TotalSeconds;
if (ageSeconds < StaleCacheSeconds)
return _snapshot;
_dirty = true;
}
var managedHostsFileName = (_options.ManagedHostsFileName ?? "").Trim();
if (string.IsNullOrEmpty(managedHostsFileName))
managedHostsFileName = "zz-dnsmasq-webui.hosts";
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var managedHostsFilePath = Path.Combine(mainDir, managedHostsFileName);
var set = BuildConfigSet(mainFull, managedPath, managedHostsFilePath);
if (set.Files.Count == 0)
{
_snapshot = CreateDefaultSnapshot();
_lastReadUtc = DateTime.UtcNow;
_dirty = false;
return _snapshot;
}
var paths = set.Files.Select(f => f.Path).ToList();
var pathToLines = ReadAllPaths(paths, ct);
var config = BuildEffectiveConfig(paths, pathToLines);
var sources = BuildEffectiveConfigSources(paths, pathToLines, set.ManagedFilePath);
var managedContent = BuildManagedContent(pathToLines, set.ManagedFilePath);
var dhcpHostEntries = BuildDhcpHostEntries(set, pathToLines);
_snapshot = new ConfigSetSnapshot(set, config, sources, managedContent, dhcpHostEntries);
_lastReadUtc = DateTime.UtcNow;
_dirty = false;
return _snapshot;
}
}
private static ConfigSetSnapshot CreateDefaultSnapshot()
{
var set = new DnsmasqConfigSet("", "", null, Array.Empty<DnsmasqConfigSetEntry>());
var config = CreateDefaultEffectiveConfig();
var sources = CreateDefaultEffectiveConfigSources();
var managedContent = new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), "");
return new ConfigSetSnapshot(set, config, sources, managedContent, Array.Empty<DhcpHostEntry>());
}
private static DnsmasqConfigSet BuildConfigSet(string mainFull, string? managedFilePath, string managedHostsFilePath)
{
var mainPath = mainFull;
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(mainPath);
var files = withSource.Select(p => new DnsmasqConfigSetEntry(
p.Path,
Path.GetFileName(p.Path),
p.Source,
IsManaged: string.Equals(p.Path, managedFilePath, StringComparison.Ordinal)
)).ToList();
if (managedFilePath != null && 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 ?? "", managedHostsFilePath, files);
}
private static IReadOnlyDictionary<string, string[]> ReadAllPaths(IReadOnlyList<string> paths, CancellationToken ct)
{
var dict = new Dictionary<string, string[]>(StringComparer.Ordinal);
foreach (var path in paths)
{
ct.ThrowIfCancellationRequested();
var canonical = Path.GetFullPath(path);
if (dict.ContainsKey(canonical))
continue;
if (!File.Exists(path))
{
dict[canonical] = Array.Empty<string>();
continue;
}
try
{
dict[canonical] = File.ReadAllLines(path, Encoding.UTF8);
}
catch
{
dict[canonical] = Array.Empty<string>();
}
}
return dict;
}
private static EffectiveDnsmasqConfig BuildEffectiveConfig(IReadOnlyList<string> paths, IReadOnlyDictionary<string, string[]> pathToLines)
{
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths, pathToLines);
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths, pathToLines);
var serverLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys);
var addressValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Address);
var interfaces = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Interface);
var listenAddresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ListenAddress);
var exceptInterfaces = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ExceptInterface);
var dhcpRanges = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpRange);
var dhcpHostLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpHost);
var dhcpOptionLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpOption);
var resolvFiles = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ResolvFile);
var expandHosts = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ExpandHosts);
var bogusPriv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.BogusPriv);
var strictOrder = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.StrictOrder);
var noResolv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoResolv);
var domainNeeded = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DomainNeeded);
var noPoll = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoPoll);
var bindInterfaces = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.BindInterfaces);
var noNegcache = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoNegcache);
var dhcpAuthoritative = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpAuthoritative);
var leasefileRo = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LeasefileRo);
var dhcpLeaseFilePath = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths, pathToLines);
var (cacheVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.CacheSize);
var cacheSize = TryParseInt(cacheVal);
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Port);
var port = TryParseInt(portVal);
var (localTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LocalTtl);
var localTtl = TryParseInt(localTtlVal);
var (pidVal, pidDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.PidFile);
var pidFilePath = DnsmasqConfIncludeParser.ResolvePath(pidVal, pidDir);
var (userVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.User);
var user = string.IsNullOrWhiteSpace(userVal) ? null : userVal.Trim();
var (groupVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Group);
var group = string.IsNullOrWhiteSpace(groupVal) ? null : groupVal.Trim();
var (logFacVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogFacility);
var logFacility = string.IsNullOrWhiteSpace(logFacVal) ? null : logFacVal.Trim();
var (leaseMaxVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpLeaseMax);
var dhcpLeaseMax = TryParseInt(leaseMaxVal);
var (negTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NegTtl);
var negTtl = TryParseInt(negTtlVal);
var (maxTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MaxTtl);
var maxTtl = TryParseInt(maxTtlVal);
var (maxCacheTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MaxCacheTtl);
var maxCacheTtl = TryParseInt(maxCacheTtlVal);
var (minCacheTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MinCacheTtl);
var minCacheTtl = TryParseInt(minCacheTtlVal);
var (dhcpTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpTtl);
var dhcpTtl = TryParseInt(dhcpTtlVal);
return new EffectiveDnsmasqConfig(
noHosts, addnHosts,
serverLocal, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines, resolvFiles,
expandHosts, bogusPriv, strictOrder, noResolv, domainNeeded, noPoll, bindInterfaces, noNegcache, dhcpAuthoritative, leasefileRo,
dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, dhcpLeaseMax,
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl
);
}
private static EffectiveConfigSources BuildEffectiveConfigSources(IReadOnlyList<string> paths, IReadOnlyDictionary<string, string[]> pathToLines, string? managedFilePath)
{
var (_, noHostsSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoHosts, managedFilePath);
var addnHostsWithSource = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(paths, pathToLines, managedFilePath);
var serverLocalWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys, managedFilePath);
var addressWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Address, managedFilePath);
var interfacesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Interface, managedFilePath);
var listenAddressesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ListenAddress, managedFilePath);
var exceptInterfacesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ExceptInterface, managedFilePath);
var dhcpRangesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpRange, managedFilePath);
var dhcpHostLinesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHost, managedFilePath);
var dhcpOptionLinesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOption, managedFilePath);
var resolvFilesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ResolvFile, managedFilePath);
var (_, expandHostsSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ExpandHosts, managedFilePath);
var (_, bogusPrivSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.BogusPriv, managedFilePath);
var (_, strictOrderSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.StrictOrder, managedFilePath);
var (_, noResolvSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoResolv, managedFilePath);
var (_, domainNeededSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DomainNeeded, managedFilePath);
var (_, noPollSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoPoll, managedFilePath);
var (_, bindInterfacesSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.BindInterfaces, managedFilePath);
var (_, noNegcacheSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoNegcache, managedFilePath);
var (_, dhcpAuthoritativeSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpAuthoritative, managedFilePath);
var (_, leasefileRoSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LeasefileRo, managedFilePath);
var (_, dhcpLeaseFilePathSource) = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFilesWithSource(paths, pathToLines, managedFilePath);
var (_, cacheSizeSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.CacheSize, managedFilePath);
var (_, portSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Port, managedFilePath);
var (_, localTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LocalTtl, managedFilePath);
var (_, pidFilePathSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.PidFile, managedFilePath);
var (_, userSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.User, managedFilePath);
var (_, groupSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Group, managedFilePath);
var (_, logFacilitySource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogFacility, managedFilePath);
var (_, dhcpLeaseMaxSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpLeaseMax, managedFilePath);
var (_, negTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NegTtl, managedFilePath);
var (_, maxTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MaxTtl, managedFilePath);
var (_, maxCacheTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MaxCacheTtl, managedFilePath);
var (_, minCacheTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MinCacheTtl, managedFilePath);
var (_, dhcpTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpTtl, managedFilePath);
return new EffectiveConfigSources(
noHostsSource, addnHostsWithSource.Select(t => new PathWithSource(t.Path, t.Source)).ToList(),
serverLocalWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
addressWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
interfacesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
listenAddressesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
exceptInterfacesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpRangesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpHostLinesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpOptionLinesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
resolvFilesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
expandHostsSource, bogusPrivSource, strictOrderSource, noResolvSource, domainNeededSource, noPollSource,
bindInterfacesSource, noNegcacheSource, dhcpAuthoritativeSource, leasefileRoSource,
dhcpLeaseFilePathSource, cacheSizeSource, portSource, localTtlSource, pidFilePathSource, userSource, groupSource,
logFacilitySource, dhcpLeaseMaxSource, negTtlSource, maxTtlSource, maxCacheTtlSource, minCacheTtlSource, dhcpTtlSource
);
}
private static ManagedConfigContent BuildManagedContent(IReadOnlyDictionary<string, string[]> pathToLines, string? managedFilePath)
{
if (string.IsNullOrEmpty(managedFilePath))
return new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), "");
var canonical = Path.GetFullPath(managedFilePath);
if (!pathToLines.TryGetValue(canonical, out var lines))
return new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), "");
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
var effectiveHostsPath = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
return new ManagedConfigContent(configLines, effectiveHostsPath);
}
private static List<DhcpHostEntry> BuildDhcpHostEntries(DnsmasqConfigSet set, IReadOnlyDictionary<string, string[]> pathToLines)
{
var result = new List<DhcpHostEntry>();
foreach (var file in set.Files)
{
var canonical = Path.GetFullPath(file.Path);
if (!pathToLines.TryGetValue(canonical, out var lines))
continue;
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
foreach (var dhcpLine in configLines.OfType<DhcpHostLine>())
{
var entry = dhcpLine.DhcpHost;
entry.SourcePath = file.Path;
entry.IsEditable = file.IsManaged;
result.Add(entry);
}
}
return result;
}
private static EffectiveDnsmasqConfig CreateDefaultEffectiveConfig() =>
new(
NoHosts: false, AddnHostsPaths: Array.Empty<string>(),
ServerLocalValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(), ExceptInterfaces: Array.Empty<string>(), DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(), DhcpOptionLines: Array.Empty<string>(), ResolvFiles: Array.Empty<string>(),
ExpandHosts: false, BogusPriv: false, StrictOrder: false, NoResolv: false, DomainNeeded: false, NoPoll: false,
BindInterfaces: false, NoNegcache: false, DhcpAuthoritative: false, LeasefileRo: false,
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
LogFacility: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null
);
private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() =>
new(
NoHosts: null, AddnHostsPaths: Array.Empty<PathWithSource>(),
ServerLocalValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
Interfaces: Array.Empty<ValueWithSource>(), ListenAddresses: Array.Empty<ValueWithSource>(),
ExceptInterfaces: Array.Empty<ValueWithSource>(), DhcpRanges: Array.Empty<ValueWithSource>(),
DhcpHostLines: Array.Empty<ValueWithSource>(), DhcpOptionLines: Array.Empty<ValueWithSource>(),
ResolvFiles: Array.Empty<ValueWithSource>(),
ExpandHosts: null, BogusPriv: null, StrictOrder: null, NoResolv: null, DomainNeeded: null, NoPoll: null,
BindInterfaces: null, NoNegcache: null, DhcpAuthoritative: null, LeasefileRo: null,
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
LogFacility: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null
);
private static int? TryParseInt(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return int.TryParse(value.Trim(), out var n) ? n : null;
}
public void Dispose()
{
_watcherMain?.Dispose();
_watcherMain = null;
_watcherManaged?.Dispose();
_watcherManaged = null;
}
}
@@ -10,11 +10,13 @@ namespace DnsmasqWebUI.Services;
public class DnsmasqConfigService : IDnsmasqConfigService
{
private readonly IDnsmasqConfigSetService _configSetService;
private readonly IConfigSetCache _configSetCache;
private readonly ILogger<DnsmasqConfigService> _logger;
public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, ILogger<DnsmasqConfigService> logger)
public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, IConfigSetCache configSetCache, ILogger<DnsmasqConfigService> logger)
{
_configSetService = configSetService;
_configSetCache = configSetCache;
_logger = logger;
}
@@ -26,27 +28,13 @@ public class DnsmasqConfigService : IDnsmasqConfigService
public async Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default)
{
var set = await _configSetService.GetConfigSetAsync(ct);
if (string.IsNullOrEmpty(set.ManagedFilePath))
var snapshot = await _configSetCache.GetSnapshotAsync(ct);
if (string.IsNullOrEmpty(snapshot.Set.ManagedFilePath))
{
_logger.LogDebug("No managed file path (no conf-dir in main config); returning empty dhcp hosts");
return Array.Empty<DhcpHostEntry>();
}
var allEntries = new List<DhcpHostEntry>();
foreach (var file in set.Files)
{
if (!File.Exists(file.Path))
continue;
var lines = await File.ReadAllLinesAsync(file.Path, Encoding.UTF8, ct);
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
var entries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
foreach (var e in entries)
{
e.SourcePath = file.Path;
e.IsEditable = file.IsManaged;
}
allEntries.AddRange(entries);
}
var allEntries = snapshot.DhcpHostEntries.ToList();
AssignStableIds(allEntries);
return allEntries;
}
@@ -151,6 +139,8 @@ public class DnsmasqConfigService : IDnsmasqConfigService
await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct);
File.Move(tmpPath, path, overwrite: true);
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(configLines, effectiveHostsPathDhcp));
_logger.LogInformation("Wrote managed config file: {Path}", path);
}
@@ -179,20 +169,10 @@ public class DnsmasqConfigService : IDnsmasqConfigService
public async Task<ManagedConfigContent> ReadManagedConfigAsync(CancellationToken ct = default)
{
var path = await GetManagedFilePathAsync(ct);
if (string.IsNullOrEmpty(path))
var snapshot = await _configSetCache.GetSnapshotAsync(ct);
if (string.IsNullOrEmpty(snapshot.Set.ManagedFilePath))
return new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), "");
if (!File.Exists(path))
{
_logger.LogWarning("Managed config file not found: {Path}", path);
return new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), "");
}
var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct);
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
var effectiveHostsPath = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
return new ManagedConfigContent(configLines, effectiveHostsPath);
return snapshot.ManagedContent;
}
public async Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default)
@@ -214,6 +194,8 @@ public class DnsmasqConfigService : IDnsmasqConfigService
await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct);
File.Move(tmpPath, path, overwrite: true);
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(list, effectiveHostsPath));
_logger.LogInformation("Wrote managed config file: {Path}", path);
}
}
@@ -1,231 +1,55 @@
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services;
public class DnsmasqConfigSetService : IDnsmasqConfigSetService
{
private readonly DnsmasqOptions _options;
private readonly IConfigSetCache _cache;
public DnsmasqConfigSetService(IOptions<DnsmasqOptions> options)
public DnsmasqConfigSetService(IConfigSetCache cache)
{
_options = options.Value;
_cache = cache;
}
public Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default) =>
Task.FromResult(GetConfigSet());
public async Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default)
{
var snapshot = await _cache.GetSnapshotAsync(ct);
return snapshot.Set;
}
/// <summary>Leases path discovered from the config set (dhcp-leasefile= or dhcp-lease=; last wins). Null if main config missing or no directive found.</summary>
public string? GetLeasesPath()
{
var set = GetConfigSet();
if (set.Files.Count == 0)
return null;
var paths = set.Files.Select(f => f.Path).ToList();
return DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
return GetSnapshot().Config.DhcpLeaseFilePath;
}
/// <summary>Effective config plus source per field so the UI can show "from X (readonly)" and why a flag cannot be unset.</summary>
public (EffectiveDnsmasqConfig Config, EffectiveConfigSources Sources) GetEffectiveConfigWithSources()
{
var set = GetConfigSet();
if (set.Files.Count == 0)
return (CreateDefaultEffectiveConfig(), CreateDefaultEffectiveConfigSources());
var paths = set.Files.Select(f => f.Path).ToList();
var config = GetEffectiveConfig();
var sources = BuildEffectiveConfigSources(paths, set.ManagedFilePath);
return (config, sources);
var snapshot = GetSnapshot();
return (snapshot.Config, snapshot.Sources);
}
/// <summary>Effective config from the config set (single-value and flag options; last/any wins).</summary>
public EffectiveDnsmasqConfig GetEffectiveConfig()
{
var set = GetConfigSet();
if (set.Files.Count == 0)
return CreateDefaultEffectiveConfig();
var paths = set.Files.Select(f => f.Path).ToList();
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
var serverLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, new[] { "server", "local" });
var addressValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "address");
var interfaces = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "interface");
var listenAddresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "listen-address");
var exceptInterfaces = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "except-interface");
var dhcpRanges = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "dhcp-range");
var dhcpHostLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "dhcp-host");
var dhcpOptionLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "dhcp-option");
var resolvFiles = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "resolv-file");
var expandHosts = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "expand-hosts");
var bogusPriv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "bogus-priv");
var strictOrder = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "strict-order");
var noResolv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "no-resolv");
var domainNeeded = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "domain-needed");
var noPoll = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "no-poll");
var bindInterfaces = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "bind-interfaces");
var noNegcache = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "no-negcache");
var dhcpAuthoritative = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "dhcp-authoritative");
var leasefileRo = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, "leasefile-ro");
var dhcpLeaseFilePath = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
var (cacheVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "cache-size");
var cacheSize = TryParseInt(cacheVal);
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "port");
var port = TryParseInt(portVal);
var (localTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "local-ttl");
var localTtl = TryParseInt(localTtlVal);
var (pidVal, pidDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "pid-file");
var pidFilePath = DnsmasqConfIncludeParser.ResolvePath(pidVal, pidDir);
var (userVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "user");
var user = string.IsNullOrWhiteSpace(userVal) ? null : userVal.Trim();
var (groupVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "group");
var group = string.IsNullOrWhiteSpace(groupVal) ? null : groupVal.Trim();
var (logFacVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "log-facility");
var logFacility = string.IsNullOrWhiteSpace(logFacVal) ? null : logFacVal.Trim();
var (leaseMaxVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "dhcp-lease-max");
var dhcpLeaseMax = TryParseInt(leaseMaxVal);
var (negTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "neg-ttl");
var negTtl = TryParseInt(negTtlVal);
var (maxTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "max-ttl");
var maxTtl = TryParseInt(maxTtlVal);
var (maxCacheTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "max-cache-ttl");
var maxCacheTtl = TryParseInt(maxCacheTtlVal);
var (minCacheTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "min-cache-ttl");
var minCacheTtl = TryParseInt(minCacheTtlVal);
var (dhcpTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "dhcp-ttl");
var dhcpTtl = TryParseInt(dhcpTtlVal);
return new EffectiveDnsmasqConfig(
noHosts, addnHosts,
serverLocal, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines, resolvFiles,
expandHosts, bogusPriv, strictOrder, noResolv, domainNeeded, noPoll, bindInterfaces, noNegcache, dhcpAuthoritative, leasefileRo,
dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, dhcpLeaseMax,
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl
);
}
private static EffectiveDnsmasqConfig CreateDefaultEffectiveConfig() =>
new(
NoHosts: false, AddnHostsPaths: Array.Empty<string>(),
ServerLocalValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(), ExceptInterfaces: Array.Empty<string>(), DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(), DhcpOptionLines: Array.Empty<string>(), ResolvFiles: Array.Empty<string>(),
ExpandHosts: false, BogusPriv: false, StrictOrder: false, NoResolv: false, DomainNeeded: false, NoPoll: false,
BindInterfaces: false, NoNegcache: false, DhcpAuthoritative: false, LeasefileRo: false,
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
LogFacility: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null
);
private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() =>
new(
NoHosts: null, AddnHostsPaths: Array.Empty<PathWithSource>(),
ServerLocalValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
Interfaces: Array.Empty<ValueWithSource>(), ListenAddresses: Array.Empty<ValueWithSource>(),
ExceptInterfaces: Array.Empty<ValueWithSource>(), DhcpRanges: Array.Empty<ValueWithSource>(),
DhcpHostLines: Array.Empty<ValueWithSource>(), DhcpOptionLines: Array.Empty<ValueWithSource>(),
ResolvFiles: Array.Empty<ValueWithSource>(),
ExpandHosts: null, BogusPriv: null, StrictOrder: null, NoResolv: null, DomainNeeded: null, NoPoll: null,
BindInterfaces: null, NoNegcache: null, DhcpAuthoritative: null, LeasefileRo: null,
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
LogFacility: null, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null
);
private static EffectiveConfigSources BuildEffectiveConfigSources(IReadOnlyList<string> paths, string managedFilePath)
{
var (_, noHostsSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "no-hosts", managedFilePath);
var addnHostsWithSource = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(paths, managedFilePath);
var serverLocalWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, new[] { "server", "local" }, managedFilePath);
var addressWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "address", managedFilePath);
var interfacesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "interface", managedFilePath);
var listenAddressesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "listen-address", managedFilePath);
var exceptInterfacesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "except-interface", managedFilePath);
var dhcpRangesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "dhcp-range", managedFilePath);
var dhcpHostLinesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "dhcp-host", managedFilePath);
var dhcpOptionLinesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "dhcp-option", managedFilePath);
var resolvFilesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, "resolv-file", managedFilePath);
var (_, expandHostsSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "expand-hosts", managedFilePath);
var (_, bogusPrivSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "bogus-priv", managedFilePath);
var (_, strictOrderSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "strict-order", managedFilePath);
var (_, noResolvSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "no-resolv", managedFilePath);
var (_, domainNeededSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "domain-needed", managedFilePath);
var (_, noPollSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "no-poll", managedFilePath);
var (_, bindInterfacesSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "bind-interfaces", managedFilePath);
var (_, noNegcacheSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "no-negcache", managedFilePath);
var (_, dhcpAuthoritativeSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "dhcp-authoritative", managedFilePath);
var (_, leasefileRoSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, "leasefile-ro", managedFilePath);
var (_, dhcpLeaseFilePathSource) = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFilesWithSource(paths, managedFilePath);
var (_, cacheSizeSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "cache-size", managedFilePath);
var (_, portSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "port", managedFilePath);
var (_, localTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "local-ttl", managedFilePath);
var (_, pidFilePathSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "pid-file", managedFilePath);
var (_, userSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "user", managedFilePath);
var (_, groupSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "group", managedFilePath);
var (_, logFacilitySource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "log-facility", managedFilePath);
var (_, dhcpLeaseMaxSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "dhcp-lease-max", managedFilePath);
var (_, negTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "neg-ttl", managedFilePath);
var (_, maxTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "max-ttl", managedFilePath);
var (_, maxCacheTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "max-cache-ttl", managedFilePath);
var (_, minCacheTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "min-cache-ttl", managedFilePath);
var (_, dhcpTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, "dhcp-ttl", managedFilePath);
return new EffectiveConfigSources(
noHostsSource, addnHostsWithSource.Select(t => new PathWithSource(t.Path, t.Source)).ToList(),
serverLocalWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
addressWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
interfacesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
listenAddressesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
exceptInterfacesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpRangesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpHostLinesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpOptionLinesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
resolvFilesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
expandHostsSource, bogusPrivSource, strictOrderSource, noResolvSource, domainNeededSource, noPollSource,
bindInterfacesSource, noNegcacheSource, dhcpAuthoritativeSource, leasefileRoSource,
dhcpLeaseFilePathSource, cacheSizeSource, portSource, localTtlSource, pidFilePathSource, userSource, groupSource,
logFacilitySource, dhcpLeaseMaxSource, negTtlSource, maxTtlSource, maxCacheTtlSource, minCacheTtlSource, dhcpTtlSource
);
}
private static int? TryParseInt(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return int.TryParse(value.Trim(), out var n) ? n : null;
return GetSnapshot().Config;
}
/// <summary>Additional hosts paths discovered from the config set (addn-hosts=; cumulative). Empty list if main config missing or no addn-hosts.</summary>
public IReadOnlyList<string> GetAddnHostsPaths()
{
return GetEffectiveConfig().AddnHostsPaths;
return GetSnapshot().Config.AddnHostsPaths;
}
/// <inheritdoc />
public (string? Start, string? End) GetDhcpRange()
{
var set = GetConfigSet();
if (set.Files.Count == 0)
return (null, null);
var paths = set.Files.Select(f => f.Path).ToList();
var (raw, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "dhcp-range");
return ParseDhcpRangeStartEnd(raw);
var snapshot = GetSnapshot();
var ranges = snapshot.Config.DhcpRanges;
var last = ranges.Count > 0 ? ranges[ranges.Count - 1] : null;
return ParseDhcpRangeStartEnd(last);
}
/// <summary>Parses dhcp-range value to (startIp, endIp). Format is typically start,end,mask,lease or tag:...,start,end,...; finds first two IPv4-looking tokens.</summary>
@@ -247,31 +71,6 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
return (start, end);
}
private DnsmasqConfigSet GetConfigSet()
{
var mainPath = _options.MainConfigPath;
if (string.IsNullOrEmpty(mainPath))
return new DnsmasqConfigSet("", "", null, Array.Empty<DnsmasqConfigSetEntry>());
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(
p.Path,
Path.GetFileName(p.Path),
p.Source,
IsManaged: string.Equals(p.Path, managedFilePath, StringComparison.Ordinal)
)).ToList();
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, managedHostsFilePath, files);
}
private ConfigSetSnapshot GetSnapshot() =>
_cache.GetSnapshotAsync(CancellationToken.None).GetAwaiter().GetResult();
}
+252
View File
@@ -0,0 +1,252 @@
using System.Text;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services;
/// <summary>
/// Singleton cache for hosts: managed hosts file + read-only (system + addn-hosts). Invalidates on watchers (managed + system path), staleness, or Invalidate.
/// Call NotifyWeWroteManagedHosts after the app writes the managed hosts file so the cache updates in place and ignores the next watcher event.
/// </summary>
public sealed class HostsCache : IHostsCache, IDisposable
{
private const int StaleCacheSeconds = 120;
private const double SelfWriteIgnoreSeconds = 1.5;
private readonly IConfigSetCache _configSetCache;
private readonly DnsmasqOptions _options;
private readonly ILogger<HostsCache> _logger;
private readonly object _lock = new();
private HostsSnapshot? _snapshot;
private DateTime? _lastReadUtc;
private bool _dirty = true;
private DateTime _lastWriteManagedUtc = DateTime.MinValue;
private string? _lastManagedHostsPath;
private FileSystemWatcher? _watcherManaged;
private FileSystemWatcher? _watcherSystem;
public HostsCache(IConfigSetCache configSetCache, IOptions<DnsmasqOptions> options, ILogger<HostsCache> logger)
{
_configSetCache = configSetCache;
_options = options.Value;
_logger = logger;
}
public void Invalidate()
{
lock (_lock)
_dirty = true;
}
public void NotifyWeWroteManagedHosts(IReadOnlyList<HostEntry> entries)
{
lock (_lock)
{
_lastWriteManagedUtc = DateTime.UtcNow;
if (_snapshot != null)
_snapshot = _snapshot with { ManagedEntries = entries.ToList() };
}
}
public async Task<HostsSnapshot> GetSnapshotAsync(CancellationToken ct = default)
{
return await Task.Run(() => GetSnapshot(ct), ct);
}
private HostsSnapshot GetSnapshot(CancellationToken ct)
{
var configSnapshot = _configSetCache.GetSnapshotAsync(ct).GetAwaiter().GetResult();
var set = configSnapshot.Set;
var effectiveConfig = configSnapshot.Config;
var managedPath = set.ManagedHostsFilePath;
var addnPaths = effectiveConfig.AddnHostsPaths ?? Array.Empty<string>();
var noHosts = effectiveConfig.NoHosts;
lock (_lock)
{
if (!_dirty && _snapshot != null && _lastReadUtc.HasValue)
{
var ageSeconds = (DateTime.UtcNow - _lastReadUtc.Value).TotalSeconds;
if (ageSeconds < StaleCacheSeconds)
return _snapshot;
_dirty = true;
}
TryEnsureWatchers(managedPath);
var managedEntries = ReadManagedEntries(managedPath);
var readOnlyFiles = ReadReadOnlyFiles(managedPath, addnPaths, noHosts, ct);
_lastManagedHostsPath = !string.IsNullOrEmpty(managedPath) ? Path.GetFullPath(managedPath) : null;
_snapshot = new HostsSnapshot(managedEntries, readOnlyFiles);
_lastReadUtc = DateTime.UtcNow;
_dirty = false;
return _snapshot;
}
}
private void TryEnsureWatchers(string? managedPath)
{
if (_watcherManaged != null && _watcherSystem != null)
return;
var managedPathFull = !string.IsNullOrEmpty(managedPath) ? Path.GetFullPath(managedPath) : null;
if (!string.IsNullOrEmpty(managedPathFull))
{
var dir = Path.GetDirectoryName(managedPathFull);
var fileName = Path.GetFileName(managedPathFull);
if (!string.IsNullOrEmpty(dir) && !string.IsNullOrEmpty(fileName) && Directory.Exists(dir))
{
try
{
_watcherManaged ??= CreateWatcher(dir, fileName, OnFileChanged);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not create watcher for managed hosts: {Path}", managedPathFull);
}
}
}
var systemPath = _options.SystemHostsPath?.Trim();
var systemPathFull = !string.IsNullOrEmpty(systemPath) ? Path.GetFullPath(systemPath) : null;
if (!string.IsNullOrEmpty(systemPathFull))
{
var dir = Path.GetDirectoryName(systemPathFull);
var fileName = Path.GetFileName(systemPathFull);
if (!string.IsNullOrEmpty(dir) && !string.IsNullOrEmpty(fileName) && Directory.Exists(dir))
{
try
{
_watcherSystem ??= CreateWatcher(dir, fileName, OnFileChanged);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not create watcher for system hosts: {Path}", systemPathFull);
}
}
}
}
private static FileSystemWatcher CreateWatcher(string dir, string fileName, FileSystemEventHandler onChanged)
{
var w = new FileSystemWatcher(dir)
{
Filter = fileName,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size
};
w.Changed += onChanged;
w.Created += onChanged;
w.EnableRaisingEvents = true;
return w;
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
var fullPath = Path.GetFullPath(e.FullPath);
lock (_lock)
{
if (_lastManagedHostsPath != null && string.Equals(fullPath, _lastManagedHostsPath, StringComparison.Ordinal))
{
var elapsed = (DateTime.UtcNow - _lastWriteManagedUtc).TotalSeconds;
if (elapsed < SelfWriteIgnoreSeconds)
return;
}
_dirty = true;
}
}
private static List<HostEntry> ReadManagedEntries(string? managedPath)
{
if (string.IsNullOrEmpty(managedPath) || !File.Exists(managedPath))
return new List<HostEntry>();
var lines = File.ReadAllLines(managedPath, Encoding.UTF8);
var entries = new List<HostEntry>();
var seenContentIds = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < lines.Length; i++)
{
var entry = HostsFileLineParser.ParseLine(lines[i], i + 1);
if (entry == null) continue;
if (entry.IsPassthrough || string.IsNullOrEmpty(entry.Address))
entry.Id = "line:" + entry.LineNumber;
else
{
var contentId = entry.Address + "|" + string.Join(",", entry.Names.OrderBy(x => x, StringComparer.Ordinal));
entry.Id = seenContentIds.Add(contentId) ? contentId : contentId + ":" + entry.LineNumber;
}
entries.Add(entry);
}
return entries;
}
private List<ReadOnlyHostsFile> ReadReadOnlyFiles(string? managedPath, IReadOnlyList<string> addnPaths, bool noHosts, CancellationToken ct)
{
var result = new List<ReadOnlyHostsFile>();
var managedPathFull = !string.IsNullOrEmpty(managedPath) ? Path.GetFullPath(managedPath) : null;
var systemPath = _options.SystemHostsPath?.Trim();
var systemPathFull = !string.IsNullOrEmpty(systemPath) ? Path.GetFullPath(systemPath) : null;
if (!noHosts && !string.IsNullOrEmpty(systemPathFull) && File.Exists(systemPathFull))
{
try
{
ct.ThrowIfCancellationRequested();
var lines = File.ReadAllLines(systemPathFull, Encoding.UTF8);
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(systemPathFull, entries));
}
catch
{
// Skip unreadable
}
}
foreach (var p in addnPaths)
{
ct.ThrowIfCancellationRequested();
var fullPath = Path.GetFullPath(p);
if (managedPathFull != null && string.Equals(fullPath, managedPathFull, StringComparison.Ordinal))
continue;
if (systemPathFull != null && string.Equals(fullPath, systemPathFull, StringComparison.Ordinal))
continue;
if (!File.Exists(fullPath))
continue;
try
{
var lines = File.ReadAllLines(fullPath, Encoding.UTF8);
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
}
}
return result;
}
public void Dispose()
{
_watcherManaged?.Dispose();
_watcherManaged = null;
_watcherSystem?.Dispose();
_watcherSystem = null;
}
}
+6 -28
View File
@@ -1,5 +1,4 @@
using System.Text;
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
@@ -9,42 +8,20 @@ namespace DnsmasqWebUI.Services;
public class HostsFileService : IHostsFileService
{
private readonly IDnsmasqConfigSetService _configSetService;
private readonly IHostsCache _hostsCache;
private readonly ILogger<HostsFileService> _logger;
public HostsFileService(IDnsmasqConfigSetService configSetService, ILogger<HostsFileService> logger)
public HostsFileService(IDnsmasqConfigSetService configSetService, IHostsCache hostsCache, ILogger<HostsFileService> logger)
{
_configSetService = configSetService;
_hostsCache = hostsCache;
_logger = logger;
}
public async Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default)
{
var set = await _configSetService.GetConfigSetAsync(ct);
var path = set.ManagedHostsFilePath;
if (string.IsNullOrEmpty(path))
return Array.Empty<HostEntry>();
if (!File.Exists(path))
{
_logger.LogDebug("Managed hosts file not found: {Path}", path);
return Array.Empty<HostEntry>();
}
var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct);
var entries = new List<HostEntry>();
var seenContentIds = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < lines.Length; i++)
{
var entry = HostsFileLineParser.ParseLine(lines[i], i + 1);
if (entry == null) continue;
if (entry.IsPassthrough || string.IsNullOrEmpty(entry.Address))
entry.Id = "line:" + entry.LineNumber;
else
{
var contentId = entry.Address + "|" + string.Join(",", entry.Names.OrderBy(x => x, StringComparer.Ordinal));
entry.Id = seenContentIds.Add(contentId) ? contentId : contentId + ":" + entry.LineNumber;
}
entries.Add(entry);
}
return entries;
var snapshot = await _hostsCache.GetSnapshotAsync(ct);
return snapshot.ManagedEntries;
}
public async Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
@@ -61,6 +38,7 @@ public class HostsFileService : IHostsFileService
var lines = entries.Select(HostsFileLineParser.ToLine).ToList();
await File.WriteAllLinesAsync(tmpPath, lines, Encoding.UTF8, ct);
File.Move(tmpPath, path, overwrite: true);
_hostsCache.NotifyWeWroteManagedHosts(entries);
_logger.LogInformation("Wrote managed hosts file: {Path}", path);
}
}