mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +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:
+13
-4
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Executable
+156
@@ -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,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 <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";
|
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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user