Options validation, CORS, HTTPS, HttpClient factory, config error output

- DnsmasqOptionsValidator: fail-fast at startup if MainConfigPath/HostsPath
  missing or files do not exist; clear stderr message before exit
- Program: AddOptions + ValidateOnStart; catch OptionsValidationException,
  print prominent error and Environment.Exit(1)
- CORS: configurable via Cors:Enabled, Cors:PolicyName, AllowedOrigins, etc.
  (appsettings + env); default disabled
- HTTPS: ForwardedHeaders (when enabled) and Https:UseRedirectAndHsts
  (appsettings + env); default disabled
- HttpClient: IHttpClientFactory + SameHostBaseAddressHandler for same-host
  API calls; default client uses Options.DefaultName
- publish-self-contained.sh: platform auto-detect, clean before publish,
  --no-clean option, RIDs including ubuntu.*-x64/arm64, linux-musl-*
- prepare-test-mount.sh: stop/tidy options
- EnsureManagedConfigHostedService / DnsmasqConfigSetService: managed
  config path and conf-file= handling
- appsettings: ForwardedHeaders, Https, Cors sections; Dnsmasq defaults

Note: Dockerfile.dnsmasq removed (may need restore for app+dnsmasq image)
This commit is contained in:
2026-01-31 22:30:18 +10:00
parent 40fbf14327
commit 5bd9eaf55d
12 changed files with 438 additions and 90 deletions
@@ -0,0 +1,26 @@
using System.Net;
namespace DnsmasqWebUI.Http;
/// <summary>
/// DelegatingHandler that rewrites relative request URIs to the current request's scheme, host, and path base.
/// Used so Blazor/API callers can use relative paths (e.g. "/api/status") and hit the same host.
/// Resolved in the same scope as the code that requested the HttpClient (e.g. Blazor component),
/// so IHttpContextAccessor has the current request when available.
/// </summary>
public sealed class SameHostBaseAddressHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is { IsAbsoluteUri: false })
{
var context = httpContextAccessor.HttpContext;
var baseUri = context != null
? new Uri($"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value ?? ""}")
: new Uri("http://localhost", UriKind.Absolute);
request.RequestUri = new Uri(baseUri, request.RequestUri);
}
return base.SendAsync(request, cancellationToken);
}
}
+3 -3
View File
@@ -3,7 +3,7 @@ namespace DnsmasqWebUI.Options;
/// <summary>
/// Configuration for dnsmasq paths and reload/status commands.
/// File permissions: the app must be able to read MainConfigPath and the conf-dir (or conf-file) target,
/// and to create/update the managed config file and HostsPath. In the test harness (Dockerfile.dnsmasq)
/// and to create/update the managed config file and HostsPath. In the Docker image (Dockerfile)
/// app and dnsmasq run in one container as root, so this works. When the UI runs in a container and
/// dnsmasq is on the host, bind-mount the host config dir (e.g. /etc/dnsmasq.d) into the container;
/// the container process typically needs to run as root (or the host dir must be writable by the
@@ -17,10 +17,10 @@ public class DnsmasqOptions
/// <summary>Configuration section name (e.g. "Dnsmasq" for appsettings and Dnsmasq__* env vars).</summary>
public const string SectionName = "Dnsmasq";
/// <summary>Path to the main dnsmasq config (e.g. /etc/dnsmasq.conf). Read only for discovery of conf-file/conf-dir via DnsmasqConfIncludeParser. App may append conf-dir= if missing; process must have write access for that.</summary>
/// <summary>Path to the main dnsmasq config (e.g. /etc/dnsmasq.conf). App appends conf-file= at the end if missing so the managed file is included; process must have write access.</summary>
public string MainConfigPath { get; set; } = "";
/// <summary>Filename we create in the first conf-dir (e.g. zz-dnsmasq-webui.conf) so it loads last after other conf-dir files. Must sort after any other files in that dir (e.g. dhcp.conf) so our addn-hosts and DHCP hosts win. Managed file content parsed with DnsmasqConfFileLineParser.</summary>
/// <summary>Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in &lt;main-config-dir&gt;/dnsmasq.d/ and included via conf-file= at the end of the main config so it loads last. Managed file content parsed with DnsmasqConfFileLineParser.</summary>
public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf";
/// <summary>Path we write as addn-hosts= in the managed file; HostsFileService reads/writes this path. Process must have read/write access.</summary>
@@ -0,0 +1,49 @@
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Options;
/// <summary>
/// Validates required dnsmasq options at startup. If config is missing or default paths don't point at existing files,
/// the application exits with a detailed error instead of failing later at runtime.
/// </summary>
public sealed class DnsmasqOptionsValidator : IValidateOptions<DnsmasqOptions>
{
public ValidateOptionsResult Validate(string? name, DnsmasqOptions options)
{
if (options == null)
return ValidateOptionsResult.Fail("Dnsmasq options are not configured. Add a 'Dnsmasq' section in appsettings.json or set Dnsmasq__* environment variables.");
var failures = new List<string>();
if (string.IsNullOrWhiteSpace(options.MainConfigPath))
{
failures.Add("Dnsmasq:MainConfigPath is required. Set it in appsettings.json (e.g. \"MainConfigPath\": \"/etc/dnsmasq.conf\") or via the Dnsmasq__MainConfigPath environment variable.");
}
else
{
var mainPath = Path.GetFullPath(options.MainConfigPath.Trim());
if (!File.Exists(mainPath))
{
failures.Add($"Main dnsmasq config file not found: {mainPath}. Ensure Dnsmasq:MainConfigPath points to an existing dnsmasq config file, or create the file. Override with Dnsmasq__MainConfigPath if using a different path.");
}
}
if (string.IsNullOrWhiteSpace(options.HostsPath))
{
failures.Add("Dnsmasq:HostsPath is required. Set it in appsettings.json (e.g. \"HostsPath\": \"/etc/hosts\") or via the Dnsmasq__HostsPath environment variable.");
}
else
{
var hostsPath = Path.GetFullPath(options.HostsPath.Trim());
if (!File.Exists(hostsPath))
{
failures.Add($"Hosts file not found: {hostsPath}. Ensure Dnsmasq:HostsPath points to an existing hosts file (e.g. /etc/hosts). Override with Dnsmasq__HostsPath if using a different path.");
}
}
if (failures.Count == 0)
return ValidateOptionsResult.Success;
return ValidateOptionsResult.Fail(failures);
}
}
+87 -11
View File
@@ -1,23 +1,27 @@
using System.Net;
using DnsmasqWebUI.Components;
using DnsmasqWebUI.Extensions;
using DnsmasqWebUI.Options;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
// CreateBuilder(args) loads config in order: appsettings.json, appsettings.{Environment}.json,
// for env vars; override Dnsmasq options via e.g. Dnsmasq__ReloadCommand= or --Dnsmasq:ReloadCommand=.
// env vars; override via e.g. Dnsmasq__ReloadCommand= or ForwardedHeaders__Enabled=true.
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<DnsmasqOptions>(
builder.Configuration.GetSection(DnsmasqOptions.SectionName));
builder.Services.AddOptions<DnsmasqOptions>()
.Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName))
.ValidateOnStart();
builder.Services.AddSingleton<Microsoft.Extensions.Options.IValidateOptions<DnsmasqOptions>, DnsmasqOptionsValidator>();
builder.Services.AddApplicationServices();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped(sp =>
{
var context = sp.GetRequiredService<IHttpContextAccessor>().HttpContext;
var baseUri = context != null ? $"{context.Request.Scheme}://{context.Request.Host}" : "http://localhost";
return new HttpClient { BaseAddress = new Uri(baseUri) };
});
// Same-host HttpClient via IHttpClientFactory: relative URIs (e.g. /api/status) are rewritten to the
// current request's scheme/host/path base by SameHostBaseAddressHandler. Components keep @inject HttpClient.
// Options.DefaultName is the "default" client name (empty string) used when you inject HttpClient or call CreateClient() with no name.
builder.Services.AddHttpClient(Microsoft.Extensions.Options.Options.DefaultName)
.AddHttpMessageHandler<DnsmasqWebUI.Http.SameHostBaseAddressHandler>();
// Same app hosts both:
// - API: AddControllers() + MapControllers() → routes like /api/status, /api/hosts, /api/reload.
@@ -27,15 +31,87 @@ builder.Services.AddControllers()
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// CORS: only added when Cors:Enabled is true. Configure origins/methods/headers in appsettings or
// env (e.g. Cors__Enabled=true, Cors__AllowedOrigins__0=https://app.example.com).
var corsEnabled = builder.Configuration.GetValue<bool>("Cors:Enabled");
if (corsEnabled)
{
var policyName = builder.Configuration.GetValue<string>("Cors:PolicyName") ?? "Default";
var allowAnyOrigin = builder.Configuration.GetValue<bool>("Cors:AllowAnyOrigin");
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
var methods = builder.Configuration.GetSection("Cors:AllowedMethods").Get<string[]>();
var headers = builder.Configuration.GetSection("Cors:AllowedHeaders").Get<string[]>();
builder.Services.AddCors(options =>
{
options.AddPolicy(policyName, policy =>
{
if (allowAnyOrigin)
policy.AllowAnyOrigin();
else if (origins.Length > 0)
policy.WithOrigins(origins);
if (methods is { Length: > 0 })
policy.WithMethods(methods);
else
policy.AllowAnyMethod();
if (headers is { Length: > 0 })
policy.WithHeaders(headers);
else
policy.AllowAnyHeader();
});
});
}
var app = builder.Build();
if (!app.Environment.IsDevelopment())
// Trigger Dnsmasq options validation and print a clear error to stderr if it fails (before any stack trace).
try
{
_ = app.Services.GetRequiredService<IOptions<DnsmasqOptions>>().Value;
}
catch (OptionsValidationException ex)
{
var err = Console.Error;
err.WriteLine();
err.WriteLine("*** DNSMASQ-WEBUI CONFIGURATION ERROR ***");
err.WriteLine();
foreach (var failure in ex.Failures)
err.WriteLine(" • " + failure);
err.WriteLine();
err.WriteLine("Fix the configuration (appsettings.json or Dnsmasq__* environment variables) and restart.");
err.WriteLine();
Environment.Exit(1);
}
if (!app.Environment.IsDevelopment())
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// HTTPS: Kestrel can serve HTTPS via config (no code needed). Set ASPNETCORE_URLS=https://*:5001 and
// ASPNETCORE_Kestrel__Certificates__Default__Path (and __Password) for the cert. When behind a
// reverse proxy that terminates TLS, set ForwardedHeaders__Enabled=true so X-Forwarded-Proto is used;
// set Https__UseRedirectAndHsts=true to redirect HTTP→HTTPS and send HSTS. Both default false.
var forwardedEnabled = builder.Configuration.GetValue<bool>("ForwardedHeaders:Enabled");
if (forwardedEnabled)
{
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
KnownNetworks = { new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Loopback, 8) },
});
}
if (builder.Configuration.GetValue<bool>("Https:UseRedirectAndHsts"))
{
app.UseHttpsRedirection();
app.UseHsts();
}
app.UseHttpsRedirection();
if (corsEnabled)
{
var policyName = builder.Configuration.GetValue<string>("Cors:PolicyName") ?? "Default";
app.UseCors(policyName);
}
app.UseAntiforgery();
// Map API controllers first, then static assets, then Blazor (catch-all for SPA-style routes).
@@ -45,12 +45,10 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
return new DnsmasqConfigSet("", "", Array.Empty<DnsmasqConfigSetEntry>());
var mainFull = Path.GetFullPath(mainPath);
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(mainPath);
var firstConfDir = DnsmasqConfIncludeParser.GetFirstConfDir(mainPath);
var managedFilePath = !string.IsNullOrEmpty(firstConfDir)
? Path.Combine(firstConfDir, _options.ManagedFileName)
: "";
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var managedFilePath = Path.Combine(mainDir, "dnsmasq.d", _options.ManagedFileName);
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(mainPath);
var files = withSource.Select(p => new DnsmasqConfigSetEntry(
p.Path,
Path.GetFileName(p.Path),
@@ -58,6 +56,9 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
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, files);
}
}
@@ -7,9 +7,9 @@ using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services;
/// <summary>
/// Runs once at startup: ensures the main dnsmasq config includes the managed file (appends conf-dir= at the end if missing),
/// then creates the managed config file if it does not exist. Requires write access to MainConfigPath and the conf-dir
/// (e.g. root or a user that owns those paths; in Docker with host dnsmasq, the container typically runs as root when bind-mounting /etc/dnsmasq.d).
/// Runs once at startup: ensures the main dnsmasq config ends with a conf-file= line for the managed file
/// (appends it at the end if missing), then creates the managed config file if it does not exist.
/// Requires write access to MainConfigPath and the managed file directory (e.g. root or a user that owns those paths).
/// </summary>
public class EnsureManagedConfigHostedService : IApplicationHostedService
{
@@ -34,35 +34,48 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
var configService = scope.ServiceProvider.GetRequiredService<IDnsmasqConfigService>();
var set = await configSetService.GetConfigSetAsync(cancellationToken);
if (string.IsNullOrEmpty(set.ManagedFilePath) || string.IsNullOrEmpty(_options.MainConfigPath))
return;
if (string.IsNullOrEmpty(set.ManagedFilePath) && !string.IsNullOrEmpty(_options.MainConfigPath))
var mainFull = Path.GetFullPath(_options.MainConfigPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var managedDir = Path.Combine(mainDir, "dnsmasq.d");
var managedPath = Path.Combine(managedDir, _options.ManagedFileName);
var confFileLine = "conf-file=" + Path.Combine("dnsmasq.d", _options.ManagedFileName);
var lines = File.Exists(mainFull)
? (await File.ReadAllLinesAsync(mainFull, Encoding.UTF8, cancellationToken)).ToList()
: new List<string>();
var toRemove = new List<int>();
for (var i = 0; i < lines.Count; i++)
{
var mainFull = Path.GetFullPath(_options.MainConfigPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var defaultConfDir = Path.Combine(mainDir, "dnsmasq.d");
var lineToAppend = "conf-dir=" + defaultConfDir;
var lines = File.Exists(mainFull)
? (await File.ReadAllLinesAsync(mainFull, Encoding.UTF8, cancellationToken)).ToList()
: new List<string>();
var trimmed = lines.Select(l => l.Trim()).ToList();
var alreadyHasConfDir = trimmed.Any(l =>
l.StartsWith("conf-dir=", StringComparison.OrdinalIgnoreCase) ||
l.StartsWith("conf-file=", StringComparison.OrdinalIgnoreCase));
if (!alreadyHasConfDir)
{
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1]))
lines.Add("");
lines.Add(lineToAppend);
await File.WriteAllLinesAsync(mainFull, lines, Encoding.UTF8, cancellationToken);
_logger.LogInformation("Appended {Line} to main config {Path} so the managed file is included.", lineToAppend, mainFull);
}
Directory.CreateDirectory(defaultConfDir);
set = await configSetService.GetConfigSetAsync(cancellationToken);
var trimmed = lines[i].Trim();
if (!trimmed.StartsWith("conf-file=", StringComparison.OrdinalIgnoreCase))
continue;
var value = trimmed.Length > 10 ? trimmed[10..].Trim() : "";
if (string.IsNullOrEmpty(value))
continue;
var resolved = Path.GetFullPath(Path.Combine(mainDir, value));
if (string.Equals(resolved, managedPath, StringComparison.Ordinal))
toRemove.Add(i);
}
if (string.IsNullOrEmpty(set.ManagedFilePath))
return;
foreach (var i in toRemove.OrderByDescending(x => x))
lines.RemoveAt(i);
var endsWithConfFile = lines.Count > 0 && lines[^1].Trim().Equals(confFileLine, StringComparison.Ordinal);
if (!endsWithConfFile)
{
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1]))
lines.Add("");
lines.Add(confFileLine);
await File.WriteAllLinesAsync(mainFull, lines, Encoding.UTF8, cancellationToken);
_logger.LogInformation("Appended {Line} to end of main config {Path} so the managed file is included.", confFileLine, mainFull);
}
Directory.CreateDirectory(managedDir);
if (File.Exists(set.ManagedFilePath))
return;
+14
View File
@@ -6,6 +6,20 @@
}
},
"AllowedHosts": "*",
"ForwardedHeaders": {
"Enabled": false
},
"Https": {
"UseRedirectAndHsts": false
},
"Cors": {
"Enabled": false,
"PolicyName": "Default",
"AllowedOrigins": [],
"AllowAnyOrigin": false,
"AllowedMethods": [],
"AllowedHeaders": []
},
"Dnsmasq": {
"MainConfigPath": "/etc/dnsmasq.conf",
"ManagedFileName": "zz-dnsmasq-webui.conf",