mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Effective config refactor, remove dead parser path, add source line numbers
Effective config: - Add EffectiveConfigFieldDescriptor (metadata + value/source/items delegates) - Add EffectiveConfigFieldBuilder with Config/Sources/Items helpers; build all fields when EffectiveConfig present (empty = not set); empty list when null - Add EffectiveConfigSectionPanel + EffectiveConfigFieldDisplay; section CSS uses ::deep for child markup, open-state styling for headers - Remove Kind from descriptor (use DnsmasqConfOptionRegistry.GetKind(OptionName) when needed) Dead code removal: - Remove DnsmasqOptionModels, ParsedDirective (DnsmasqConfDirective), DnsmasqOptionKind, DnsmasqConfOptionRegistry - Remove ParseLine from DnsmasqConfDirectiveParser; keep StripComment and TryParseKeyValue (used by DnsmasqConfIncludeParser) - Simplify DnsmasqConfDirectiveParserTests; remove DnsmasqConfOptionRegistryTests ConfigValueSource: - Add LineNumber (int?); IncludeParser passes 1-based line to MakeSource - GetReadOnlyTooltip includes 'line N' when LineNumber set - Add ConfigValueSourceTests; assert LineNumber in IncludeParser WithSource tests Also: .github/workflows/release.yml, scripts/install.sh, script tweaks
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# Build self-contained binaries for multiple RIDs when a version tag is pushed,
|
||||
# then create/update the release and attach the zips as release assets.
|
||||
# Run: push a tag (e.g. v1.0.0); the workflow builds that commit and uploads assets.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
rid:
|
||||
- linux-x64
|
||||
- linux-arm64
|
||||
- ubuntu.24.04-x64
|
||||
- ubuntu.22.04-x64
|
||||
- linux-musl-x64
|
||||
- linux-musl-arm64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Publish for ${{ matrix.rid }}
|
||||
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }}
|
||||
|
||||
- name: Zip publish output
|
||||
run: |
|
||||
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net9.0/${{ matrix.rid }}/publish"
|
||||
(cd "$PUBLISH_DIR" && zip -r "../../../../../../../dnsmasq-webui-${{ matrix.rid }}.zip" .)
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dnsmasq-webui-${{ matrix.rid }}
|
||||
path: dnsmasq-webui-${{ matrix.rid }}.zip
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Collect zips into workspace root
|
||||
run: |
|
||||
for dir in dnsmasq-webui-*/; do
|
||||
[ -d "$dir" ] && cp "$dir"/*.zip . 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Create release and upload assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Release ${{ github.ref_name }}
|
||||
files: dnsmasq-webui-*.zip
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
Executable
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env sh
|
||||
# Download and install dnsmasq-webui from a GitHub release. Picks the binary
|
||||
# matching the current OS/arch (RID). Run from a clone or via:
|
||||
# curl -sSL https://raw.githubusercontent.com/OWNER/REPO/main/scripts/install.sh | sh -s -- [OPTIONS]
|
||||
# When run from a clone, repo is detected from git remote origin. Otherwise set
|
||||
# GITHUB_REPO=owner/repo or pass --repo owner/repo.
|
||||
set -e
|
||||
|
||||
GITHUB_REPO="${GITHUB_REPO:-}"
|
||||
VERSION=""
|
||||
LIST=false
|
||||
INSTALL_DIR=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Download and install dnsmasq-webui from a GitHub release for this machine's OS/arch."
|
||||
echo ""
|
||||
echo "Repo (when not in a clone):"
|
||||
echo " --repo OWNER/REPO GitHub owner/repo (e.g. myuser/dnsmasq-webui)."
|
||||
echo " Or set GITHUB_REPO=owner/repo."
|
||||
echo " When run inside a git clone with origin, repo is detected automatically."
|
||||
echo ""
|
||||
echo "Release:"
|
||||
echo " --list List available releases (tag, name, published_at) and exit."
|
||||
echo " --version TAG Install from release with tag TAG (e.g. v1.0.0). Default: latest."
|
||||
echo " -h, -?, --help Show this help."
|
||||
echo ""
|
||||
echo "Install:"
|
||||
echo " --dir DIR Install into DIR (default: ./dnsmasq-webui in current directory)."
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # In clone: install latest into ./dnsmasq-webui"
|
||||
echo " $0 --repo owner/dnsmasq-webui # Install latest from owner/dnsmasq-webui"
|
||||
echo " $0 --repo owner/dnsmasq-webui --version v1.0.0"
|
||||
echo " $0 --repo owner/dnsmasq-webui --list # List releases"
|
||||
echo " GITHUB_REPO=owner/dnsmasq-webui $0 --dir /opt/dnsmasq-webui"
|
||||
echo ""
|
||||
echo "After install, configure via appsettings.json or Dnsmasq__* environment variables, then run the binary."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Detect owner/repo from git remote if we're in a clone.
|
||||
detect_repo() {
|
||||
if [ -n "$GITHUB_REPO" ]; then
|
||||
return
|
||||
fi
|
||||
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
origin="$(git remote get-url origin 2>/dev/null)" || true
|
||||
if [ -n "$origin" ]; then
|
||||
# https://github.com/owner/repo or git@github.com:owner/repo.git
|
||||
GITHUB_REPO="$(echo "$origin" | sed -E 's|^https://github\.com/||; s|^git@github\.com:||; s|\.git$||; s|/$||')"
|
||||
if [ -n "$GITHUB_REPO" ]; then
|
||||
return
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "Error: GitHub repo not set. Use --repo owner/repo or set GITHUB_REPO=owner/repo (or run from a clone)." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Require jq for parsing GitHub API JSON.
|
||||
check_jq() {
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
echo "Error: jq is required to parse GitHub API responses. Install jq (e.g. apt install jq)." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Detect RID (same logic as publish-self-contained.sh).
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo "x64" ;;
|
||||
aarch64|arm64) echo "arm64" ;;
|
||||
armv7l|armhf) echo "arm" ;;
|
||||
*) echo "x64" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_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
|
||||
}
|
||||
|
||||
# GET release (latest or by tag). Output raw JSON to stdout.
|
||||
fetch_release() {
|
||||
local api_url
|
||||
if [ -z "$VERSION" ] || [ "$VERSION" = "latest" ]; then
|
||||
api_url="https://api.github.com/repos/$GITHUB_REPO/releases/latest"
|
||||
else
|
||||
api_url="https://api.github.com/repos/$GITHUB_REPO/releases/tags/$VERSION"
|
||||
fi
|
||||
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url")"
|
||||
code="$(echo "$resp" | tail -n1)"
|
||||
body="$(echo "$resp" | sed '$d')"
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "Error: GitHub API returned $code for $api_url" >&2
|
||||
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$body"
|
||||
}
|
||||
|
||||
# List releases and exit.
|
||||
list_releases() {
|
||||
check_jq
|
||||
detect_repo
|
||||
api_url="https://api.github.com/repos/$GITHUB_REPO/releases?per_page=20"
|
||||
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url")"
|
||||
code="$(echo "$resp" | tail -n1)"
|
||||
body="$(echo "$resp" | sed '$d')"
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "Error: GitHub API returned $code" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$body" | jq -r '.[] | "\(.tag_name) \(.name) \(.published_at // .created_at)"'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Find asset download URL whose name contains the given RID.
|
||||
find_asset_url() {
|
||||
local release_json rid
|
||||
release_json="$1"
|
||||
rid="$2"
|
||||
echo "$release_json" | jq -r --arg rid "$rid" '
|
||||
.assets[] | select(.name | test($rid)) | .browser_download_url
|
||||
' | head -n1
|
||||
}
|
||||
|
||||
# Main install: fetch release, find asset for RID, download, extract.
|
||||
do_install() {
|
||||
check_jq
|
||||
detect_repo
|
||||
rid="$(detect_rid)"
|
||||
echo "Detected RID: $rid"
|
||||
echo "Fetching release..."
|
||||
release_json="$(fetch_release)"
|
||||
tag="$(echo "$release_json" | jq -r '.tag_name')"
|
||||
echo "Release: $tag"
|
||||
url="$(find_asset_url "$release_json" "$rid")"
|
||||
if [ -z "$url" ] || [ "$url" = "null" ]; then
|
||||
echo "Error: No asset found for RID $rid in release $tag." >&2
|
||||
echo "Available assets:" >&2
|
||||
echo "$release_json" | jq -r '.assets[].name' | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$INSTALL_DIR" ]; then
|
||||
INSTALL_DIR="./dnsmasq-webui"
|
||||
fi
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
tmpzip="${TMPDIR:-/tmp}/dnsmasq-webui-$rid.zip"
|
||||
echo "Downloading $url ..."
|
||||
curl -sSL -A "dnsmasq-webui-install/1.0" -o "$tmpzip" "$url"
|
||||
echo "Extracting to $INSTALL_DIR ..."
|
||||
unzip -o -q "$tmpzip" -d "$INSTALL_DIR"
|
||||
rm -f "$tmpzip"
|
||||
echo ""
|
||||
echo "Installed to $INSTALL_DIR"
|
||||
echo "Run: $INSTALL_DIR/DnsmasqWebUI"
|
||||
echo "Configure via appsettings.json in that directory or Dnsmasq__* environment variables (e.g. Dnsmasq__MainConfigPath=/etc/dnsmasq.conf)."
|
||||
}
|
||||
|
||||
# Parse args
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|-?|--help)
|
||||
usage
|
||||
;;
|
||||
--repo)
|
||||
shift
|
||||
[ $# -gt 0 ] || { echo "Error: --repo requires OWNER/REPO" >&2; exit 1; }
|
||||
GITHUB_REPO="$1"
|
||||
shift
|
||||
;;
|
||||
--list)
|
||||
LIST=true
|
||||
shift
|
||||
;;
|
||||
--version)
|
||||
shift
|
||||
[ $# -gt 0 ] || { echo "Error: --version requires TAG" >&2; exit 1; }
|
||||
VERSION="$1"
|
||||
shift
|
||||
;;
|
||||
--dir)
|
||||
shift
|
||||
[ $# -gt 0 ] || { echo "Error: --dir requires DIR" >&2; exit 1; }
|
||||
INSTALL_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
echo "Error: unknown option $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Error: unexpected argument $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$LIST" = true ]; then
|
||||
list_releases
|
||||
fi
|
||||
|
||||
do_install
|
||||
@@ -3,7 +3,7 @@
|
||||
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
|
||||
#
|
||||
# What this script does:
|
||||
# Start (default): clear mount (unless --no-clear), sync source -> mount, remove *dnsmasq-webui*.conf,
|
||||
# Start (default): clear mount (unless --no-clear), sync source -> mount, clean up previous test data,
|
||||
# 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.
|
||||
@@ -16,7 +16,7 @@ COMPOSE_FILE="docker-compose.test.yml"
|
||||
SOURCE_DIR=""
|
||||
MOUNT_DIR=""
|
||||
PREPARE_ONLY=false
|
||||
NO_BUILD=false
|
||||
BUILD=false
|
||||
RECREATE=false
|
||||
NO_CLEAR=false
|
||||
STOP=false
|
||||
@@ -25,13 +25,12 @@ TIDY=false
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS] [--]"
|
||||
echo ""
|
||||
echo "Prepare the testdata mount directory and optionally start the Docker test harness"
|
||||
echo "(app with dnsmasq in one container, plus a DHCP client). The mount is synced from"
|
||||
echo "testdata/ by default (includes sample leases file so the harness shows leases)."
|
||||
echo "Prepare the testdata mount directory and optionally start or stop the Docker test harness"
|
||||
echo "(app + dnsmasq + DHCP client). By default: clear mount, sync testdata -> mount, then up -d (no rebuild)."
|
||||
echo ""
|
||||
echo "Steps:"
|
||||
echo "Steps (when starting):"
|
||||
echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount."
|
||||
echo " 2. Remove any leftover *dnsmasq-webui*.conf so dnsmasq starts clean."
|
||||
echo " 2. Clean up previous test data (e.g. managed config) so the harness starts clean."
|
||||
echo " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
|
||||
echo ""
|
||||
echo "Path options:"
|
||||
@@ -47,31 +46,32 @@ usage() {
|
||||
echo "Compose behaviour:"
|
||||
echo " --prepare-only Only prepare the mount; do not run docker compose."
|
||||
echo " Use to inspect or edit the mount before starting containers."
|
||||
echo " --no-build Do not pass --build to docker compose (use existing images)."
|
||||
echo " Use for a quick restart when only the mount changed."
|
||||
echo " --recreate Pass --force-recreate to docker compose (recreate containers)."
|
||||
echo " --build Pass --build to docker compose (rebuild images before starting)."
|
||||
echo " Use after changing the app or Dockerfile. Default: use existing images."
|
||||
echo " --no-build Do not rebuild (default). Use existing images for a quick restart."
|
||||
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 / tidy (take precedence: other options are ignored when used):"
|
||||
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 " -h, -?, --help Show this help and exit."
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0"
|
||||
echo " Full run: clear mount, sync testdata, build and start containers."
|
||||
echo " Full run: clear mount, sync testdata, start containers (no rebuild)."
|
||||
echo ""
|
||||
echo " $0 --no-build"
|
||||
echo " Clear and sync, then start containers without rebuilding images."
|
||||
echo " $0 --build"
|
||||
echo " Clear and sync, then rebuild images and start containers."
|
||||
echo ""
|
||||
echo " $0 --recreate"
|
||||
echo " Clear and sync, then up --build --force-recreate (clean containers)."
|
||||
echo " Clear and sync, then up -d --force-recreate (fresh containers)."
|
||||
echo ""
|
||||
echo " $0 --no-clear --no-build"
|
||||
echo " Preserve mount contents, sync over it, start without rebuild."
|
||||
echo " $0 --no-clear"
|
||||
echo " Preserve mount contents, sync over it, start (no rebuild)."
|
||||
echo ""
|
||||
echo " $0 --prepare-only"
|
||||
echo " Only clear and sync testdata -> testdata-mount; no containers."
|
||||
@@ -90,7 +90,7 @@ usage() {
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
-h|-?|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
@@ -114,8 +114,12 @@ while [ $# -gt 0 ]; do
|
||||
PREPARE_ONLY=true
|
||||
shift
|
||||
;;
|
||||
--build)
|
||||
BUILD=true
|
||||
shift
|
||||
;;
|
||||
--no-build)
|
||||
NO_BUILD=true
|
||||
BUILD=false
|
||||
shift
|
||||
;;
|
||||
--recreate)
|
||||
@@ -134,14 +138,25 @@ while [ $# -gt 0 ]; do
|
||||
shift
|
||||
break
|
||||
;;
|
||||
*)
|
||||
-?*)
|
||||
echo "Error: unknown option $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Error: unexpected argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
echo "Error: unexpected argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
: "${SOURCE_DIR:=testdata}"
|
||||
@@ -186,23 +201,28 @@ else
|
||||
rm -f "$MOUNT_DIR/leases"
|
||||
fi
|
||||
|
||||
# Remove any leftover managed config from previous runs so dnsmasq starts clean (app will create zz-dnsmasq-webui.conf on startup).
|
||||
# Clean up previous test data (e.g. managed config) so the harness starts clean (app will create zz-dnsmasq-webui.conf on startup).
|
||||
find "$MOUNT_DIR" -name '*dnsmasq-webui*.conf' -type f -delete 2>/dev/null || true
|
||||
|
||||
echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR)."
|
||||
|
||||
if [ "$PREPARE_ONLY" = true ]; then
|
||||
echo "To start the harness: TESTDATA_MOUNT=./$MOUNT_DIR docker compose -f $COMPOSE_FILE up -d --build"
|
||||
case "$MOUNT_DIR" in
|
||||
/*) MOUNT_EXPORT="$MOUNT_DIR" ;;
|
||||
*) MOUNT_EXPORT="./$MOUNT_DIR" ;;
|
||||
esac
|
||||
echo "To start the harness: TESTDATA_MOUNT=$MOUNT_EXPORT docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Compose uses TESTDATA_MOUNT for the data volume (default: ./testdata-mount)
|
||||
export TESTDATA_MOUNT="./$MOUNT_DIR"
|
||||
case "$MOUNT_DIR" in
|
||||
/*) export TESTDATA_MOUNT="$MOUNT_DIR" ;;
|
||||
*) export TESTDATA_MOUNT="./$MOUNT_DIR" ;;
|
||||
esac
|
||||
|
||||
COMPOSE_CMD="docker compose -f $COMPOSE_FILE up -d"
|
||||
if [ "$NO_BUILD" = true ]; then
|
||||
COMPOSE_CMD="$COMPOSE_CMD"
|
||||
else
|
||||
if [ "$BUILD" = true ]; then
|
||||
COMPOSE_CMD="$COMPOSE_CMD --build"
|
||||
fi
|
||||
if [ "$RECREATE" = true ]; then
|
||||
|
||||
@@ -59,16 +59,17 @@ default_rid() {
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
-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 "Pass options first, then RID if desired (e.g. $0 --trim ubuntu.24.04-x64)."
|
||||
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 " -h, -?, --help Show this help"
|
||||
echo ""
|
||||
echo "Supported RIDs:"
|
||||
echo " Generic (glibc):"
|
||||
@@ -101,6 +102,11 @@ while [ $# -gt 0 ]; do
|
||||
CLEAN=false
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
echo "Error: unknown option $1" >&2
|
||||
echo "Use -h, -?, or --help for usage." >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
RID="$1"
|
||||
shift
|
||||
@@ -109,6 +115,12 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
echo "Error: unexpected argument: $1" >&2
|
||||
echo "Use -h, -?, or --help for usage." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$RID" ]; then
|
||||
RID="$(default_rid)"
|
||||
AUTO_RID=true
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using DnsmasqWebUI.Models.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ConfigValueSource"/> and <see cref="ConfigValueSource.GetReadOnlyTooltip"/>.
|
||||
/// </summary>
|
||||
public class ConfigValueSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetReadOnlyTooltip_WhenManaged_ReturnsNull()
|
||||
{
|
||||
var source = new ConfigValueSource("/data/zz-managed.conf", "zz-managed.conf", IsManaged: true, LineNumber: 5);
|
||||
Assert.Null(source.GetReadOnlyTooltip());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetReadOnlyTooltip_WhenReadOnlyWithoutLineNumber_ReturnsFileNameOnly()
|
||||
{
|
||||
var source = new ConfigValueSource("/etc/dnsmasq.d/02.conf", "02.conf", IsManaged: false, LineNumber: null);
|
||||
var tooltip = source.GetReadOnlyTooltip();
|
||||
Assert.NotNull(tooltip);
|
||||
Assert.Contains("02.conf", tooltip);
|
||||
Assert.Contains("readonly", tooltip);
|
||||
Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip);
|
||||
Assert.DoesNotContain("line", tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetReadOnlyTooltip_WhenReadOnlyWithLineNumber_IncludesLineNumber()
|
||||
{
|
||||
var source = new ConfigValueSource("/etc/dnsmasq.d/02.conf", "02.conf", IsManaged: false, LineNumber: 3);
|
||||
var tooltip = source.GetReadOnlyTooltip();
|
||||
Assert.NotNull(tooltip);
|
||||
Assert.Contains("02.conf", tooltip);
|
||||
Assert.Contains("line 3", tooltip);
|
||||
Assert.Contains("readonly", tooltip);
|
||||
Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadOnly_WhenManaged_IsFalse()
|
||||
{
|
||||
var source = new ConfigValueSource("/data/zz.conf", "zz.conf", IsManaged: true);
|
||||
Assert.False(source.IsReadOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadOnly_WhenNotManaged_IsTrue()
|
||||
{
|
||||
var source = new ConfigValueSource("/etc/dnsmasq.conf", "dnsmasq.conf", IsManaged: false);
|
||||
Assert.True(source.IsReadOnly);
|
||||
}
|
||||
}
|
||||
@@ -1,228 +1,52 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.EffectiveConfig;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfDirectiveParser. Parses a single dnsmasq .conf line into DnsmasqConfDirective
|
||||
/// with typed option (AddnHostsOption, DhcpLeaseFileOption, PathOption, RawOption, etc.).
|
||||
/// Tests for DnsmasqConfDirectiveParser. Key/value parsing (TryParseKeyValue) and comment stripping (StripComment)
|
||||
/// used by DnsmasqConfIncludeParser to build effective config.
|
||||
/// </summary>
|
||||
public class DnsmasqConfDirectiveParserTests
|
||||
{
|
||||
const string SourcePath = "/etc/dnsmasq.d/example.conf";
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Empty_ReturnsNull()
|
||||
public void TryParseKeyValue_Empty_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDirectiveParser.ParseLine("", 1, SourcePath));
|
||||
Assert.Null(DnsmasqConfDirectiveParser.ParseLine(" ", 2, SourcePath));
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue(""));
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Comment_ReturnsNull()
|
||||
public void TryParseKeyValue_Comment_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDirectiveParser.ParseLine("# comment", 1, SourcePath));
|
||||
Assert.Null(DnsmasqConfDirectiveParser.ParseLine("# addn-hosts=/etc/hosts", 1, SourcePath));
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("# comment"));
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("# addn-hosts=/etc/hosts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_AddnHosts_ReturnsAddnHostsOption()
|
||||
public void TryParseKeyValue_KeyValue_ReturnsKeyAndValue()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("addn-hosts=/var/lib/dnsmasq/hosts", 3, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(3, d!.LineNumber);
|
||||
Assert.Equal(SourcePath, d.SourceFilePath);
|
||||
Assert.Equal(DnsmasqOptionKind.AddnHosts, d.Kind);
|
||||
var opt = Assert.IsType<AddnHostsOption>(d.TypedOption);
|
||||
Assert.Equal("/var/lib/dnsmasq/hosts", opt.Path);
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53");
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
|
||||
Assert.Equal("53", kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_AddnHosts_RelativePath_ResolvedAgainstSourceDir()
|
||||
public void TryParseKeyValue_KeyOnly_ReturnsEmptyValue()
|
||||
{
|
||||
var conf = "/etc/dnsmasq.d/zz.conf";
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("addn-hosts=hosts.d/app.hosts", 1, conf);
|
||||
Assert.NotNull(d);
|
||||
var opt = Assert.IsType<AddnHostsOption>(d!.TypedOption);
|
||||
Assert.Equal(Path.GetFullPath("/etc/dnsmasq.d/hosts.d/app.hosts"), opt.Path);
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("domain-needed");
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.DomainNeeded, kv!.Value.key);
|
||||
Assert.Equal("", kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DhcpLeasefile_ReturnsDhcpLeaseFileOption()
|
||||
public void TryParseKeyValue_CommentedLine_ReturnsNull()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-leasefile=/var/lib/dnsmasq/dnsmasq.leases", 5, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, d!.Kind);
|
||||
var opt = Assert.IsType<DhcpLeaseFileOption>(d.TypedOption);
|
||||
Assert.Equal("/var/lib/dnsmasq/dnsmasq.leases", opt.Path);
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("#addn-hosts=/etc/hosts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DhcpLeaseFile_AlternativeKey_ReturnsDhcpLeaseFileOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-lease=/run/dnsmasq.leases", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, d!.Kind);
|
||||
var opt = Assert.IsType<DhcpLeaseFileOption>(d.TypedOption);
|
||||
Assert.Equal("/run/dnsmasq.leases", opt.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Domain_ReturnsDomainOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("domain=local", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.Domain, d!.Kind);
|
||||
var opt = Assert.IsType<DomainOption>(d.TypedOption);
|
||||
Assert.Equal("local", opt.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ConfFile_ReturnsConfFileOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("conf-file=/etc/dnsmasq.d/extra.conf", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.ConfFile, d!.Kind);
|
||||
var opt = Assert.IsType<ConfFileOption>(d.TypedOption);
|
||||
Assert.Equal("/etc/dnsmasq.d/extra.conf", opt.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ConfDir_ReturnsConfDirOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("conf-dir=/etc/dnsmasq.d", 2, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.ConfDir, d!.Kind);
|
||||
var opt = Assert.IsType<ConfDirOption>(d.TypedOption);
|
||||
Assert.Equal(Path.GetFullPath("/etc/dnsmasq.d"), opt.Path);
|
||||
Assert.Null(opt.Suffix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ConfDir_WithSuffix_ReturnsConfDirOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("conf-dir=/etc/dnsmasq.d,.conf", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
var opt = Assert.IsType<ConfDirOption>(d!.TypedOption);
|
||||
Assert.Equal(Path.GetFullPath("/etc/dnsmasq.d"), opt.Path);
|
||||
Assert.Equal(".conf", opt.Suffix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DhcpRange_ReturnsDhcpRangeOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-range=192.168.1.100,192.168.1.200,255.255.255.0,12h", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.DhcpRange, d!.Kind);
|
||||
var opt = Assert.IsType<DhcpRangeOption>(d.TypedOption);
|
||||
Assert.Equal("192.168.1.100,192.168.1.200,255.255.255.0,12h", opt.RawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ResolvFile_ReturnsPathOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("resolv-file=/etc/resolv.dnsmasq.conf", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.Path, d!.Kind);
|
||||
var opt = Assert.IsType<PathOption>(d.TypedOption);
|
||||
Assert.Equal("resolv-file", opt.Key);
|
||||
Assert.Equal("/etc/resolv.dnsmasq.conf", opt.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_FlagOption_ReturnsRawOptionWithEmptyValue()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("domain-needed", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.Flag, d!.Kind);
|
||||
var opt = Assert.IsType<RawOption>(d.TypedOption);
|
||||
Assert.Equal("domain-needed", opt.Key);
|
||||
Assert.Equal("", opt.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_UnknownOption_ReturnsRawOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("unknown-option=foo", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.Raw, d!.Kind);
|
||||
var opt = Assert.IsType<RawOption>(d.TypedOption);
|
||||
Assert.Equal("unknown-option", opt.Key);
|
||||
Assert.Equal("foo", opt.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_KeyOnly_ReturnsRawOptionWithEmptyValue()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("some-flag", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.Raw, d!.Kind);
|
||||
var opt = Assert.IsType<RawOption>(d.TypedOption);
|
||||
Assert.Equal("some-flag", opt.Key);
|
||||
Assert.Equal("", opt.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_CommentedLine_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDirectiveParser.ParseLine("#addn-hosts=/etc/hosts", 1, SourcePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Server_ReturnsServerOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("server=8.8.8.8", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.Server, d!.Kind);
|
||||
var opt = Assert.IsType<ServerOption>(d.TypedOption);
|
||||
Assert.Equal("8.8.8.8", opt.RawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Local_ReturnsLocalOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("local=/localdomain/", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.Local, d!.Kind);
|
||||
var opt = Assert.IsType<LocalOption>(d.TypedOption);
|
||||
Assert.Equal("/localdomain/", opt.RawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Address_ReturnsAddressOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("address=/doubleclick.net/127.0.0.1", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.Address, d!.Kind);
|
||||
var opt = Assert.IsType<AddressOption>(d.TypedOption);
|
||||
Assert.Equal("/doubleclick.net/127.0.0.1", opt.RawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DhcpOption_ReturnsDhcpOptionOption()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-option=option:router,192.168.1.1", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.DhcpOption, d!.Kind);
|
||||
var opt = Assert.IsType<DhcpOptionOption>(d.TypedOption);
|
||||
Assert.Equal("option:router,192.168.1.1", opt.RawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DhcpHost_ReturnsDhcpHostEntry()
|
||||
{
|
||||
var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1, SourcePath);
|
||||
Assert.NotNull(d);
|
||||
Assert.Equal(DnsmasqOptionKind.DhcpHost, d!.Kind);
|
||||
var opt = Assert.IsType<DhcpHostEntry>(d.TypedOption);
|
||||
Assert.Single(opt.MacAddresses);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", opt.MacAddresses[0]);
|
||||
Assert.Equal("192.168.1.10", opt.Address);
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq read_file: strip from first '#' that is at word start (after whitespace) to EOL.</summary>
|
||||
[Fact]
|
||||
public void StripComment_CommentAfterSpace_StripsToEnd()
|
||||
{
|
||||
@@ -230,13 +54,12 @@ public class DnsmasqConfDirectiveParserTests
|
||||
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # comment"));
|
||||
}
|
||||
|
||||
/// <summary>TryParseKeyValue uses StripComment; value should not include # or rest of line.</summary>
|
||||
[Fact]
|
||||
public void TryParseKeyValue_LineWithComment_ValueExcludesComment()
|
||||
{
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53 # DNS port");
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal("port", kv!.Value.key);
|
||||
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
|
||||
Assert.Equal("53", kv.Value.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
@@ -174,10 +175,10 @@ public class DnsmasqConfIncludeParserTests
|
||||
if (!File.Exists(mainPath))
|
||||
return;
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, new[] { "server", "local" });
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "address");
|
||||
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "listen-address");
|
||||
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
|
||||
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
|
||||
Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines");
|
||||
Assert.Contains("1.1.1.1", servers);
|
||||
Assert.Contains("8.8.8.8", servers);
|
||||
@@ -283,9 +284,9 @@ public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "expand-hosts");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.True(result);
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "no-resolv"));
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -302,7 +303,7 @@ public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "port=53\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "no-hosts");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
@@ -322,7 +323,7 @@ public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
File.WriteAllText(f1, "cache-size=100\n");
|
||||
File.WriteAllText(f2, "cache-size=200\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, "cache-size");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal("200", value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
@@ -341,7 +342,7 @@ public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "log-facility=/data/dnsmasq.log\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, "log-facility");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal("/data/dnsmasq.log", value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
@@ -362,7 +363,7 @@ public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
File.WriteAllText(f1, "log-facility=/var/log/dnsmasq.log\n");
|
||||
File.WriteAllText(f2, "log-facility=/data/dnsmasq.log\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, "log-facility");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal("/data/dnsmasq.log", value);
|
||||
}
|
||||
finally
|
||||
@@ -399,7 +400,7 @@ public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts=1\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "expand-hosts");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
@@ -418,7 +419,7 @@ public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "Port=53\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, "port");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
|
||||
Assert.Null(value);
|
||||
}
|
||||
finally
|
||||
@@ -440,7 +441,7 @@ public class DnsmasqConfIncludeParserTests
|
||||
File.WriteAllText(main, "port=53\nconf-file=extra.conf\nport=5353\n");
|
||||
File.WriteAllText(extra, "port=54\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "port");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.Equal("54", value);
|
||||
}
|
||||
finally
|
||||
@@ -516,9 +517,12 @@ public class DnsmasqConfIncludeParserTests
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.a"), result[0].Path);
|
||||
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.b"), result[1].Path);
|
||||
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.c"), result[2].Path);
|
||||
Assert.Equal(2, result[2].Source.LineNumber);
|
||||
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
|
||||
}
|
||||
finally
|
||||
@@ -541,7 +545,9 @@ public class DnsmasqConfIncludeParserTests
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.True(result[1].Source.IsManaged);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -560,7 +566,7 @@ public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
File.WriteAllText(f1, "server=1.1.1.1\nlocal=/local/\n");
|
||||
File.WriteAllText(f2, "server=/example.com/192.168.1.1\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, new[] { "server", "local" });
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal("1.1.1.1", result[0]);
|
||||
Assert.Equal("/local/", result[1]);
|
||||
@@ -581,7 +587,7 @@ public class DnsmasqConfIncludeParserTests
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-range=172.28.0.10,172.28.0.50,12h\ndhcp-range=192.168.1.10,192.168.1.100,255.255.255.0,24h\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, "dhcp-range");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("172.28.0.10,172.28.0.50,12h", result[0]);
|
||||
Assert.Equal("192.168.1.10,192.168.1.100,255.255.255.0,24h", result[1]);
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfOptionRegistry. Maps dnsmasq .conf option names to DnsmasqOptionKind.
|
||||
/// </summary>
|
||||
public class DnsmasqConfOptionRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetKind_EmptyOrWhitespace_ReturnsRaw()
|
||||
{
|
||||
Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind(""));
|
||||
Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKind_ConfFile_ReturnsConfFile()
|
||||
{
|
||||
Assert.Equal(DnsmasqOptionKind.ConfFile, DnsmasqConfOptionRegistry.GetKind("conf-file"));
|
||||
Assert.Equal(DnsmasqOptionKind.ConfFile, DnsmasqConfOptionRegistry.GetKind("CONF-FILE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKind_ConfDir_ReturnsConfDir()
|
||||
{
|
||||
Assert.Equal(DnsmasqOptionKind.ConfDir, DnsmasqConfOptionRegistry.GetKind("conf-dir"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKind_AddnHosts_ReturnsAddnHosts()
|
||||
{
|
||||
Assert.Equal(DnsmasqOptionKind.AddnHosts, DnsmasqConfOptionRegistry.GetKind("addn-hosts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKind_DhcpLeasefile_ReturnsDhcpLeaseFile()
|
||||
{
|
||||
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-leasefile"));
|
||||
Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-lease"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKind_Domain_ReturnsDomain()
|
||||
{
|
||||
Assert.Equal(DnsmasqOptionKind.Domain, DnsmasqConfOptionRegistry.GetKind("domain"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKind_ResolvFile_ReturnsPath()
|
||||
{
|
||||
Assert.Equal(DnsmasqOptionKind.Path, DnsmasqConfOptionRegistry.GetKind("resolv-file"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKind_FlagOptions_ReturnsFlag()
|
||||
{
|
||||
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("domain-needed"));
|
||||
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("bogus-priv"));
|
||||
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("no-hosts"));
|
||||
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("expand-hosts"));
|
||||
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("bind-interfaces"));
|
||||
Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("log-queries"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKind_UnknownOption_ReturnsRaw()
|
||||
{
|
||||
Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind("unknown-option"));
|
||||
Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind("custom-key"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionKindByKey_ContainsExpectedOptions()
|
||||
{
|
||||
var registry = DnsmasqConfOptionRegistry.OptionKindByKey;
|
||||
Assert.True(registry.ContainsKey("addn-hosts"));
|
||||
Assert.True(registry.ContainsKey("dhcp-leasefile"));
|
||||
Assert.True(registry.ContainsKey("dhcp-lease"));
|
||||
Assert.True(registry.ContainsKey("conf-file"));
|
||||
Assert.True(registry.ContainsKey("conf-dir"));
|
||||
Assert.True(registry.ContainsKey("domain"));
|
||||
Assert.True(registry.ContainsKey("resolv-file"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlagOptions_ContainsExpectedFlags()
|
||||
{
|
||||
var flags = DnsmasqConfOptionRegistry.FlagOptions;
|
||||
Assert.Contains("domain-needed", flags);
|
||||
Assert.Contains("no-hosts", flags);
|
||||
Assert.Contains("bind-interfaces", flags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.EffectiveConfig
|
||||
|
||||
@if (Descriptor.IsMultiValue)
|
||||
{
|
||||
var items = Descriptor.GetItems();
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" />
|
||||
}
|
||||
else
|
||||
{
|
||||
var value = Descriptor.GetValue();
|
||||
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
|
||||
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.EffectiveConfig
|
||||
|
||||
<span class="d-inline-block me-2" title="@(Source?.GetReadOnlyTooltip())">
|
||||
@Value
|
||||
@if (Source?.IsReadOnly == true)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">readonly</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
}
|
||||
+16
-2
@@ -1,10 +1,24 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.EffectiveConfig
|
||||
|
||||
<li>
|
||||
<strong>@Label:</strong> @Content
|
||||
<strong>@Label:</strong>
|
||||
@if (Items?.Count > 0)
|
||||
{
|
||||
@for (var i = 0; i < Items.Count; i++)
|
||||
{
|
||||
if (i > 0) { <span class="me-2">, </span> }
|
||||
var item = Items[i];
|
||||
<EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" />
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">(none)</span>
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public MarkupString Content { get; set; }
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
|
||||
}
|
||||
|
||||
@@ -5,136 +5,24 @@
|
||||
@if (Status != null)
|
||||
{
|
||||
<div class="ec-sections small">
|
||||
@* Hosts *@
|
||||
<div class="ec-section">
|
||||
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("hosts")' aria-expanded="@IsOpen("hosts")">
|
||||
<span class="ec-section-title fw-bold">Hosts</span>
|
||||
<i class="bi @(IsOpen("hosts") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen("hosts") ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<EffectiveConfigOptionItem Label="no-hosts" DisplayValue="@(Status.NoHosts ? "Yes (hosts files disabled)" : "No")" Source="@(Status.EffectiveConfigSources?.NoHosts)" />
|
||||
<li>
|
||||
<strong>addn-hosts:</strong>
|
||||
@if (Status.AddnHostsPaths?.Count > 0)
|
||||
{
|
||||
var srcList = Status.EffectiveConfigSources?.AddnHostsPaths;
|
||||
if (srcList != null && srcList.Count == Status.AddnHostsPaths.Count)
|
||||
{
|
||||
@foreach (var entry in srcList)
|
||||
{
|
||||
<span class="d-inline-block me-2" title="@(entry.Source?.GetReadOnlyTooltip())">@entry.Path @(entry.Source?.IsReadOnly == true ? " (readonly)" : "")</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var deduped = (Status.AddnHostsPaths ?? Array.Empty<string>()).Distinct().ToList();
|
||||
@(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped))
|
||||
}
|
||||
}
|
||||
else { <span class="text-muted">(none)</span> }
|
||||
</li>
|
||||
@if (Status.ManagedHostsFilePath != null) { <li><strong>Managed hosts file (editable):</strong> @Status.ManagedHostsFilePath @(Status.ManagedHostsPathExists ? "✓" : "(not created yet)")</li> }
|
||||
@if (Status.SystemHostsPath != null) { <li><strong>System hosts (read-only):</strong> @Status.SystemHostsPath @(Status.SystemHostsPathExists ? "✓" : "(missing)")</li> }
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Status.EffectiveConfig != null)
|
||||
@{
|
||||
var descriptors = EffectiveConfigFieldBuilder.BuildFieldDescriptors(Status);
|
||||
var sections = new[]
|
||||
{
|
||||
(EffectiveConfigFieldBuilder.SectionHosts, "Hosts"),
|
||||
(EffectiveConfigFieldBuilder.SectionResolver, "Resolver / DNS"),
|
||||
(EffectiveConfigFieldBuilder.SectionDhcp, "DHCP"),
|
||||
(EffectiveConfigFieldBuilder.SectionCache, "Cache"),
|
||||
(EffectiveConfigFieldBuilder.SectionProcess, "Process & networking")
|
||||
};
|
||||
}
|
||||
@foreach (var (sectionId, title) in sections)
|
||||
{
|
||||
var ec = Status.EffectiveConfig;
|
||||
var src = Status.EffectiveConfigSources;
|
||||
|
||||
@* Resolver / DNS *@
|
||||
<div class="ec-section">
|
||||
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("resolver")' aria-expanded="@IsOpen("resolver")">
|
||||
<span class="ec-section-title fw-bold">Resolver / DNS</span>
|
||||
<i class="bi @(IsOpen("resolver") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen("resolver") ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<EffectiveConfigOptionItem Label="expand-hosts" DisplayValue="@(ec.ExpandHosts ? "Yes" : "No")" Source="@(src?.ExpandHosts)" />
|
||||
<EffectiveConfigOptionItem Label="bogus-priv" DisplayValue="@(ec.BogusPriv ? "Yes" : "No")" Source="@(src?.BogusPriv)" />
|
||||
<EffectiveConfigOptionItem Label="strict-order" DisplayValue="@(ec.StrictOrder ? "Yes" : "No")" Source="@(src?.StrictOrder)" />
|
||||
<EffectiveConfigOptionItem Label="no-resolv" DisplayValue="@(ec.NoResolv ? "Yes" : "No")" Source="@(src?.NoResolv)" />
|
||||
<EffectiveConfigOptionItem Label="domain-needed" DisplayValue="@(ec.DomainNeeded ? "Yes" : "No")" Source="@(src?.DomainNeeded)" />
|
||||
<EffectiveConfigOptionItem Label="port" DisplayValue="@(ec.Port switch { null => "53 (default)", 0 => "0 (no DNS listener)", _ => ec.Port.Value.ToString() })" Source="@(src?.Port)" />
|
||||
@if (ec.ServerLocalValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="server / local" Content="@((MarkupString)RenderMultiValueList(ec.ServerLocalValues, src?.ServerLocalValues))" /> }
|
||||
@if (ec.AddressValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="address" Content="@((MarkupString)RenderMultiValueList(ec.AddressValues, src?.AddressValues))" /> }
|
||||
@if (ec.ResolvFiles?.Count > 0) { <EffectiveConfigMultiValueRow Label="resolv-file" Content="@((MarkupString)RenderMultiValueList(ec.ResolvFiles, src?.ResolvFiles))" /> }
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* DHCP *@
|
||||
<div class="ec-section">
|
||||
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("dhcp")' aria-expanded="@IsOpen("dhcp")">
|
||||
<span class="ec-section-title fw-bold">DHCP</span>
|
||||
<i class="bi @(IsOpen("dhcp") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen("dhcp") ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<EffectiveConfigOptionItem Label="dhcp-authoritative" DisplayValue="@(ec.DhcpAuthoritative ? "Yes" : "No")" Source="@(src?.DhcpAuthoritative)" />
|
||||
<EffectiveConfigOptionItem Label="leasefile-ro" DisplayValue="@(ec.LeasefileRo ? "Yes" : "No")" Source="@(src?.LeasefileRo)" />
|
||||
<EffectiveConfigOptionItem Label="dhcp-leasefile" DisplayValue="@(ec.DhcpLeaseFilePath ?? "(default)")" Source="@(src?.DhcpLeaseFilePath)" />
|
||||
@if (ec.DhcpLeaseMax.HasValue) { <EffectiveConfigOptionItem Label="dhcp-lease-max" DisplayValue="@ec.DhcpLeaseMax.Value.ToString()" Source="@(src?.DhcpLeaseMax)" /> }
|
||||
@if (ec.DhcpTtl.HasValue) { <EffectiveConfigOptionItem Label="dhcp-ttl" DisplayValue="@ec.DhcpTtl.Value.ToString()" Source="@(src?.DhcpTtl)" /> }
|
||||
@if (ec.DhcpRanges?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-range" Content="@((MarkupString)RenderMultiValueList(ec.DhcpRanges, src?.DhcpRanges))" /> }
|
||||
@if (ec.DhcpHostLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-host" Content="@((MarkupString)RenderMultiValueList(ec.DhcpHostLines, src?.DhcpHostLines))" /> }
|
||||
@if (ec.DhcpOptionLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-option" Content="@((MarkupString)RenderMultiValueList(ec.DhcpOptionLines, src?.DhcpOptionLines))" /> }
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Cache *@
|
||||
<div class="ec-section">
|
||||
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("cache")' aria-expanded="@IsOpen("cache")">
|
||||
<span class="ec-section-title fw-bold">Cache</span>
|
||||
<i class="bi @(IsOpen("cache") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen("cache") ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
@if (ec.CacheSize.HasValue) { <EffectiveConfigOptionItem Label="cache-size" DisplayValue="@ec.CacheSize.Value.ToString()" Source="@(src?.CacheSize)" /> }
|
||||
@if (ec.LocalTtl.HasValue) { <EffectiveConfigOptionItem Label="local-ttl" DisplayValue="@ec.LocalTtl.Value.ToString()" Source="@(src?.LocalTtl)" /> }
|
||||
<EffectiveConfigOptionItem Label="no-negcache" DisplayValue="@(ec.NoNegcache ? "Yes" : "No")" Source="@(src?.NoNegcache)" />
|
||||
@if (ec.NegTtl.HasValue) { <EffectiveConfigOptionItem Label="neg-ttl" DisplayValue="@ec.NegTtl.Value.ToString()" Source="@(src?.NegTtl)" /> }
|
||||
@if (ec.MaxTtl.HasValue) { <EffectiveConfigOptionItem Label="max-ttl" DisplayValue="@ec.MaxTtl.Value.ToString()" Source="@(src?.MaxTtl)" /> }
|
||||
@if (ec.MaxCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="max-cache-ttl" DisplayValue="@ec.MaxCacheTtl.Value.ToString()" Source="@(src?.MaxCacheTtl)" /> }
|
||||
@if (ec.MinCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="min-cache-ttl" DisplayValue="@ec.MinCacheTtl.Value.ToString()" Source="@(src?.MinCacheTtl)" /> }
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Process & networking *@
|
||||
<div class="ec-section">
|
||||
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("process")' aria-expanded="@IsOpen("process")">
|
||||
<span class="ec-section-title fw-bold">Process & networking</span>
|
||||
<i class="bi @(IsOpen("process") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen("process") ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<EffectiveConfigOptionItem Label="no-poll" DisplayValue="@(ec.NoPoll ? "Yes" : "No")" Source="@(src?.NoPoll)" />
|
||||
<EffectiveConfigOptionItem Label="bind-interfaces" DisplayValue="@(ec.BindInterfaces ? "Yes" : "No")" Source="@(src?.BindInterfaces)" />
|
||||
@if (ec.Interfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="interface" Content="@((MarkupString)RenderMultiValueList(ec.Interfaces, src?.Interfaces))" /> }
|
||||
@if (ec.ListenAddresses?.Count > 0) { <EffectiveConfigMultiValueRow Label="listen-address" Content="@((MarkupString)RenderMultiValueList(ec.ListenAddresses, src?.ListenAddresses))" /> }
|
||||
@if (ec.ExceptInterfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="except-interface" Content="@((MarkupString)RenderMultiValueList(ec.ExceptInterfaces, src?.ExceptInterfaces))" /> }
|
||||
@if (ec.PidFilePath != null) { <EffectiveConfigOptionItem Label="pid-file" DisplayValue="@ec.PidFilePath" Source="@(src?.PidFilePath)" /> }
|
||||
@if (ec.User != null) { <EffectiveConfigOptionItem Label="user" DisplayValue="@ec.User" Source="@(src?.User)" /> }
|
||||
@if (ec.Group != null) { <EffectiveConfigOptionItem Label="group" DisplayValue="@ec.Group" Source="@(src?.Group)" /> }
|
||||
@if (ec.LogFacility != null) { <EffectiveConfigOptionItem Label="log-facility" DisplayValue="@ec.LogFacility" Source="@(src?.LogFacility)" /> }
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
var sectionDescriptors = descriptors.Where(d => d.SectionId == sectionId).ToList();
|
||||
if (sectionDescriptors.Count > 0)
|
||||
{
|
||||
<EffectiveConfigSectionPanel SectionId="@sectionId" Title="@title" Descriptors="@sectionDescriptors" IsOpen="@IsOpen(sectionId)" OnToggle="@OnSectionToggle" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -146,33 +34,11 @@
|
||||
|
||||
private bool IsOpen(string id) => _openPanels.Contains(id);
|
||||
|
||||
private void TogglePanel(string id)
|
||||
private void OnSectionToggle(string id)
|
||||
{
|
||||
if (_openPanels.Contains(id))
|
||||
_openPanels.Remove(id);
|
||||
else
|
||||
_openPanels.Add(id);
|
||||
}
|
||||
|
||||
private static MarkupString RenderMultiValueList(IReadOnlyList<string>? values, IReadOnlyList<ValueWithSource>? sources)
|
||||
{
|
||||
if (values == null || values.Count == 0)
|
||||
return (MarkupString)"(none)";
|
||||
if (sources != null && sources.Count == values.Count)
|
||||
{
|
||||
var parts = sources.Select(e =>
|
||||
{
|
||||
var s = e.Source;
|
||||
var valueEnc = System.Net.WebUtility.HtmlEncode(e.Value);
|
||||
if (s?.IsReadOnly == true)
|
||||
{
|
||||
var tipEnc = System.Net.WebUtility.HtmlEncode(s.GetReadOnlyTooltip() ?? "");
|
||||
return $"{valueEnc} <span class=\"badge bg-secondary ms-1\" title=\"{tipEnc}\">readonly</span>";
|
||||
}
|
||||
return valueEnc;
|
||||
});
|
||||
return (MarkupString)string.Join(", ", parts);
|
||||
}
|
||||
return (MarkupString)System.Net.WebUtility.HtmlEncode(string.Join(", ", values));
|
||||
}
|
||||
}
|
||||
|
||||
+37
-16
@@ -1,28 +1,30 @@
|
||||
/* Effective config sections – mobile-first, touch-friendly */
|
||||
/* Effective config sections – mobile-first, touch-friendly.
|
||||
Use ::deep so styles apply to EffectiveConfigSectionPanel (child component) markup. */
|
||||
.ec-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.ec-section {
|
||||
::deep .ec-section {
|
||||
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
|
||||
}
|
||||
|
||||
.ec-section:last-child {
|
||||
::deep .ec-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Touch-friendly header: min 44px tap target (WCAG 2.5.5) */
|
||||
.ec-section-header {
|
||||
::deep .ec-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.4rem 0;
|
||||
padding: 0.5rem 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
@@ -30,30 +32,49 @@
|
||||
font-size: inherit;
|
||||
line-height: 1.3;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.ec-section-header:hover {
|
||||
::deep .ec-section-header:hover {
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.ec-section-header:focus-visible {
|
||||
::deep .ec-section-header:focus-visible {
|
||||
outline: 2px solid var(--bs-primary, #0d6efd);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ec-section-title {
|
||||
/* Open state: header reads as "active" and clearly separates from body */
|
||||
::deep .ec-section-open .ec-section-header {
|
||||
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.05));
|
||||
color: var(--bs-emphasis-color, inherit);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::deep .ec-section-open .ec-section-header:hover {
|
||||
background-color: var(--bs-tertiary-bg, rgba(0, 0, 0, 0.08));
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
|
||||
::deep .ec-section-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ec-section-chevron {
|
||||
::deep .ec-section-chevron {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
margin-left: 0.35rem;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ec-section-body {
|
||||
::deep .ec-section-open .ec-section-chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::deep .ec-section-body {
|
||||
padding: 0.25rem 0 0.5rem 0;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
|
||||
@@ -62,18 +83,18 @@
|
||||
}
|
||||
|
||||
/* Long paths/values wrap on small screens; badges stay inline */
|
||||
.ec-section-body ul {
|
||||
::deep .ec-section-body ul {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ec-section-body li {
|
||||
::deep .ec-section-body li {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.ec-section-body {
|
||||
::deep .ec-section-body {
|
||||
padding-left: 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.EffectiveConfig
|
||||
|
||||
<div class="ec-section @(IsOpen ? "ec-section-open" : "")">
|
||||
<button type="button" class="ec-section-header" @onclick="ToggleSection" aria-expanded="@IsOpen">
|
||||
<span class="ec-section-title fw-bold">@Title</span>
|
||||
<i class="bi @(IsOpen ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
@foreach (var f in Descriptors)
|
||||
{
|
||||
<EffectiveConfigFieldDisplay Descriptor="@f" />
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string SectionId { get; set; } = "";
|
||||
[Parameter] public string Title { get; set; } = "";
|
||||
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
|
||||
[Parameter] public bool IsOpen { get; set; }
|
||||
[Parameter] public EventCallback<string> OnToggle { get; set; }
|
||||
|
||||
private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
namespace DnsmasqWebUI.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of dnsmasq .conf option names to their <see cref="DnsmasqOptionKind"/>.
|
||||
/// Used by <see cref="DnsmasqConfDirectiveParser"/> to dispatch each line to the correct backing model.
|
||||
/// Add entries as we add parsers for more options.
|
||||
/// </summary>
|
||||
public static class DnsmasqConfOptionRegistry
|
||||
{
|
||||
/// <summary>Option name (config key) to kind. Keys are lowercase; config file uses same as long option without "--".</summary>
|
||||
public static IReadOnlyDictionary<string, DnsmasqOptionKind> OptionKindByKey { get; } =
|
||||
new Dictionary<string, DnsmasqOptionKind>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Include
|
||||
{ DnsmasqConfKeys.ConfFile, DnsmasqOptionKind.ConfFile },
|
||||
{ DnsmasqConfKeys.ConfDir, DnsmasqOptionKind.ConfDir },
|
||||
// Path-valued
|
||||
{ DnsmasqConfKeys.AddnHosts, DnsmasqOptionKind.AddnHosts },
|
||||
{ DnsmasqConfKeys.DhcpLeasefile, DnsmasqOptionKind.DhcpLeaseFile },
|
||||
{ DnsmasqConfKeys.DhcpLease, DnsmasqOptionKind.DhcpLeaseFile },
|
||||
{ DnsmasqConfKeys.ResolvFile, DnsmasqOptionKind.Path },
|
||||
{ "dhcp-hostsfile", DnsmasqOptionKind.Path },
|
||||
{ "dhcp-optsfile", DnsmasqOptionKind.Path },
|
||||
{ DnsmasqConfKeys.PidFile, DnsmasqOptionKind.Path },
|
||||
{ "hostsdir", DnsmasqOptionKind.Path },
|
||||
{ "dhcp-script", DnsmasqOptionKind.Path },
|
||||
{ "dhcp-lua-script", DnsmasqOptionKind.Path },
|
||||
{ "read-ethers", DnsmasqOptionKind.Path },
|
||||
// Simple string / domain
|
||||
{ "domain", DnsmasqOptionKind.Domain },
|
||||
{ DnsmasqConfKeys.Interface, DnsmasqOptionKind.String },
|
||||
{ DnsmasqConfKeys.ListenAddress, DnsmasqOptionKind.String },
|
||||
{ DnsmasqConfKeys.Port, DnsmasqOptionKind.String },
|
||||
{ DnsmasqConfKeys.User, DnsmasqOptionKind.String },
|
||||
{ DnsmasqConfKeys.Group, DnsmasqOptionKind.String },
|
||||
{ DnsmasqConfKeys.CacheSize, DnsmasqOptionKind.String },
|
||||
{ DnsmasqConfKeys.LocalTtl, DnsmasqOptionKind.String },
|
||||
{ DnsmasqConfKeys.LogFacility, DnsmasqOptionKind.String },
|
||||
{ "dhcp-ignore", DnsmasqOptionKind.String },
|
||||
{ "min-port", DnsmasqOptionKind.String },
|
||||
{ "max-port", DnsmasqOptionKind.String },
|
||||
{ "query-port", DnsmasqOptionKind.String },
|
||||
{ "edns-packet-max", DnsmasqOptionKind.String },
|
||||
// DHCP structured
|
||||
{ DnsmasqConfKeys.DhcpRange, DnsmasqOptionKind.DhcpRange },
|
||||
{ DnsmasqConfKeys.DhcpHost, DnsmasqOptionKind.DhcpHost },
|
||||
{ DnsmasqConfKeys.DhcpOption, DnsmasqOptionKind.DhcpOption },
|
||||
// DNS structured
|
||||
{ DnsmasqConfKeys.Server, DnsmasqOptionKind.Server },
|
||||
{ DnsmasqConfKeys.Local, DnsmasqOptionKind.Local },
|
||||
{ DnsmasqConfKeys.Address, DnsmasqOptionKind.Address },
|
||||
};
|
||||
|
||||
/// <summary>Well-known flag options (no value).</summary>
|
||||
public static IReadOnlySet<string> FlagOptions { get; } =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
DnsmasqConfKeys.DomainNeeded, DnsmasqConfKeys.BogusPriv, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ExpandHosts, DnsmasqConfKeys.StrictOrder,
|
||||
DnsmasqConfKeys.NoResolv, DnsmasqConfKeys.NoPoll, DnsmasqConfKeys.BindInterfaces, DnsmasqConfKeys.NoNegcache, "log-queries", "log-dhcp",
|
||||
"all-servers", DnsmasqConfKeys.LeasefileRo, DnsmasqConfKeys.DhcpAuthoritative,
|
||||
"quiet-dhcp", "quiet-dhcp6", "quiet-ra", "dhcp-broadcast", "dhcp-sequential-ip",
|
||||
"enable-tftp", "self-resolve", "conntrack",
|
||||
};
|
||||
|
||||
/// <summary>Resolve option key (e.g. "addn-hosts") to kind; returns Raw if unknown.</summary>
|
||||
public static DnsmasqOptionKind GetKind(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return DnsmasqOptionKind.Raw;
|
||||
var k = key.Trim();
|
||||
if (FlagOptions.Contains(k)) return DnsmasqOptionKind.Flag;
|
||||
return OptionKindByKey.TryGetValue(k, out var kind) ? kind : DnsmasqOptionKind.Raw;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
namespace DnsmasqWebUI.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Kind of dnsmasq .conf option. Each kind has a dedicated parser and backing model
|
||||
/// so we parse once into structured data instead of re-scanning files for single fields.
|
||||
/// See testdata/dnsmasq.conf.example and man 8 dnsmasq.
|
||||
/// </summary>
|
||||
public enum DnsmasqOptionKind
|
||||
{
|
||||
/// <summary>Unknown or not-yet-typed option; store as key=value.</summary>
|
||||
Raw,
|
||||
|
||||
// --- Include (main config only) ---
|
||||
/// <summary>conf-file=path → <see cref="ConfFileOption"/></summary>
|
||||
ConfFile,
|
||||
/// <summary>conf-dir=path[,suffix] → <see cref="ConfDirOption"/></summary>
|
||||
ConfDir,
|
||||
|
||||
// --- Path-valued (single or cumulative) ---
|
||||
/// <summary>addn-hosts=path (cumulative) → <see cref="AddnHostsOption"/></summary>
|
||||
AddnHosts,
|
||||
/// <summary>dhcp-leasefile= or dhcp-lease-file= (last wins) → <see cref="DhcpLeaseFileOption"/></summary>
|
||||
DhcpLeaseFile,
|
||||
/// <summary>resolv-file=, dhcp-hostsfile=, etc. → path model</summary>
|
||||
Path,
|
||||
|
||||
// --- Simple string ---
|
||||
/// <summary>domain=value → <see cref="DomainOption"/></summary>
|
||||
Domain,
|
||||
/// <summary>user=, group=, port=, etc. → RawOption</summary>
|
||||
String,
|
||||
/// <summary>Flag (no value): domain-needed, bogus-priv, no-hosts, expand-hosts, etc.</summary>
|
||||
Flag,
|
||||
|
||||
// --- DHCP (structured) ---
|
||||
/// <summary>dhcp-range=... → <see cref="DhcpRangeOption"/> (complex)</summary>
|
||||
DhcpRange,
|
||||
/// <summary>dhcp-host=... → DhcpHostEntry (in DnsmasqWebUI.Models)</summary>
|
||||
DhcpHost,
|
||||
/// <summary>dhcp-option=... (complex)</summary>
|
||||
DhcpOption,
|
||||
|
||||
// --- DNS (structured) ---
|
||||
/// <summary>server=/domain/ip or server=ip → server model</summary>
|
||||
Server,
|
||||
/// <summary>local=/domain/</summary>
|
||||
Local,
|
||||
/// <summary>address=/domain/ip</summary>
|
||||
Address,
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
namespace DnsmasqWebUI.Models.Config;
|
||||
|
||||
/// <summary>Backing model for conf-file=path. Parsed from main config only.</summary>
|
||||
public record ConfFileOption(string Path, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Backing model for conf-dir=path[,suffix]. Parsed from main config only.</summary>
|
||||
public record ConfDirOption(string Path, string? Suffix, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Backing model for addn-hosts=path. Cumulative across files.</summary>
|
||||
public record AddnHostsOption(string Path, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Backing model for dhcp-leasefile= or dhcp-lease-file=. Last wins.</summary>
|
||||
public record DhcpLeaseFileOption(string Path, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Backing model for domain=value. Last wins (or first per-subnet if domain=name,subnet).</summary>
|
||||
public record DomainOption(string Value, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Generic path-valued option (resolv-file, dhcp-hostsfile, etc.).</summary>
|
||||
public record PathOption(string Key, string Path, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Generic key=value option (port, user, cache-size, etc.) or unknown option.</summary>
|
||||
public record RawOption(string Key, string Value, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Backing model for dhcp-range=... (start,end,netmask,lease or tag:..., set:..., etc.). Parsed by DhcpRangeOptionParser when needed.</summary>
|
||||
public record DhcpRangeOption(string RawValue, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Backing model for dhcp-option=... (option:value or option:value,value). Raw value for now.</summary>
|
||||
public record DhcpOptionOption(string RawValue, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Backing model for server=/domain/ip or server=ip. Raw value for now.</summary>
|
||||
public record ServerOption(string RawValue, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Backing model for local=/domain/. Raw value for now.</summary>
|
||||
public record LocalOption(string RawValue, int LineNumber, string SourceFilePath);
|
||||
|
||||
/// <summary>Backing model for address=/domain/ip. Raw value for now.</summary>
|
||||
public record AddressOption(string RawValue, int LineNumber, string SourceFilePath);
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace DnsmasqWebUI.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// One parsed directive from a dnsmasq .conf file. Kind tells which option type;
|
||||
/// TypedOption is the backing model when we have a parser for it (AddnHostsOption, DhcpLeaseFileOption, etc.),
|
||||
/// otherwise RawOption with Key and Value.
|
||||
/// </summary>
|
||||
public sealed class DnsmasqConfDirective
|
||||
{
|
||||
public int LineNumber { get; init; }
|
||||
public string SourceFilePath { get; init; } = "";
|
||||
public DnsmasqOptionKind Kind { get; init; }
|
||||
public object TypedOption { get; init; } = null!; // AddnHostsOption | DhcpLeaseFileOption | DomainOption | RawOption | ...
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
namespace DnsmasqWebUI.Models.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Where an effective config value came from (which file). Used so the UI can show exactly which file
|
||||
/// Where an effective config value came from (which file and line). Used so the UI can show exactly which file
|
||||
/// set the value and why it is readonly or editable.
|
||||
/// </summary>
|
||||
/// <param name="FilePath">Absolute path of the config file that set this value. Use for "edit this file" or tooltips.</param>
|
||||
/// <param name="FileName">Filename only (e.g. dnsmasq.conf, 01-default.conf) for compact display.</param>
|
||||
/// <param name="IsManaged">True when this file is the app's managed config file (editable from UI).</param>
|
||||
/// <param name="LineNumber">1-based line number in the file where the value was set; null if unknown.</param>
|
||||
/// <remarks>
|
||||
/// When <see cref="IsManaged"/> is true: the user can change or remove the value from the UI (we read/write the managed file).
|
||||
/// When false (<see cref="IsReadOnly"/> is true): the value is from main or an included conf file; the user must edit that file to change it.
|
||||
/// For single-value or multi-value options: show "from FileName (readonly)" and e.g. "Edit FilePath to change."
|
||||
/// For flags: if the flag is set in a non-managed file, the user cannot unset it from the UI (they must remove the line in that file).
|
||||
/// </remarks>
|
||||
public record ConfigValueSource(string FilePath, string FileName, bool IsManaged)
|
||||
public record ConfigValueSource(string FilePath, string FileName, bool IsManaged, int? LineNumber = null)
|
||||
{
|
||||
/// <summary>True when the value is from a non-managed file: user cannot change it from the UI; for flags, cannot unset without editing that file.</summary>
|
||||
public bool IsReadOnly => !IsManaged;
|
||||
|
||||
/// <summary>Returns a short tooltip for readonly values (which file and how to change). Null when <see cref="IsManaged"/> (editable).</summary>
|
||||
/// <summary>Returns a short tooltip for readonly values (which file, optional line, and how to change). Null when <see cref="IsManaged"/> (editable).</summary>
|
||||
public string? GetReadOnlyTooltip() =>
|
||||
IsReadOnly ? $"From {FileName} (readonly). Edit {FilePath} to change." : null;
|
||||
IsReadOnly
|
||||
? LineNumber.HasValue
|
||||
? $"From {FileName} line {LineNumber} (readonly). Edit {FilePath} to change."
|
||||
: $"From {FileName} (readonly). Edit {FilePath} to change."
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Value plus its config file source. Used for multi-value options (server/local, address, etc.) so JSON serializes as "value" and "source".</summary>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Status;
|
||||
|
||||
namespace DnsmasqWebUI.Models.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Builds field descriptors for viewing and editing. Mapping is done once here via delegates passed to each descriptor.
|
||||
/// </summary>
|
||||
public static class EffectiveConfigFieldBuilder
|
||||
{
|
||||
private static EffectiveDnsmasqConfig? Config(DnsmasqServiceStatus? s) => s?.EffectiveConfig;
|
||||
private static EffectiveConfigSources? Sources(DnsmasqServiceStatus? s) => s?.EffectiveConfigSources;
|
||||
|
||||
/// <summary>Builds getItems from config/source list selectors; used by Multi() for fields that have both.</summary>
|
||||
private static Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?> Items(
|
||||
Func<EffectiveDnsmasqConfig?, IReadOnlyList<string>?> getValues,
|
||||
Func<EffectiveConfigSources?, IReadOnlyList<ValueWithSource>?> getWithSource)
|
||||
{
|
||||
return s =>
|
||||
{
|
||||
var values = getValues(Config(s));
|
||||
var withSource = getWithSource(Sources(s));
|
||||
if (values == null || values.Count == 0) return null;
|
||||
return withSource != null && withSource.Count == values.Count
|
||||
? withSource
|
||||
: values.Select(v => new ValueWithSource(v, null)).ToList();
|
||||
};
|
||||
}
|
||||
|
||||
public const string SectionHosts = "hosts";
|
||||
public const string SectionResolver = "resolver";
|
||||
public const string SectionDhcp = "dhcp";
|
||||
public const string SectionCache = "cache";
|
||||
public const string SectionProcess = "process";
|
||||
|
||||
public static IReadOnlyList<EffectiveConfigFieldDescriptor> BuildFieldDescriptors(DnsmasqServiceStatus? status)
|
||||
{
|
||||
if (status == null || status.EffectiveConfig == null)
|
||||
return Array.Empty<EffectiveConfigFieldDescriptor>();
|
||||
|
||||
var list = new List<EffectiveConfigFieldDescriptor>();
|
||||
|
||||
// --- Hosts ---
|
||||
list.Add(Single(SectionHosts, DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null));
|
||||
list.Add(Multi(SectionHosts, DnsmasqConfKeys.AddnHosts, status,
|
||||
s => ToValueWithSourceList(s?.AddnHostsPaths, Sources(s)?.AddnHostsPaths)));
|
||||
|
||||
// --- Resolver / DNS ---
|
||||
list.Add(Single(SectionResolver, DnsmasqConfKeys.ExpandHosts, status, s => Config(s)?.ExpandHosts, s => Sources(s)?.ExpandHosts, null));
|
||||
list.Add(Single(SectionResolver, DnsmasqConfKeys.BogusPriv, status, s => Config(s)?.BogusPriv, s => Sources(s)?.BogusPriv, null));
|
||||
list.Add(Single(SectionResolver, DnsmasqConfKeys.StrictOrder, status, s => Config(s)?.StrictOrder, s => Sources(s)?.StrictOrder, null));
|
||||
list.Add(Single(SectionResolver, DnsmasqConfKeys.NoResolv, status, s => Config(s)?.NoResolv, s => Sources(s)?.NoResolv, null));
|
||||
list.Add(Single(SectionResolver, DnsmasqConfKeys.DomainNeeded, status, s => Config(s)?.DomainNeeded, s => Sources(s)?.DomainNeeded, null));
|
||||
list.Add(Single(SectionResolver, DnsmasqConfKeys.Port, status, s => Config(s)?.Port, s => Sources(s)?.Port, null));
|
||||
list.Add(Multi(SectionResolver, "server / local", status, Items(ec => ec?.ServerLocalValues, src => src?.ServerLocalValues)));
|
||||
list.Add(Multi(SectionResolver, DnsmasqConfKeys.Address, status, Items(ec => ec?.AddressValues, src => src?.AddressValues)));
|
||||
list.Add(Multi(SectionResolver, DnsmasqConfKeys.ResolvFile, status, Items(ec => ec?.ResolvFiles, src => src?.ResolvFiles)));
|
||||
|
||||
// --- DHCP ---
|
||||
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null));
|
||||
list.Add(Single(SectionDhcp, DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, null));
|
||||
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpLeasefile, status, s => Config(s)?.DhcpLeaseFilePath, s => Sources(s)?.DhcpLeaseFilePath, null));
|
||||
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, status, s => Config(s)?.DhcpLeaseMax, s => Sources(s)?.DhcpLeaseMax, null));
|
||||
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpTtl, status, s => Config(s)?.DhcpTtl, s => Sources(s)?.DhcpTtl, null));
|
||||
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpRange, status, Items(ec => ec?.DhcpRanges, src => src?.DhcpRanges)));
|
||||
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpHost, status, Items(ec => ec?.DhcpHostLines, src => src?.DhcpHostLines)));
|
||||
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpOption, status, Items(ec => ec?.DhcpOptionLines, src => src?.DhcpOptionLines)));
|
||||
|
||||
// --- Cache ---
|
||||
list.Add(Single(SectionCache, DnsmasqConfKeys.CacheSize, status, s => Config(s)?.CacheSize, s => Sources(s)?.CacheSize, null));
|
||||
list.Add(Single(SectionCache, DnsmasqConfKeys.LocalTtl, status, s => Config(s)?.LocalTtl, s => Sources(s)?.LocalTtl, null));
|
||||
list.Add(Single(SectionCache, DnsmasqConfKeys.NoNegcache, status, s => Config(s)?.NoNegcache, s => Sources(s)?.NoNegcache, null));
|
||||
list.Add(Single(SectionCache, DnsmasqConfKeys.NegTtl, status, s => Config(s)?.NegTtl, s => Sources(s)?.NegTtl, null));
|
||||
list.Add(Single(SectionCache, DnsmasqConfKeys.MaxTtl, status, s => Config(s)?.MaxTtl, s => Sources(s)?.MaxTtl, null));
|
||||
list.Add(Single(SectionCache, DnsmasqConfKeys.MaxCacheTtl, status, s => Config(s)?.MaxCacheTtl, s => Sources(s)?.MaxCacheTtl, null));
|
||||
list.Add(Single(SectionCache, DnsmasqConfKeys.MinCacheTtl, status, s => Config(s)?.MinCacheTtl, s => Sources(s)?.MinCacheTtl, null));
|
||||
|
||||
// --- Process & networking ---
|
||||
list.Add(Single(SectionProcess, DnsmasqConfKeys.NoPoll, status, s => Config(s)?.NoPoll, s => Sources(s)?.NoPoll, null));
|
||||
list.Add(Single(SectionProcess, DnsmasqConfKeys.BindInterfaces, status, s => Config(s)?.BindInterfaces, s => Sources(s)?.BindInterfaces, null));
|
||||
list.Add(Multi(SectionProcess, DnsmasqConfKeys.Interface, status, Items(ec => ec?.Interfaces, src => src?.Interfaces)));
|
||||
list.Add(Multi(SectionProcess, DnsmasqConfKeys.ListenAddress, status, Items(ec => ec?.ListenAddresses, src => src?.ListenAddresses)));
|
||||
list.Add(Multi(SectionProcess, DnsmasqConfKeys.ExceptInterface, status, Items(ec => ec?.ExceptInterfaces, src => src?.ExceptInterfaces)));
|
||||
list.Add(Single(SectionProcess, DnsmasqConfKeys.PidFile, status, s => Config(s)?.PidFilePath, s => Sources(s)?.PidFilePath, null));
|
||||
list.Add(Single(SectionProcess, DnsmasqConfKeys.User, status, s => Config(s)?.User, s => Sources(s)?.User, null));
|
||||
list.Add(Single(SectionProcess, DnsmasqConfKeys.Group, status, s => Config(s)?.Group, s => Sources(s)?.Group, null));
|
||||
list.Add(Single(SectionProcess, DnsmasqConfKeys.LogFacility, status, s => Config(s)?.LogFacility, s => Sources(s)?.LogFacility, null));
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static EffectiveConfigFieldDescriptor Single(string sectionId, string optionName, DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, object?>? getValue,
|
||||
Func<DnsmasqServiceStatus?, ConfigValueSource?>? getSource,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
|
||||
{
|
||||
return new EffectiveConfigFieldDescriptor(sectionId, optionName, false, status, getValue, getSource, getItems);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ValueWithSource>? ToValueWithSourceList(IReadOnlyList<string>? paths, IReadOnlyList<PathWithSource>? withSource)
|
||||
{
|
||||
if (paths == null || paths.Count == 0) return null;
|
||||
return withSource != null && withSource.Count == paths.Count
|
||||
? withSource.Select(p => new ValueWithSource(p.Path, p.Source)).ToList()
|
||||
: paths.Select(p => new ValueWithSource(p, null)).ToList();
|
||||
}
|
||||
|
||||
private static EffectiveConfigFieldDescriptor Multi(string sectionId, string optionName, DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
|
||||
{
|
||||
return new EffectiveConfigFieldDescriptor(sectionId, optionName, true, status, null, null, getItems);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using DnsmasqWebUI.Models.Status;
|
||||
|
||||
namespace DnsmasqWebUI.Models.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Glue for rendering one effective-config field: metadata (section, option name) plus delegates that resolve value, source, and items from Status at render time.
|
||||
/// </summary>
|
||||
public record EffectiveConfigFieldDescriptor(
|
||||
string SectionId,
|
||||
string OptionName,
|
||||
bool IsMultiValue,
|
||||
DnsmasqServiceStatus? Status,
|
||||
Func<DnsmasqServiceStatus?, object?>? ResolveValue,
|
||||
Func<DnsmasqServiceStatus?, ConfigValueSource?>? ResolveSource,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? ResolveItems
|
||||
)
|
||||
{
|
||||
public object? GetValue() => ResolveValue?.Invoke(Status);
|
||||
public ConfigValueSource? GetSource() => ResolveSource?.Invoke(Status);
|
||||
public IReadOnlyList<ValueWithSource>? GetItems() => ResolveItems?.Invoke(Status);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.EffectiveConfig;
|
||||
using Superpower;
|
||||
using Superpower.Model;
|
||||
using Superpower.Parsers;
|
||||
@@ -7,9 +5,8 @@ using Superpower.Parsers;
|
||||
namespace DnsmasqWebUI.Parsers;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single line from a dnsmasq .conf file into a <see cref="DnsmasqConfDirective"/> using
|
||||
/// <see cref="DnsmasqConfOptionRegistry"/> to resolve option kind and dispatching to the
|
||||
/// appropriate backing model. Add parsers here for each option kind we support.
|
||||
/// Key/value parsing for dnsmasq .conf lines. Used by <see cref="DnsmasqConfIncludeParser"/> to build effective config.
|
||||
/// StripComment and TryParseKeyValue handle comments and key=value (or key-only) lines.
|
||||
/// </summary>
|
||||
public static class DnsmasqConfDirectiveParser
|
||||
{
|
||||
@@ -64,60 +61,4 @@ public static class DnsmasqConfDirectiveParser
|
||||
errorPosition = result.ErrorPosition;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Parse one non-blank, non-comment .conf line into a typed directive. Returns null if line is empty or comment.</summary>
|
||||
public static DnsmasqConfDirective? ParseLine(string line, int lineNumber, string sourceFilePath)
|
||||
{
|
||||
var kv = TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
return null;
|
||||
|
||||
var (key, value) = kv.Value;
|
||||
var kind = DnsmasqConfOptionRegistry.GetKind(key);
|
||||
var dir = Path.GetDirectoryName(sourceFilePath) ?? "";
|
||||
|
||||
static string ResolvePath(string baseDir, string val)
|
||||
{
|
||||
if (string.IsNullOrEmpty(val)) return val;
|
||||
return Path.IsPathRooted(val) ? Path.GetFullPath(val) : Path.GetFullPath(Path.Combine(baseDir, val));
|
||||
}
|
||||
|
||||
static ConfDirOption ParseConfDirValue(string value, string baseDir, int lineNumber, string sourceFilePath)
|
||||
{
|
||||
var parts = value.Split(',', 2);
|
||||
var path = parts[0].Trim();
|
||||
var suffix = parts.Length > 1 ? parts[1].Trim() : null;
|
||||
if (string.IsNullOrEmpty(suffix)) suffix = null;
|
||||
return new ConfDirOption(ResolvePath(baseDir, path), suffix, lineNumber, sourceFilePath);
|
||||
}
|
||||
|
||||
object typed = kind switch
|
||||
{
|
||||
DnsmasqOptionKind.ConfFile => new ConfFileOption(ResolvePath(dir, value), lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.ConfDir => ParseConfDirValue(value, dir, lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.AddnHosts => new AddnHostsOption(ResolvePath(dir, value), lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.DhcpLeaseFile => new DhcpLeaseFileOption(ResolvePath(dir, value), lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.Domain => new DomainOption(value, lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.DhcpRange => new DhcpRangeOption(value, lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.DhcpOption => new DhcpOptionOption(value, lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.DhcpHost => DnsmasqConfDhcpHostLineParser.ParseLine(key + (string.IsNullOrEmpty(value) ? "" : "=" + value), lineNumber) is { } dhcp
|
||||
? dhcp
|
||||
: new RawOption(key, value, lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.Server => new ServerOption(value, lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.Local => new LocalOption(value, lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.Address => new AddressOption(value, lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.String => new RawOption(key, value, lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.Path => new PathOption(key, ResolvePath(dir, value), lineNumber, sourceFilePath),
|
||||
DnsmasqOptionKind.Flag => new RawOption(key, "", lineNumber, sourceFilePath),
|
||||
_ => new RawOption(key, value, lineNumber, sourceFilePath)
|
||||
};
|
||||
|
||||
return new DnsmasqConfDirective
|
||||
{
|
||||
LineNumber = lineNumber,
|
||||
SourceFilePath = sourceFilePath,
|
||||
Kind = kind,
|
||||
TypedOption = typed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,8 +447,8 @@ public static class DnsmasqConfIncludeParser
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ConfigValueSource MakeSource(string configPath, string? managedFilePath) =>
|
||||
new(configPath, Path.GetFileName(configPath), string.Equals(Path.GetFullPath(configPath), managedFilePath != null ? Path.GetFullPath(managedFilePath) : null, StringComparison.Ordinal));
|
||||
private static ConfigValueSource MakeSource(string configPath, string? managedFilePath, int? lineNumber = null) =>
|
||||
new(configPath, Path.GetFileName(configPath), string.Equals(Path.GetFullPath(configPath), managedFilePath != null ? Path.GetFullPath(managedFilePath) : null, StringComparison.Ordinal), lineNumber);
|
||||
|
||||
/// <summary>Like <see cref="GetLastValueFromConfigFiles"/> but returns which file set the value (for readonly/editable UI).</summary>
|
||||
public static (string? Value, ConfigValueSource? Source) GetLastValueFromConfigFilesWithSource(
|
||||
@@ -464,8 +464,10 @@ public static class DnsmasqConfIncludeParser
|
||||
if (!File.Exists(configPath))
|
||||
continue;
|
||||
var dir = Path.GetDirectoryName(configPath) ?? "";
|
||||
foreach (var line in File.ReadAllLines(configPath))
|
||||
var lines = File.ReadAllLines(configPath);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
@@ -474,7 +476,7 @@ public static class DnsmasqConfIncludeParser
|
||||
continue;
|
||||
var trimmed = value.Trim();
|
||||
lastValue = trimmed;
|
||||
lastSource = MakeSource(configPath, managedFilePath);
|
||||
lastSource = MakeSource(configPath, managedFilePath, i + 1);
|
||||
}
|
||||
}
|
||||
return (lastValue, lastSource);
|
||||
@@ -491,8 +493,10 @@ public static class DnsmasqConfIncludeParser
|
||||
ConfigValueSource? lastSource = null;
|
||||
foreach (var configPath in configFilePathsInOrder)
|
||||
{
|
||||
foreach (var line in GetLines(configPath, pathToLines))
|
||||
var lines = GetLines(configPath, pathToLines);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
@@ -500,7 +504,7 @@ public static class DnsmasqConfIncludeParser
|
||||
if (!string.Equals(k, key, KeyComparison))
|
||||
continue;
|
||||
lastValue = value.Trim();
|
||||
lastSource = MakeSource(configPath, managedFilePath);
|
||||
lastSource = MakeSource(configPath, managedFilePath, i + 1);
|
||||
}
|
||||
}
|
||||
return (lastValue, lastSource);
|
||||
@@ -517,8 +521,10 @@ public static class DnsmasqConfIncludeParser
|
||||
{
|
||||
if (!File.Exists(configPath))
|
||||
continue;
|
||||
foreach (var line in File.ReadAllLines(configPath))
|
||||
var lines = File.ReadAllLines(configPath);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
@@ -527,7 +533,7 @@ public static class DnsmasqConfIncludeParser
|
||||
continue;
|
||||
if (!string.IsNullOrEmpty(v?.Trim()))
|
||||
continue;
|
||||
return (true, MakeSource(configPath, managedFilePath));
|
||||
return (true, MakeSource(configPath, managedFilePath, i + 1));
|
||||
}
|
||||
}
|
||||
return (false, null);
|
||||
@@ -542,8 +548,10 @@ public static class DnsmasqConfIncludeParser
|
||||
return (false, null);
|
||||
foreach (var configPath in configFilePathsInOrder)
|
||||
{
|
||||
foreach (var line in GetLines(configPath, pathToLines))
|
||||
var lines = GetLines(configPath, pathToLines);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
@@ -552,7 +560,7 @@ public static class DnsmasqConfIncludeParser
|
||||
continue;
|
||||
if (!string.IsNullOrEmpty(v?.Trim()))
|
||||
continue;
|
||||
return (true, MakeSource(configPath, managedFilePath));
|
||||
return (true, MakeSource(configPath, managedFilePath, i + 1));
|
||||
}
|
||||
}
|
||||
return (false, null);
|
||||
@@ -569,8 +577,10 @@ public static class DnsmasqConfIncludeParser
|
||||
if (!File.Exists(configPath))
|
||||
continue;
|
||||
var dir = Path.GetDirectoryName(configPath) ?? "";
|
||||
foreach (var line in File.ReadAllLines(configPath))
|
||||
var lines = File.ReadAllLines(configPath);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
@@ -581,7 +591,7 @@ public static class DnsmasqConfIncludeParser
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
result = ResolvePath(path, dir) ?? result;
|
||||
lastSource = MakeSource(configPath, managedFilePath);
|
||||
lastSource = MakeSource(configPath, managedFilePath, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -597,8 +607,10 @@ public static class DnsmasqConfIncludeParser
|
||||
foreach (var configPath in configFilePathsInOrder)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(configPath) ?? "";
|
||||
foreach (var line in GetLines(configPath, pathToLines))
|
||||
var lines = GetLines(configPath, pathToLines);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
@@ -609,7 +621,7 @@ public static class DnsmasqConfIncludeParser
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
result = ResolvePath(path, dir) ?? result;
|
||||
lastSource = MakeSource(configPath, managedFilePath);
|
||||
lastSource = MakeSource(configPath, managedFilePath, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -626,9 +638,10 @@ public static class DnsmasqConfIncludeParser
|
||||
if (!File.Exists(configPath))
|
||||
continue;
|
||||
var dir = Path.GetDirectoryName(configPath) ?? "";
|
||||
var source = MakeSource(configPath, managedFilePath);
|
||||
foreach (var line in File.ReadAllLines(configPath))
|
||||
var lines = File.ReadAllLines(configPath);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
@@ -638,7 +651,7 @@ public static class DnsmasqConfIncludeParser
|
||||
var path = value.Trim();
|
||||
if (string.IsNullOrEmpty(path))
|
||||
continue;
|
||||
result.Add((Path.GetFullPath(Path.Combine(dir, path)), source));
|
||||
result.Add((Path.GetFullPath(Path.Combine(dir, path)), MakeSource(configPath, managedFilePath, i + 1)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -652,9 +665,10 @@ public static class DnsmasqConfIncludeParser
|
||||
foreach (var configPath in configFilePathsInOrder)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(configPath) ?? "";
|
||||
var source = MakeSource(configPath, managedFilePath);
|
||||
foreach (var line in GetLines(configPath, pathToLines))
|
||||
var lines = GetLines(configPath, pathToLines);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
@@ -664,7 +678,7 @@ public static class DnsmasqConfIncludeParser
|
||||
var path = value.Trim();
|
||||
if (string.IsNullOrEmpty(path))
|
||||
continue;
|
||||
result.Add((Path.GetFullPath(Path.Combine(dir, path)), source));
|
||||
result.Add((Path.GetFullPath(Path.Combine(dir, path)), MakeSource(configPath, managedFilePath, i + 1)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -782,16 +796,17 @@ public static class DnsmasqConfIncludeParser
|
||||
{
|
||||
if (!File.Exists(configPath))
|
||||
continue;
|
||||
var source = MakeSource(configPath, managedFilePath);
|
||||
foreach (var line in File.ReadAllLines(configPath))
|
||||
var lines = File.ReadAllLines(configPath);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
var (k, value) = kv.Value;
|
||||
if (!string.Equals(k, key, KeyComparison))
|
||||
continue;
|
||||
result.Add((value.Trim(), source));
|
||||
result.Add((value.Trim(), MakeSource(configPath, managedFilePath, i + 1)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -807,16 +822,17 @@ public static class DnsmasqConfIncludeParser
|
||||
return result;
|
||||
foreach (var configPath in configFilePathsInOrder)
|
||||
{
|
||||
var source = MakeSource(configPath, managedFilePath);
|
||||
foreach (var line in GetLines(configPath, pathToLines))
|
||||
var lines = GetLines(configPath, pathToLines);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
var (k, value) = kv.Value;
|
||||
if (!string.Equals(k, key, KeyComparison))
|
||||
continue;
|
||||
result.Add((value.Trim(), source));
|
||||
result.Add((value.Trim(), MakeSource(configPath, managedFilePath, i + 1)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -834,16 +850,17 @@ public static class DnsmasqConfIncludeParser
|
||||
{
|
||||
if (!File.Exists(configPath))
|
||||
continue;
|
||||
var source = MakeSource(configPath, managedFilePath);
|
||||
foreach (var line in File.ReadAllLines(configPath))
|
||||
var lines = File.ReadAllLines(configPath);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
var (k, value) = kv.Value;
|
||||
if (!keys.Contains(k))
|
||||
continue;
|
||||
result.Add((value.Trim(), source));
|
||||
result.Add((value.Trim(), MakeSource(configPath, managedFilePath, i + 1)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -859,16 +876,17 @@ public static class DnsmasqConfIncludeParser
|
||||
var result = new List<(string Value, ConfigValueSource Source)>();
|
||||
foreach (var configPath in configFilePathsInOrder)
|
||||
{
|
||||
var source = MakeSource(configPath, managedFilePath);
|
||||
foreach (var line in GetLines(configPath, pathToLines))
|
||||
var lines = GetLines(configPath, pathToLines);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
if (kv == null)
|
||||
continue;
|
||||
var (k, value) = kv.Value;
|
||||
if (!keys.Contains(k))
|
||||
continue;
|
||||
result.Add((value.Trim(), source));
|
||||
result.Add((value.Trim(), MakeSource(configPath, managedFilePath, i + 1)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user