diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs index db47d5f..b33a23c 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs @@ -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.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.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); } } diff --git a/src/DnsmasqWebUI/Controllers/HostsController.cs b/src/DnsmasqWebUI/Controllers/HostsController.cs index 36b471e..72f7dda 100644 --- a/src/DnsmasqWebUI/Controllers/HostsController.cs +++ b/src/DnsmasqWebUI/Controllers/HostsController.cs @@ -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 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(); - var managedPath = set.ManagedHostsFilePath != null ? Path.GetFullPath(set.ManagedHostsFilePath) : null; - - var result = new List(); - - // System hosts: only when configured and no-hosts is false (dnsmasq reads it then). - var systemPath = _options.SystemHostsPath?.Trim(); - if (!string.IsNullOrEmpty(systemPath) && !effectiveConfig.NoHosts) - { - var fullPath = Path.GetFullPath(systemPath); - if (System.IO.File.Exists(fullPath)) - { - try - { - var lines = await System.IO.File.ReadAllLinesAsync(fullPath, Encoding.UTF8, ct); - var entries = new List(); - for (var i = 0; i < lines.Length; i++) - { - var entry = HostsFileLineParser.ParseLine(lines[i], i + 1); - if (entry != null) - entries.Add(entry); - } - result.Add(new ReadOnlyHostsFile(fullPath, entries)); - } - catch - { - // Skip unreadable - } - } - } - - var systemPathFull = !string.IsNullOrEmpty(systemPath) ? Path.GetFullPath(systemPath) : null; - - foreach (var p in addnPaths) - { - var fullPath = Path.GetFullPath(p); - if (managedPath != null && string.Equals(fullPath, managedPath, StringComparison.Ordinal)) - continue; - if (systemPathFull != null && string.Equals(fullPath, systemPathFull, StringComparison.Ordinal)) - continue; - if (!System.IO.File.Exists(fullPath)) - continue; - try - { - var lines = await System.IO.File.ReadAllLinesAsync(fullPath, Encoding.UTF8, ct); - var entries = new List(); - for (var i = 0; i < lines.Length; i++) - { - var entry = HostsFileLineParser.ParseLine(lines[i], i + 1); - if (entry != null) - entries.Add(entry); - } - result.Add(new ReadOnlyHostsFile(fullPath, entries)); - } - catch - { - // Skip unreadable files - } - } - return Ok(result); + var snapshot = await _hostsCache.GetSnapshotAsync(ct); + return Ok(snapshot.ReadOnlyFiles); } catch (Exception ex) { diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqConfKeys.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqConfKeys.cs new file mode 100644 index 0000000..7471be8 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqConfKeys.cs @@ -0,0 +1,63 @@ +namespace DnsmasqWebUI.Models.Config; + +/// +/// 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. +/// +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"; + + /// Keys collected for effective config "server/local" multi-value (order preserved). + public static readonly string[] ServerLocalKeys = { Server, Local }; +} diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs index 3c03a80..0045c78 100644 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs @@ -12,52 +12,52 @@ public static class DnsmasqConfOptionRegistry new Dictionary(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 }, }; /// Well-known flag options (no value). public static IReadOnlySet FlagOptions { get; } = new HashSet(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", }; diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs index 9567183..c432bee 100644 --- a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs +++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs @@ -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; + /// Gets lines for a path from pre-read content; used by config set cache to avoid double read. + private static string[] GetLines(string path, IReadOnlyDictionary pathToLines) => + pathToLines.TryGetValue(path, out var L) ? L : Array.Empty(); + /// /// 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; } + /// Same as but uses pre-read content (e.g. from config set cache). + public static bool GetFlagFromConfigFiles(IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary 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; + } + /// /// 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); } + /// Same as but uses pre-read content. + public static (string? Value, string? ConfigFileDir) GetLastValueFromConfigFiles(IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary 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); + } + /// /// Resolves a path value against the config file directory. If value is null/empty or already absolute, returns as-is (or null). /// @@ -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; + } + + /// Same as but uses pre-read content. + public static string? GetDhcpLeaseFilePathFromConfigFiles(IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary 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). /// public static bool GetNoHostsFromConfigFiles(IReadOnlyList configFilePathsInOrder) => - GetFlagFromConfigFiles(configFilePathsInOrder, "no-hosts"); + GetFlagFromConfigFiles(configFilePathsInOrder, DnsmasqConfKeys.NoHosts); + + /// Same as but uses pre-read content. + public static bool GetNoHostsFromConfigFiles(IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary pathToLines) => + GetFlagFromConfigFiles(configFilePathsInOrder, pathToLines, DnsmasqConfKeys.NoHosts); /// /// Reads the given config files in order and returns all addn-hosts= 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; + } + + /// Same as but uses pre-read content. + public static IReadOnlyList GetAddnHostsPathsFromConfigFiles(IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary pathToLines) + { + var result = new List(); + 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); } + /// Same as but uses pre-read content. + public static (string? Value, ConfigValueSource? Source) GetLastValueFromConfigFilesWithSource( + IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary 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); + } + /// Like but returns which file set the flag (for readonly: if not managed, user cannot unset from UI). public static (bool IsSet, ConfigValueSource? Source) GetFlagFromConfigFilesWithSource( IReadOnlyList configFilePathsInOrder, string optionKey, string? managedFilePath) @@ -401,6 +533,31 @@ public static class DnsmasqConfIncludeParser return (false, null); } + /// Same as but uses pre-read content. + public static (bool IsSet, ConfigValueSource? Source) GetFlagFromConfigFilesWithSource( + IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary 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); + } + /// Like but returns which file set the value. public static (string? Path, ConfigValueSource? Source) GetDhcpLeaseFilePathFromConfigFilesWithSource( IReadOnlyList 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); + } + + /// Same as but uses pre-read content. + public static (string? Path, ConfigValueSource? Source) GetDhcpLeaseFilePathFromConfigFilesWithSource( + IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary 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; + } + + /// Same as but uses pre-read content. + public static IReadOnlyList<(string Path, ConfigValueSource Source)> GetAddnHostsPathsFromConfigFilesWithSource( + IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary 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; } + /// Same as but uses pre-read content. + public static IReadOnlyList GetMultiValueFromConfigFiles( + IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary pathToLines, string optionKey) + { + var result = new List(); + 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; + } + /// Collects all values for multiple option keys (e.g. server and local) in file order. Keys matched case-sensitively. public static IReadOnlyList GetMultiValueFromConfigFiles( IReadOnlyList configFilePathsInOrder, IReadOnlyList optionKeys) @@ -511,6 +746,30 @@ public static class DnsmasqConfIncludeParser return result; } + /// Same as but uses pre-read content. + public static IReadOnlyList GetMultiValueFromConfigFiles( + IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary pathToLines, IReadOnlyList optionKeys) + { + var keys = new HashSet(optionKeys.Select(k => k.Trim()).Where(k => k.Length > 0), StringComparer.Ordinal); + if (keys.Count == 0) + return Array.Empty(); + var result = new List(); + 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; + } + /// Like but returns source per value. public static IReadOnlyList<(string Value, ConfigValueSource Source)> GetMultiValueFromConfigFilesWithSource( IReadOnlyList configFilePathsInOrder, string optionKey, string? managedFilePath) @@ -538,6 +797,31 @@ public static class DnsmasqConfIncludeParser return result; } + /// Same as but uses pre-read content. + public static IReadOnlyList<(string Value, ConfigValueSource Source)> GetMultiValueFromConfigFilesWithSource( + IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary 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; + } + /// Like but returns source per value. public static IReadOnlyList<(string Value, ConfigValueSource Source)> GetMultiValueFromConfigFilesWithSource( IReadOnlyList configFilePathsInOrder, IReadOnlyList optionKeys, string? managedFilePath) @@ -564,4 +848,29 @@ public static class DnsmasqConfIncludeParser } return result; } + + /// Same as but uses pre-read content. + public static IReadOnlyList<(string Value, ConfigValueSource Source)> GetMultiValueFromConfigFilesWithSource( + IReadOnlyList configFilePathsInOrder, IReadOnlyDictionary pathToLines, IReadOnlyList optionKeys, string? managedFilePath) + { + var keys = new HashSet(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; + } } diff --git a/src/DnsmasqWebUI/Services/Abstractions/ConfigSetSnapshot.cs b/src/DnsmasqWebUI/Services/Abstractions/ConfigSetSnapshot.cs new file mode 100644 index 0000000..a5d946d --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/ConfigSetSnapshot.cs @@ -0,0 +1,13 @@ +using DnsmasqWebUI.Models.Dhcp; +using DnsmasqWebUI.Models.EffectiveConfig; + +namespace DnsmasqWebUI.Services.Abstractions; + +/// Immutable snapshot from the config set cache: config set, effective config, sources, managed file content, and DHCP host entries (one read per refresh). +public record ConfigSetSnapshot( + DnsmasqConfigSet Set, + EffectiveDnsmasqConfig Config, + EffectiveConfigSources Sources, + ManagedConfigContent ManagedContent, + IReadOnlyList DhcpHostEntries +); diff --git a/src/DnsmasqWebUI/Services/Abstractions/HostsSnapshot.cs b/src/DnsmasqWebUI/Services/Abstractions/HostsSnapshot.cs new file mode 100644 index 0000000..08b8441 --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/HostsSnapshot.cs @@ -0,0 +1,9 @@ +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Services.Abstractions; + +/// Immutable snapshot from the hosts cache: managed hosts file entries and read-only hosts files (system + addn-hosts). +public record HostsSnapshot( + IReadOnlyList ManagedEntries, + IReadOnlyList ReadOnlyFiles +); diff --git a/src/DnsmasqWebUI/Services/Abstractions/IConfigSetCache.cs b/src/DnsmasqWebUI/Services/Abstractions/IConfigSetCache.cs new file mode 100644 index 0000000..0e1adf5 --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/IConfigSetCache.cs @@ -0,0 +1,17 @@ +namespace DnsmasqWebUI.Services.Abstractions; + +/// +/// 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 so the cache updates in place instead of treating the write as an external change. +/// +public interface IConfigSetCache : IApplicationSingleton +{ + /// Returns the current snapshot (config set, effective config, sources, managed content), refreshing from disk if dirty or stale. + Task GetSnapshotAsync(CancellationToken ct = default); + + /// Forces the next to re-read all config files. + void Invalidate(); + + /// 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. + void NotifyWeWroteManagedConfig(ManagedConfigContent newContent); +} diff --git a/src/DnsmasqWebUI/Services/Abstractions/IHostsCache.cs b/src/DnsmasqWebUI/Services/Abstractions/IHostsCache.cs new file mode 100644 index 0000000..25912ed --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/IHostsCache.cs @@ -0,0 +1,14 @@ +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Services.Abstractions; + +/// +/// Singleton cache for hosts: managed hosts file + read-only (system + addn-hosts). Invalidates on watchers, staleness, or Invalidate. +/// Call after the app writes the managed hosts file so the cache updates in place and ignores the next watcher event. +/// +public interface IHostsCache : IApplicationSingleton +{ + Task GetSnapshotAsync(CancellationToken ct = default); + void Invalidate(); + void NotifyWeWroteManagedHosts(IReadOnlyList entries); +} diff --git a/src/DnsmasqWebUI/Services/ConfigSetCache.cs b/src/DnsmasqWebUI/Services/ConfigSetCache.cs new file mode 100644 index 0000000..72615ca --- /dev/null +++ b/src/DnsmasqWebUI/Services/ConfigSetCache.cs @@ -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; + +/// +/// 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. +/// +public sealed class ConfigSetCache : IConfigSetCache, IDisposable +{ + private const int StaleCacheSeconds = 120; + private const double SelfWriteIgnoreSeconds = 1.5; + + private readonly DnsmasqOptions _options; + private readonly ILogger _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 options, ILogger 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 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()); + var config = CreateDefaultEffectiveConfig(); + var sources = CreateDefaultEffectiveConfigSources(); + var managedContent = new ManagedConfigContent(Array.Empty(), ""); + return new ConfigSetSnapshot(set, config, sources, managedContent, Array.Empty()); + } + + 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 ReadAllPaths(IReadOnlyList paths, CancellationToken ct) + { + var dict = new Dictionary(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(); + continue; + } + try + { + dict[canonical] = File.ReadAllLines(path, Encoding.UTF8); + } + catch + { + dict[canonical] = Array.Empty(); + } + } + return dict; + } + + private static EffectiveDnsmasqConfig BuildEffectiveConfig(IReadOnlyList paths, IReadOnlyDictionary 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 paths, IReadOnlyDictionary 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 pathToLines, string? managedFilePath) + { + if (string.IsNullOrEmpty(managedFilePath)) + return new ManagedConfigContent(Array.Empty(), ""); + var canonical = Path.GetFullPath(managedFilePath); + if (!pathToLines.TryGetValue(canonical, out var lines)) + return new ManagedConfigContent(Array.Empty(), ""); + var configLines = DnsmasqConfFileLineParser.ParseFile(lines); + var effectiveHostsPath = configLines.OfType().FirstOrDefault()?.AddnHostsPath ?? ""; + return new ManagedConfigContent(configLines, effectiveHostsPath); + } + + private static List BuildDhcpHostEntries(DnsmasqConfigSet set, IReadOnlyDictionary pathToLines) + { + var result = new List(); + 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()) + { + 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(), + ServerLocalValues: Array.Empty(), AddressValues: Array.Empty(), Interfaces: Array.Empty(), + ListenAddresses: Array.Empty(), ExceptInterfaces: Array.Empty(), DhcpRanges: Array.Empty(), + DhcpHostLines: Array.Empty(), DhcpOptionLines: Array.Empty(), ResolvFiles: Array.Empty(), + 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(), + ServerLocalValues: Array.Empty(), AddressValues: Array.Empty(), + Interfaces: Array.Empty(), ListenAddresses: Array.Empty(), + ExceptInterfaces: Array.Empty(), DhcpRanges: Array.Empty(), + DhcpHostLines: Array.Empty(), DhcpOptionLines: Array.Empty(), + ResolvFiles: Array.Empty(), + 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; + } +} diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs index 93c3ec8..5759386 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs @@ -10,11 +10,13 @@ namespace DnsmasqWebUI.Services; public class DnsmasqConfigService : IDnsmasqConfigService { private readonly IDnsmasqConfigSetService _configSetService; + private readonly IConfigSetCache _configSetCache; private readonly ILogger _logger; - public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, ILogger logger) + public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, IConfigSetCache configSetCache, ILogger logger) { _configSetService = configSetService; + _configSetCache = configSetCache; _logger = logger; } @@ -26,27 +28,13 @@ public class DnsmasqConfigService : IDnsmasqConfigService public async Task> 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(); } - var allEntries = new List(); - 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().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().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 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(), ""); - - if (!File.Exists(path)) - { - _logger.LogWarning("Managed config file not found: {Path}", path); - return new ManagedConfigContent(Array.Empty(), ""); - } - - var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct); - var configLines = DnsmasqConfFileLineParser.ParseFile(lines); - var effectiveHostsPath = configLines.OfType().FirstOrDefault()?.AddnHostsPath ?? ""; - return new ManagedConfigContent(configLines, effectiveHostsPath); + return snapshot.ManagedContent; } public async Task WriteManagedConfigAsync(IReadOnlyList 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().FirstOrDefault()?.AddnHostsPath ?? ""; + _configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(list, effectiveHostsPath)); _logger.LogInformation("Wrote managed config file: {Path}", path); } } diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs index bdb19f5..259e2d1 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs @@ -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 options) + public DnsmasqConfigSetService(IConfigSetCache cache) { - _options = options.Value; + _cache = cache; } - public Task GetConfigSetAsync(CancellationToken ct = default) => - Task.FromResult(GetConfigSet()); + public async Task GetConfigSetAsync(CancellationToken ct = default) + { + var snapshot = await _cache.GetSnapshotAsync(ct); + return snapshot.Set; + } /// Leases path discovered from the config set (dhcp-leasefile= or dhcp-lease=; last wins). Null if main config missing or no directive found. 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; } /// Effective config plus source per field so the UI can show "from X (readonly)" and why a flag cannot be unset. 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); } /// Effective config from the config set (single-value and flag options; last/any wins). 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(), - ServerLocalValues: Array.Empty(), AddressValues: Array.Empty(), Interfaces: Array.Empty(), - ListenAddresses: Array.Empty(), ExceptInterfaces: Array.Empty(), DhcpRanges: Array.Empty(), - DhcpHostLines: Array.Empty(), DhcpOptionLines: Array.Empty(), ResolvFiles: Array.Empty(), - 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(), - ServerLocalValues: Array.Empty(), AddressValues: Array.Empty(), - Interfaces: Array.Empty(), ListenAddresses: Array.Empty(), - ExceptInterfaces: Array.Empty(), DhcpRanges: Array.Empty(), - DhcpHostLines: Array.Empty(), DhcpOptionLines: Array.Empty(), - ResolvFiles: Array.Empty(), - 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 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; } /// Additional hosts paths discovered from the config set (addn-hosts=; cumulative). Empty list if main config missing or no addn-hosts. public IReadOnlyList GetAddnHostsPaths() { - return GetEffectiveConfig().AddnHostsPaths; + return GetSnapshot().Config.AddnHostsPaths; } /// 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); } /// Parses dhcp-range value to (startIp, endIp). Format is typically start,end,mask,lease or tag:...,start,end,...; finds first two IPv4-looking tokens. @@ -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()); - - 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(); } diff --git a/src/DnsmasqWebUI/Services/HostsCache.cs b/src/DnsmasqWebUI/Services/HostsCache.cs new file mode 100644 index 0000000..5a6c72f --- /dev/null +++ b/src/DnsmasqWebUI/Services/HostsCache.cs @@ -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; + +/// +/// 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. +/// +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 _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 options, ILogger logger) + { + _configSetCache = configSetCache; + _options = options.Value; + _logger = logger; + } + + public void Invalidate() + { + lock (_lock) + _dirty = true; + } + + public void NotifyWeWroteManagedHosts(IReadOnlyList entries) + { + lock (_lock) + { + _lastWriteManagedUtc = DateTime.UtcNow; + if (_snapshot != null) + _snapshot = _snapshot with { ManagedEntries = entries.ToList() }; + } + } + + public async Task 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(); + 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 ReadManagedEntries(string? managedPath) + { + if (string.IsNullOrEmpty(managedPath) || !File.Exists(managedPath)) + return new List(); + + var lines = File.ReadAllLines(managedPath, Encoding.UTF8); + var entries = new List(); + var seenContentIds = new HashSet(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 ReadReadOnlyFiles(string? managedPath, IReadOnlyList addnPaths, bool noHosts, CancellationToken ct) + { + var result = new List(); + 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(); + 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(); + 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; + } +} diff --git a/src/DnsmasqWebUI/Services/HostsFileService.cs b/src/DnsmasqWebUI/Services/HostsFileService.cs index aefce6b..840cc5f 100644 --- a/src/DnsmasqWebUI/Services/HostsFileService.cs +++ b/src/DnsmasqWebUI/Services/HostsFileService.cs @@ -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 _logger; - public HostsFileService(IDnsmasqConfigSetService configSetService, ILogger logger) + public HostsFileService(IDnsmasqConfigSetService configSetService, IHostsCache hostsCache, ILogger logger) { _configSetService = configSetService; + _hostsCache = hostsCache; _logger = logger; } public async Task> ReadAsync(CancellationToken ct = default) { - var set = await _configSetService.GetConfigSetAsync(ct); - var path = set.ManagedHostsFilePath; - if (string.IsNullOrEmpty(path)) - return Array.Empty(); - if (!File.Exists(path)) - { - _logger.LogDebug("Managed hosts file not found: {Path}", path); - return Array.Empty(); - } - var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct); - var entries = new List(); - var seenContentIds = new HashSet(StringComparer.Ordinal); - for (var i = 0; i < lines.Length; i++) - { - 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 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); } }