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
+13 -4
View File
@@ -1,3 +1,8 @@
# App + dnsmasq in one container. Use this image for running dnsmasq (and the UI) in Docker.
# Both run as root; file permissions work because config dirs are in the container.
# For dnsmasq on the host, use the self-contained publish (scripts/publish-self-contained.sh)
# or run the app on the host; see DnsmasqOptions XML doc for permissions and ReloadCommand scope.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
@@ -14,10 +19,14 @@ RUN dotnet build "DnsmasqWebUI.csproj" -c Release -o /app/build
FROM build AS publish FROM build AS publish
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Runs as root by default. For host dnsmasq + container UI: bind-mount host config dir FROM base AS app
# and ensure the mount is writable by the container user, or run this image as root.
FROM base AS final
WORKDIR /app WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_URLS=http://+:8080
COPY --from=publish /app/publish . COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DnsmasqWebUI.dll"]
FROM app AS final
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/entrypoint.sh .
RUN chmod +x entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]
-32
View File
@@ -1,32 +0,0 @@
# App + dnsmasq in one container (test harness). Both run as root; file permissions work
# because config dirs are in the container. For UI in container + dnsmasq on host, see
# DnsmasqOptions XML doc (permissions and ReloadCommand/StatusCommand scope).
# Stages mirror Dockerfile; final stage adds dnsmasq.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"]
RUN dotnet restore "src/DnsmasqWebUI/DnsmasqWebUI.csproj"
COPY . .
WORKDIR "/src/src/DnsmasqWebUI"
RUN dotnet build "DnsmasqWebUI.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS app
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
COPY --from=publish /app/publish .
FROM app AS final
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/entrypoint.sh .
RUN chmod +x entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]
+1 -2
View File
@@ -2,7 +2,7 @@
# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR] # Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount). # Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
# #
# app builds from Dockerfile.dnsmasq (app + dnsmasq in one container; app is main process). # app builds from Dockerfile (app + dnsmasq in one container; app is main process).
# ReloadCommand/StatusCommand use pkill/pgrep (procps in image). cap_add NET_ADMIN for dnsmasq. # ReloadCommand/StatusCommand use pkill/pgrep (procps in image). cap_add NET_ADMIN for dnsmasq.
# #
# Networks: testnet 172.28.0.0/16 so dnsmasq can hand out 172.28.0.10–50. # Networks: testnet 172.28.0.0/16 so dnsmasq can hand out 172.28.0.10–50.
@@ -10,7 +10,6 @@ services:
app: app:
build: build:
context: . context: .
dockerfile: Dockerfile.dnsmasq
ports: ports:
- "8080:8080" - "8080:8080"
cap_add: cap_add:
+42 -5
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Prepare the testdata mount and optionally start the Docker test harness # Prepare the testdata mount and optionally start or stop the Docker test harness
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml. # (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
# #
# What this script does: # What this script does:
# 1. Clears the mount directory (unless --no-clear), then syncs source -> mount. # Start (default): clear mount (unless --no-clear), sync source -> mount, remove *dnsmasq-webui*.conf,
# 2. Excludes 'leases' so dnsmasq creates/owns the real leases file in the container. # then docker compose up -d [--build] [--force-recreate].
# 3. Removes any *dnsmasq-webui*.conf so dnsmasq starts clean (app creates zz-dnsmasq-webui.conf at startup). # --stop: docker compose down (stop and remove containers/networks).
# 4. Unless --prepare-only, runs: docker compose -f docker-compose.test.yml up -d [--build] [--force-recreate] # --tidy: docker compose down, then clear the mount directory for a clean next run.
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -19,6 +19,8 @@ PREPARE_ONLY=false
NO_BUILD=false NO_BUILD=false
RECREATE=false RECREATE=false
NO_CLEAR=false NO_CLEAR=false
STOP=false
TIDY=false
usage() { usage() {
echo "Usage: $0 [OPTIONS] [--]" echo "Usage: $0 [OPTIONS] [--]"
@@ -50,6 +52,11 @@ usage() {
echo " --recreate Pass --force-recreate to docker compose (recreate containers)." echo " --recreate Pass --force-recreate to docker compose (recreate containers)."
echo " Use to ensure fresh container state and mounts." echo " Use to ensure fresh container state and mounts."
echo "" echo ""
echo "Stop / tidy:"
echo " --stop Stop the test harness: docker compose down (no prepare, no start)."
echo " --tidy Stop the harness and clear the mount directory for a clean next run."
echo " Uses default mount dir unless --mount DIR is given."
echo ""
echo "Other:" echo "Other:"
echo " -h, --help Show this help and exit." echo " -h, --help Show this help and exit."
echo "" echo ""
@@ -73,6 +80,12 @@ usage() {
echo " $0 --source myfixtures --mount mymount --prepare-only" echo " $0 --source myfixtures --mount mymount --prepare-only"
echo " Sync myfixtures -> mymount only. Start with:" echo " Sync myfixtures -> mymount only. Start with:"
echo " TESTDATA_MOUNT=./mymount docker compose -f $COMPOSE_FILE up -d" echo " TESTDATA_MOUNT=./mymount docker compose -f $COMPOSE_FILE up -d"
echo ""
echo " $0 --stop"
echo " Stop and remove test harness containers and networks."
echo ""
echo " $0 --tidy"
echo " Stop harness and clear testdata-mount for a clean next run."
} }
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
@@ -109,6 +122,14 @@ while [ $# -gt 0 ]; do
RECREATE=true RECREATE=true
shift shift
;; ;;
--stop)
STOP=true
shift
;;
--tidy)
TIDY=true
shift
;;
--) --)
shift shift
break break
@@ -126,6 +147,22 @@ cd "$REPO_ROOT"
: "${SOURCE_DIR:=testdata}" : "${SOURCE_DIR:=testdata}"
: "${MOUNT_DIR:=testdata-mount}" : "${MOUNT_DIR:=testdata-mount}"
# Stop and/or tidy: no prepare, no start
if [ "$STOP" = true ] || [ "$TIDY" = true ]; then
echo "Stopping test harness: docker compose -f $COMPOSE_FILE down"
docker compose -f "$COMPOSE_FILE" down
if [ "$TIDY" = true ]; then
if [ -d "$MOUNT_DIR" ]; then
echo "Clearing mount directory: $MOUNT_DIR"
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
echo "Mount directory cleared."
else
echo "Mount directory $MOUNT_DIR does not exist; nothing to clear."
fi
fi
exit 0
fi
if [ ! -d "$SOURCE_DIR" ]; then if [ ! -d "$SOURCE_DIR" ]; then
echo "Error: source directory '$SOURCE_DIR' does not exist" >&2 echo "Error: source directory '$SOURCE_DIR' does not exist" >&2
exit 1 exit 1
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env sh
# Build a self-contained folder publish for Linux (no single-file; folder is recommended
# for ASP.NET Core). Copy the publish directory to the target host and run the binary.
#
# Platform auto-detection: if you do not pass a RID, the script picks one from /etc/os-release
# and uname -m. On Ubuntu 24.04/22.04 we use ubuntu.<ver>-<arch> so the runtime matches the
# host and avoids TypeLoadException seen with generic linux-x64 on those distros. Else we use
# generic linux-* or linux-musl-* (Alpine). Trimming is off by default because PublishTrimmed
# can cause Blazor routing/404 issues. The script cleans the target RID before publish
# so previous artefacts (e.g. from a different --trim or RID) cannot pollute the build.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PROJECT="$REPO_ROOT/src/DnsmasqWebUI/DnsmasqWebUI.csproj"
VALID_RIDS="linux-x64 linux-arm64 linux-arm linux-musl-x64 linux-musl-arm64 ubuntu.24.04-x64 ubuntu.24.04-arm64 ubuntu.22.04-x64 ubuntu.22.04-arm64"
TRIM=false
CLEAN=true
RID=""
AUTO_RID=false
# Detect architecture: x86_64/amd64 -> x64, aarch64 -> arm64, armv7l/armhf -> arm
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "x64" ;;
aarch64|arm64) echo "arm64" ;;
armv7l|armhf) echo "arm" ;;
*) echo "x64" ;;
esac
}
# Pick RID from OS and arch so runtime matches host (avoids TypeLoadException on Ubuntu).
default_rid() {
local arch
arch="$(detect_arch)"
if [ -f /etc/os-release ]; then
. /etc/os-release
case "${ID:-}" in
ubuntu)
case "${VERSION_ID:-}" in
24.04) echo "ubuntu.24.04-$arch"; return ;;
22.04) echo "ubuntu.22.04-$arch"; return ;;
esac ;;
alpine)
case "$arch" in
x64) echo "linux-musl-x64"; return ;;
arm64) echo "linux-musl-arm64"; return ;;
*) echo "linux-musl-x64"; return ;;
esac ;;
esac
fi
case "$arch" in
arm64) echo "linux-arm64" ;;
arm) echo "linux-arm" ;;
*) echo "linux-x64" ;;
esac
}
while [ $# -gt 0 ]; do
case "$1" in
-h|--help)
echo "Usage: $0 [OPTIONS] [RID]"
echo ""
echo "Build a self-contained folder publish for Linux. If RID is omitted, the script"
echo "auto-detects from the current OS and architecture (recommended on Ubuntu and Alpine)."
echo ""
echo "Options:"
echo " --trim Enable trimming (smaller output; can cause 404/routing issues with Blazor)"
echo " --no-clean Skip clean before publish (faster; use only if same RID and options as last run)"
echo " -h, --help Show this help"
echo ""
echo "Supported RIDs:"
echo " Generic (glibc):"
echo " linux-x64 Most servers/desktops (Debian, Fedora, etc.)"
echo " linux-arm64 Raspberry Pi 4/5, aarch64"
echo " linux-arm 32-bit ARM (older Pi)"
echo " Alpine (musl):"
echo " linux-musl-x64 Alpine amd64"
echo " linux-musl-arm64 Alpine aarch64"
echo " Ubuntu (use on Ubuntu to avoid TypeLoadException with generic RID):"
echo " ubuntu.24.04-x64 ubuntu.24.04-arm64"
echo " ubuntu.22.04-x64 ubuntu.22.04-arm64"
echo ""
echo "Output: src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/"
echo ""
echo "Examples:"
echo " $0 # Publish for current machine (auto-detect RID)"
echo " $0 ubuntu.24.04-x64 # Publish for Ubuntu 24.04 amd64"
echo " $0 linux-arm64 # Publish for Raspberry Pi 4/5 or other aarch64"
echo " $0 linux-musl-x64 # Publish for Alpine (e.g. Docker)"
echo " $0 --trim linux-x64 # Smaller build (not recommended for Blazor)"
echo " $0 --no-clean ubuntu.24.04-x64 # Skip clean (faster; same RID/options as last run)"
exit 0
;;
--trim)
TRIM=true
shift
;;
--no-clean)
CLEAN=false
shift
;;
*)
RID="$1"
shift
break
;;
esac
done
if [ -z "$RID" ]; then
RID="$(default_rid)"
AUTO_RID=true
fi
case "$RID" in
linux-x64|linux-arm64|linux-arm|linux-musl-x64|linux-musl-arm64|ubuntu.24.04-x64|ubuntu.24.04-arm64|ubuntu.22.04-x64|ubuntu.22.04-arm64) ;;
*)
echo "Unknown RID: $RID" >&2
echo "Supported: $VALID_RIDS" >&2
exit 1
;;
esac
if [ "$AUTO_RID" = true ]; then
echo "Detected RID: $RID (override by passing a RID as argument)"
fi
# Clean first so previous publish artefacts (e.g. different --trim or stale obj) cannot pollute this build.
# Use -c Release only (no -r) so clean does not require the RID in project.assets.json.
if [ "$CLEAN" = true ]; then
echo "Cleaning Release..."
dotnet clean "$PROJECT" -c Release -nologo -v q
fi
if [ "$TRIM" = true ]; then
echo "Publishing self-contained (trimmed, folder) for $RID..."
dotnet publish "$PROJECT" \
-c Release \
-r "$RID" \
--self-contained true \
-p:PublishTrimmed=true
else
echo "Publishing self-contained (no trim, folder) for $RID..."
dotnet publish "$PROJECT" \
-c Release \
-r "$RID" \
--self-contained true
fi
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net9.0/$RID/publish"
echo ""
echo "Done. Output: $OUT_DIR"
echo "Run on this host: $OUT_DIR/DnsmasqWebUI"
echo "Copy to another: rsync -av $OUT_DIR/ user@host:/opt/dnsmasq-webui/"
@@ -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> /// <summary>
/// Configuration for dnsmasq paths and reload/status commands. /// 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, /// 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 /// 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; /// 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 /// 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> /// <summary>Configuration section name (e.g. "Dnsmasq" for appsettings and Dnsmasq__* env vars).</summary>
public const string SectionName = "Dnsmasq"; 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; } = ""; 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"; 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> /// <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.Components;
using DnsmasqWebUI.Extensions; using DnsmasqWebUI.Extensions;
using DnsmasqWebUI.Options; using DnsmasqWebUI.Options;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
// CreateBuilder(args) loads config in order: appsettings.json, appsettings.{Environment}.json, // 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); var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<DnsmasqOptions>( builder.Services.AddOptions<DnsmasqOptions>()
builder.Configuration.GetSection(DnsmasqOptions.SectionName)); .Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName))
.ValidateOnStart();
builder.Services.AddSingleton<Microsoft.Extensions.Options.IValidateOptions<DnsmasqOptions>, DnsmasqOptionsValidator>();
builder.Services.AddApplicationServices(); builder.Services.AddApplicationServices();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped(sp => // 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.
var context = sp.GetRequiredService<IHttpContextAccessor>().HttpContext; // Options.DefaultName is the "default" client name (empty string) used when you inject HttpClient or call CreateClient() with no name.
var baseUri = context != null ? $"{context.Request.Scheme}://{context.Request.Host}" : "http://localhost"; builder.Services.AddHttpClient(Microsoft.Extensions.Options.Options.DefaultName)
return new HttpClient { BaseAddress = new Uri(baseUri) }; .AddHttpMessageHandler<DnsmasqWebUI.Http.SameHostBaseAddressHandler>();
});
// Same app hosts both: // Same app hosts both:
// - API: AddControllers() + MapControllers() → routes like /api/status, /api/hosts, /api/reload. // - API: AddControllers() + MapControllers() → routes like /api/status, /api/hosts, /api/reload.
@@ -27,15 +31,87 @@ builder.Services.AddControllers()
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .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(); 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); 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.UseHsts();
} }
app.UseHttpsRedirection(); if (corsEnabled)
{
var policyName = builder.Configuration.GetValue<string>("Cors:PolicyName") ?? "Default";
app.UseCors(policyName);
}
app.UseAntiforgery(); app.UseAntiforgery();
// Map API controllers first, then static assets, then Blazor (catch-all for SPA-style routes). // 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>()); return new DnsmasqConfigSet("", "", Array.Empty<DnsmasqConfigSetEntry>());
var mainFull = Path.GetFullPath(mainPath); var mainFull = Path.GetFullPath(mainPath);
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(mainPath); var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var firstConfDir = DnsmasqConfIncludeParser.GetFirstConfDir(mainPath); var managedFilePath = Path.Combine(mainDir, "dnsmasq.d", _options.ManagedFileName);
var managedFilePath = !string.IsNullOrEmpty(firstConfDir)
? Path.Combine(firstConfDir, _options.ManagedFileName)
: "";
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(mainPath);
var files = withSource.Select(p => new DnsmasqConfigSetEntry( var files = withSource.Select(p => new DnsmasqConfigSetEntry(
p.Path, p.Path,
Path.GetFileName(p.Path), Path.GetFileName(p.Path),
@@ -58,6 +56,9 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
IsManaged: string.Equals(p.Path, managedFilePath, StringComparison.Ordinal) IsManaged: string.Equals(p.Path, managedFilePath, StringComparison.Ordinal)
)).ToList(); )).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); return new DnsmasqConfigSet(mainFull, managedFilePath, files);
} }
} }
@@ -7,9 +7,9 @@ using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services; namespace DnsmasqWebUI.Services;
/// <summary> /// <summary>
/// Runs once at startup: ensures the main dnsmasq config includes the managed file (appends conf-dir= at the end if missing), /// Runs once at startup: ensures the main dnsmasq config ends with a conf-file= line for the managed file
/// then creates the managed config file if it does not exist. Requires write access to MainConfigPath and the conf-dir /// (appends it at the end if missing), then creates the managed config file if it does not exist.
/// (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). /// Requires write access to MainConfigPath and the managed file directory (e.g. root or a user that owns those paths).
/// </summary> /// </summary>
public class EnsureManagedConfigHostedService : IApplicationHostedService public class EnsureManagedConfigHostedService : IApplicationHostedService
{ {
@@ -34,35 +34,48 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
var configService = scope.ServiceProvider.GetRequiredService<IDnsmasqConfigService>(); var configService = scope.ServiceProvider.GetRequiredService<IDnsmasqConfigService>();
var set = await configSetService.GetConfigSetAsync(cancellationToken); 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 trimmed = lines[i].Trim();
var mainDir = Path.GetDirectoryName(mainFull) ?? ""; if (!trimmed.StartsWith("conf-file=", StringComparison.OrdinalIgnoreCase))
var defaultConfDir = Path.Combine(mainDir, "dnsmasq.d"); continue;
var lineToAppend = "conf-dir=" + defaultConfDir; var value = trimmed.Length > 10 ? trimmed[10..].Trim() : "";
if (string.IsNullOrEmpty(value))
var lines = File.Exists(mainFull) continue;
? (await File.ReadAllLinesAsync(mainFull, Encoding.UTF8, cancellationToken)).ToList() var resolved = Path.GetFullPath(Path.Combine(mainDir, value));
: new List<string>(); if (string.Equals(resolved, managedPath, StringComparison.Ordinal))
var trimmed = lines.Select(l => l.Trim()).ToList(); toRemove.Add(i);
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);
} }
if (string.IsNullOrEmpty(set.ManagedFilePath)) foreach (var i in toRemove.OrderByDescending(x => x))
return; 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)) if (File.Exists(set.ManagedFilePath))
return; return;
+14
View File
@@ -6,6 +6,20 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ForwardedHeaders": {
"Enabled": false
},
"Https": {
"UseRedirectAndHsts": false
},
"Cors": {
"Enabled": false,
"PolicyName": "Default",
"AllowedOrigins": [],
"AllowAnyOrigin": false,
"AllowedMethods": [],
"AllowedHeaders": []
},
"Dnsmasq": { "Dnsmasq": {
"MainConfigPath": "/etc/dnsmasq.conf", "MainConfigPath": "/etc/dnsmasq.conf",
"ManagedFileName": "zz-dnsmasq-webui.conf", "ManagedFileName": "zz-dnsmasq-webui.conf",