From 5bd9eaf55d99613736ec50bd6291cd5ccd95471e Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sat, 31 Jan 2026 22:30:18 +1000 Subject: [PATCH] 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) --- Dockerfile | 17 +- Dockerfile.dnsmasq | 32 ---- docker-compose.test.yml | 3 +- scripts/prepare-test-mount.sh | 47 +++++- scripts/publish-self-contained.sh | 156 ++++++++++++++++++ .../Http/SameHostBaseAddressHandler.cs | 26 +++ src/DnsmasqWebUI/Options/DnsmasqOptions.cs | 6 +- .../Options/DnsmasqOptionsValidator.cs | 49 ++++++ src/DnsmasqWebUI/Program.cs | 98 +++++++++-- .../Services/DnsmasqConfigSetService.cs | 11 +- .../EnsureManagedConfigHostedService.cs | 69 ++++---- src/DnsmasqWebUI/appsettings.json | 14 ++ 12 files changed, 438 insertions(+), 90 deletions(-) delete mode 100644 Dockerfile.dnsmasq create mode 100755 scripts/publish-self-contained.sh create mode 100644 src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs create mode 100644 src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs diff --git a/Dockerfile b/Dockerfile index 880b85d..657938e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 WORKDIR /app EXPOSE 8080 @@ -14,10 +19,14 @@ 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 -# Runs as root by default. For host dnsmasq + container UI: bind-mount host config dir -# and ensure the mount is writable by the container user, or run this image as root. -FROM base AS final +FROM base AS app WORKDIR /app ENV ASPNETCORE_URLS=http://+:8080 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"] diff --git a/Dockerfile.dnsmasq b/Dockerfile.dnsmasq deleted file mode 100644 index c182965..0000000 --- a/Dockerfile.dnsmasq +++ /dev/null @@ -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"] diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 4d8085a..acb9ad5 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -2,7 +2,7 @@ # 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). # -# 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. # # Networks: testnet 172.28.0.0/16 so dnsmasq can hand out 172.28.0.10–50. @@ -10,7 +10,6 @@ services: app: build: context: . - dockerfile: Dockerfile.dnsmasq ports: - "8080:8080" cap_add: diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh index e5aec8d..9dad222 100755 --- a/scripts/prepare-test-mount.sh +++ b/scripts/prepare-test-mount.sh @@ -1,12 +1,12 @@ #!/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. # # What this script does: -# 1. Clears the mount directory (unless --no-clear), then syncs source -> mount. -# 2. Excludes 'leases' so dnsmasq creates/owns the real leases file in the container. -# 3. Removes any *dnsmasq-webui*.conf so dnsmasq starts clean (app creates zz-dnsmasq-webui.conf at startup). -# 4. Unless --prepare-only, runs: docker compose -f docker-compose.test.yml up -d [--build] [--force-recreate] +# Start (default): clear mount (unless --no-clear), sync source -> mount, remove *dnsmasq-webui*.conf, +# then docker compose up -d [--build] [--force-recreate]. +# --stop: docker compose down (stop and remove containers/networks). +# --tidy: docker compose down, then clear the mount directory for a clean next run. set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -19,6 +19,8 @@ PREPARE_ONLY=false NO_BUILD=false RECREATE=false NO_CLEAR=false +STOP=false +TIDY=false usage() { echo "Usage: $0 [OPTIONS] [--]" @@ -50,6 +52,11 @@ usage() { echo " --recreate Pass --force-recreate to docker compose (recreate containers)." echo " Use to ensure fresh container state and mounts." 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 " -h, --help Show this help and exit." echo "" @@ -73,6 +80,12 @@ usage() { echo " $0 --source myfixtures --mount mymount --prepare-only" echo " Sync myfixtures -> mymount only. Start with:" 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 @@ -109,6 +122,14 @@ while [ $# -gt 0 ]; do RECREATE=true shift ;; + --stop) + STOP=true + shift + ;; + --tidy) + TIDY=true + shift + ;; --) shift break @@ -126,6 +147,22 @@ cd "$REPO_ROOT" : "${SOURCE_DIR:=testdata}" : "${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 echo "Error: source directory '$SOURCE_DIR' does not exist" >&2 exit 1 diff --git a/scripts/publish-self-contained.sh b/scripts/publish-self-contained.sh new file mode 100755 index 0000000..79afc8f --- /dev/null +++ b/scripts/publish-self-contained.sh @@ -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.- 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//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/" diff --git a/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs b/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs new file mode 100644 index 0000000..b26049a --- /dev/null +++ b/src/DnsmasqWebUI/Http/SameHostBaseAddressHandler.cs @@ -0,0 +1,26 @@ +using System.Net; + +namespace DnsmasqWebUI.Http; + +/// +/// 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. +/// +public sealed class SameHostBaseAddressHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler +{ + protected override Task 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); + } +} diff --git a/src/DnsmasqWebUI/Options/DnsmasqOptions.cs b/src/DnsmasqWebUI/Options/DnsmasqOptions.cs index 92ef32d..f955328 100644 --- a/src/DnsmasqWebUI/Options/DnsmasqOptions.cs +++ b/src/DnsmasqWebUI/Options/DnsmasqOptions.cs @@ -3,7 +3,7 @@ namespace DnsmasqWebUI.Options; /// /// 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 /// Configuration section name (e.g. "Dnsmasq" for appsettings and Dnsmasq__* env vars). public const string SectionName = "Dnsmasq"; - /// 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. + /// 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. public string MainConfigPath { get; set; } = ""; - /// 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. + /// 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. public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf"; /// Path we write as addn-hosts= in the managed file; HostsFileService reads/writes this path. Process must have read/write access. diff --git a/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs b/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs new file mode 100644 index 0000000..ad79a7d --- /dev/null +++ b/src/DnsmasqWebUI/Options/DnsmasqOptionsValidator.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Options; + +namespace DnsmasqWebUI.Options; + +/// +/// 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. +/// +public sealed class DnsmasqOptionsValidator : IValidateOptions +{ + 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(); + + 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); + } +} diff --git a/src/DnsmasqWebUI/Program.cs b/src/DnsmasqWebUI/Program.cs index faebec0..e4e8110 100644 --- a/src/DnsmasqWebUI/Program.cs +++ b/src/DnsmasqWebUI/Program.cs @@ -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( - builder.Configuration.GetSection(DnsmasqOptions.SectionName)); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName)) + .ValidateOnStart(); +builder.Services.AddSingleton, DnsmasqOptionsValidator>(); builder.Services.AddApplicationServices(); builder.Services.AddHttpContextAccessor(); -builder.Services.AddScoped(sp => -{ - var context = sp.GetRequiredService().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(); // 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("Cors:Enabled"); +if (corsEnabled) +{ + var policyName = builder.Configuration.GetValue("Cors:PolicyName") ?? "Default"; + var allowAnyOrigin = builder.Configuration.GetValue("Cors:AllowAnyOrigin"); + var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; + var methods = builder.Configuration.GetSection("Cors:AllowedMethods").Get(); + var headers = builder.Configuration.GetSection("Cors:AllowedHeaders").Get(); + + 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>().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("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("Https:UseRedirectAndHsts")) +{ + app.UseHttpsRedirection(); app.UseHsts(); } -app.UseHttpsRedirection(); +if (corsEnabled) +{ + var policyName = builder.Configuration.GetValue("Cors:PolicyName") ?? "Default"; + app.UseCors(policyName); +} + app.UseAntiforgery(); // Map API controllers first, then static assets, then Blazor (catch-all for SPA-style routes). diff --git a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs index d49dab3..b1ce5d5 100644 --- a/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs +++ b/src/DnsmasqWebUI/Services/DnsmasqConfigSetService.cs @@ -45,12 +45,10 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService return new DnsmasqConfigSet("", "", Array.Empty()); 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); } } diff --git a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs index 7ef1f9b..dead341 100644 --- a/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs +++ b/src/DnsmasqWebUI/Services/EnsureManagedConfigHostedService.cs @@ -7,9 +7,9 @@ using Microsoft.Extensions.Options; namespace DnsmasqWebUI.Services; /// -/// 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). /// public class EnsureManagedConfigHostedService : IApplicationHostedService { @@ -34,35 +34,48 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService var configService = scope.ServiceProvider.GetRequiredService(); 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(); + + var toRemove = new List(); + 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(); - 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; diff --git a/src/DnsmasqWebUI/appsettings.json b/src/DnsmasqWebUI/appsettings.json index aad5b01..7328db6 100644 --- a/src/DnsmasqWebUI/appsettings.json +++ b/src/DnsmasqWebUI/appsettings.json @@ -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",