Fix UTF-8 BOM in dnsmasq config/hosts writes so dnsmasq does not fail

- Add DnsmasqFileEncoding.Utf8NoBom for all config and hosts file writes
- Strip BOM from first line when reading main config and managed config
- Prevents 'bad option at line 1' when dnsmasq loads files written by the app
This commit is contained in:
2026-02-05 03:10:09 +10:00
parent b38f278a1f
commit beff128ed9
4 changed files with 48 additions and 10 deletions
@@ -0,0 +1,9 @@
using System.Text;
namespace DnsmasqWebUI.Infrastructure;
/// <summary>Encoding for dnsmasq config and hosts files. UTF-8 without BOM so dnsmasq does not see a BOM as "bad option" on line 1.</summary>
public static class DnsmasqFileEncoding
{
public static readonly Encoding Utf8NoBom = new UTF8Encoding(false);
}
@@ -1,4 +1,5 @@
using System.Text; using System.Text;
using DnsmasqWebUI.Infrastructure;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dhcp; using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.EffectiveConfig; using DnsmasqWebUI.Models.EffectiveConfig;
@@ -82,7 +83,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var dir = Path.GetDirectoryName(managedHostsPath); var dir = Path.GetDirectoryName(managedHostsPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
File.WriteAllText(managedHostsPath, ""); File.WriteAllText(managedHostsPath, "", DnsmasqFileEncoding.Utf8NoBom);
} }
public async Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default) public async Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
@@ -110,7 +111,10 @@ public class DnsmasqConfigService : IDnsmasqConfigService
IReadOnlyList<string> rawLines; IReadOnlyList<string> rawLines;
if (File.Exists(path)) 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 else
rawLines = Array.Empty<string>(); rawLines = Array.Empty<string>();
@@ -136,7 +140,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
output.Add(DnsmasqConfDhcpHostLineParser.ToLine(entry)); output.Add(DnsmasqConfDhcpHostLineParser.ToLine(entry));
var tmpPath = path + ".tmp"; 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); File.Move(tmpPath, path, overwrite: true);
EnsureManagedHostsFileExists(set.ManagedHostsFilePath); EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? ""; var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().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))) foreach (var file in set.Files.Where(f => !f.IsManaged && !string.Equals(f.Path, managedPath, StringComparison.Ordinal)))
{ {
if (!File.Exists(file.Path)) continue; 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); var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
foreach (var dhcp in configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost)) foreach (var dhcp in configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost))
{ {
@@ -191,11 +195,25 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList(); var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList();
var tmpPath = path + ".tmp"; var tmpPath = path + ".tmp";
await File.WriteAllLinesAsync(tmpPath, output, Encoding.UTF8, ct); await File.WriteAllLinesAsync(tmpPath, output, DnsmasqFileEncoding.Utf8NoBom, ct);
File.Move(tmpPath, path, overwrite: true); File.Move(tmpPath, path, overwrite: true);
EnsureManagedHostsFileExists(set.ManagedHostsFilePath); EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? ""; var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(list, effectiveHostsPath)); _configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(list, effectiveHostsPath));
_logger.LogInformation("Wrote managed config file: {Path}", path); _logger.LogInformation("Wrote managed config file: {Path}", path);
} }
/// <summary>Strip UTF-8 BOM from first line if present.</summary>
private static IReadOnlyList<string> StripBomFromFirstLine(IReadOnlyList<string> 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;
}
} }
@@ -1,4 +1,5 @@
using System.Text; using System.Text;
using DnsmasqWebUI.Infrastructure;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.EffectiveConfig; using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Configuration; using DnsmasqWebUI.Configuration;
@@ -48,8 +49,9 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
var confFileLine = "conf-file=" + Path.GetRelativePath(mainDir, managedPath); var confFileLine = "conf-file=" + Path.GetRelativePath(mainDir, managedPath);
var lines = File.Exists(mainFull) var lines = File.Exists(mainFull)
? (await File.ReadAllLinesAsync(mainFull, Encoding.UTF8, cancellationToken)).ToList() ? (await File.ReadAllLinesAsync(mainFull, DnsmasqFileEncoding.Utf8NoBom, cancellationToken)).ToList()
: new List<string>(); : new List<string>();
StripBomFromFirstLine(lines);
// Remove any conf-file= line that points to our managed path (we will add it as the last line). // 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--) 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])) if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1]))
lines.Add(""); lines.Add("");
lines.Add(confFileLine); 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); _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 else
{ {
await File.WriteAllLinesAsync(mainFull, lines, Encoding.UTF8, cancellationToken); await File.WriteAllLinesAsync(mainFull, lines, DnsmasqFileEncoding.Utf8NoBom, cancellationToken);
} }
Directory.CreateDirectory(managedDir); Directory.CreateDirectory(managedDir);
@@ -92,5 +94,14 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
await configService.WriteManagedConfigAsync(Array.Empty<DnsmasqConfLine>(), cancellationToken); await configService.WriteManagedConfigAsync(Array.Empty<DnsmasqConfLine>(), cancellationToken);
} }
/// <summary>Strip UTF-8 BOM from first line if present, so we do not perpetuate it when writing.</summary>
private static void StripBomFromFirstLine(List<string> 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; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
@@ -1,4 +1,4 @@
using System.Text; using DnsmasqWebUI.Infrastructure;
using DnsmasqWebUI.Models.Hosts; using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Parsers; using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions; using DnsmasqWebUI.Services.Abstractions;
@@ -36,7 +36,7 @@ public class HostsFileService : IHostsFileService
var tmpPath = path + ".tmp"; var tmpPath = path + ".tmp";
var lines = entries.Select(HostsFileLineParser.ToLine).ToList(); var lines = entries.Select(HostsFileLineParser.ToLine).ToList();
await File.WriteAllLinesAsync(tmpPath, lines, Encoding.UTF8, ct); await File.WriteAllLinesAsync(tmpPath, lines, DnsmasqFileEncoding.Utf8NoBom, ct);
File.Move(tmpPath, path, overwrite: true); File.Move(tmpPath, path, overwrite: true);
_hostsCache.NotifyWeWroteManagedHosts(entries); _hostsCache.NotifyWeWroteManagedHosts(entries);
_logger.LogInformation("Wrote managed hosts file: {Path}", path); _logger.LogInformation("Wrote managed hosts file: {Path}", path);