mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-22 01:42:08 +10:00
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:
@@ -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,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 <main-config-dir>/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
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user