diff --git a/src/DnsmasqWebUI/Infrastructure/DnsmasqFileEncoding.cs b/src/DnsmasqWebUI/Infrastructure/DnsmasqFileEncoding.cs new file mode 100644 index 0000000..c9886cd --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/DnsmasqFileEncoding.cs @@ -0,0 +1,9 @@ +using System.Text; + +namespace DnsmasqWebUI.Infrastructure; + +/// Encoding for dnsmasq config and hosts files. UTF-8 without BOM so dnsmasq does not see a BOM as "bad option" on line 1. +public static class DnsmasqFileEncoding +{ + public static readonly Encoding Utf8NoBom = new UTF8Encoding(false); +} diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs index 5759386..0bfae0f 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigService.cs @@ -1,4 +1,5 @@ using System.Text; +using DnsmasqWebUI.Infrastructure; using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Dhcp; using DnsmasqWebUI.Models.EffectiveConfig; @@ -82,7 +83,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService var dir = Path.GetDirectoryName(managedHostsPath); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - File.WriteAllText(managedHostsPath, ""); + File.WriteAllText(managedHostsPath, "", DnsmasqFileEncoding.Utf8NoBom); } public async Task WriteDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default) @@ -110,7 +111,10 @@ public class DnsmasqConfigService : IDnsmasqConfigService IReadOnlyList rawLines; if (File.Exists(path)) - rawLines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct); + { + var read = await File.ReadAllLinesAsync(path, DnsmasqFileEncoding.Utf8NoBom, ct); + rawLines = StripBomFromFirstLine(read); + } else rawLines = Array.Empty(); @@ -136,7 +140,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService output.Add(DnsmasqConfDhcpHostLineParser.ToLine(entry)); var tmpPath = path + ".tmp"; - await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct); + await File.WriteAllLinesAsync(tmpPath, output, DnsmasqFileEncoding.Utf8NoBom, ct); File.Move(tmpPath, path, overwrite: true); EnsureManagedHostsFileExists(set.ManagedHostsFilePath); var effectiveHostsPathDhcp = configLines.OfType().FirstOrDefault()?.AddnHostsPath ?? ""; @@ -152,7 +156,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService foreach (var file in set.Files.Where(f => !f.IsManaged && !string.Equals(f.Path, managedPath, StringComparison.Ordinal))) { if (!File.Exists(file.Path)) continue; - var lines = await File.ReadAllLinesAsync(file.Path, Encoding.UTF8, ct); + var lines = await File.ReadAllLinesAsync(file.Path, DnsmasqFileEncoding.Utf8NoBom, ct); var configLines = DnsmasqConfFileLineParser.ParseFile(lines); foreach (var dhcp in configLines.OfType().Select(c => c.DhcpHost)) { @@ -191,11 +195,25 @@ public class DnsmasqConfigService : IDnsmasqConfigService var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList(); var tmpPath = path + ".tmp"; - await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct); + await File.WriteAllLinesAsync(tmpPath, output, DnsmasqFileEncoding.Utf8NoBom, 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); } + + /// Strip UTF-8 BOM from first line if present. + private static IReadOnlyList StripBomFromFirstLine(IReadOnlyList lines) + { + if (lines.Count == 0) return lines; + var first = lines[0]; + if (first.Length > 0 && first[0] == '\uFEFF') + { + var list = lines.ToList(); + list[0] = first[1..]; + return list; + } + return lines; + } } diff --git a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs index 36e6307..3161fe2 100644 --- a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs +++ b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs @@ -1,4 +1,5 @@ using System.Text; +using DnsmasqWebUI.Infrastructure; using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.EffectiveConfig; using DnsmasqWebUI.Configuration; @@ -48,8 +49,9 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService var confFileLine = "conf-file=" + Path.GetRelativePath(mainDir, managedPath); var lines = File.Exists(mainFull) - ? (await File.ReadAllLinesAsync(mainFull, Encoding.UTF8, cancellationToken)).ToList() + ? (await File.ReadAllLinesAsync(mainFull, DnsmasqFileEncoding.Utf8NoBom, cancellationToken)).ToList() : new List(); + StripBomFromFirstLine(lines); // Remove any conf-file= line that points to our managed path (we will add it as the last line). for (var i = lines.Count - 1; i >= 0; i--) @@ -75,12 +77,12 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(""); lines.Add(confFileLine); - await File.WriteAllLinesAsync(mainFull, lines, Encoding.UTF8, cancellationToken); + await File.WriteAllLinesAsync(mainFull, lines, DnsmasqFileEncoding.Utf8NoBom, cancellationToken); _logger.LogInformation("Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=.", confFileLine, mainFull); } else { - await File.WriteAllLinesAsync(mainFull, lines, Encoding.UTF8, cancellationToken); + await File.WriteAllLinesAsync(mainFull, lines, DnsmasqFileEncoding.Utf8NoBom, cancellationToken); } Directory.CreateDirectory(managedDir); @@ -92,5 +94,14 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService await configService.WriteManagedConfigAsync(Array.Empty(), cancellationToken); } + /// Strip UTF-8 BOM from first line if present, so we do not perpetuate it when writing. + private static void StripBomFromFirstLine(List lines) + { + if (lines.Count == 0) return; + var first = lines[0]; + if (first.Length > 0 && first[0] == '\uFEFF') + lines[0] = first[1..]; + } + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/DnsmasqWebUI/Services/HostsFileService.cs b/src/DnsmasqWebUI/Services/HostsFileService.cs index 840cc5f..78fe5e4 100644 --- a/src/DnsmasqWebUI/Services/HostsFileService.cs +++ b/src/DnsmasqWebUI/Services/HostsFileService.cs @@ -1,4 +1,4 @@ -using System.Text; +using DnsmasqWebUI.Infrastructure; using DnsmasqWebUI.Models.Hosts; using DnsmasqWebUI.Parsers; using DnsmasqWebUI.Services.Abstractions; @@ -36,7 +36,7 @@ public class HostsFileService : IHostsFileService var tmpPath = path + ".tmp"; var lines = entries.Select(HostsFileLineParser.ToLine).ToList(); - await File.WriteAllLinesAsync(tmpPath, lines, Encoding.UTF8, ct); + await File.WriteAllLinesAsync(tmpPath, lines, DnsmasqFileEncoding.Utf8NoBom, ct); File.Move(tmpPath, path, overwrite: true); _hostsCache.NotifyWeWroteManagedHosts(entries); _logger.LogInformation("Wrote managed hosts file: {Path}", path);