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:
2026-02-04 20:23:59 +10:00
parent 368489e5f2
commit 633c1ef827
23 changed files with 783 additions and 776 deletions
+69
View File
@@ -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 }}
+230
View File
@@ -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
+47 -27
View File
@@ -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
+14 -2
View File
@@ -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!;
}
@@ -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; }
}
@@ -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 &amp; 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));
}
}
@@ -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;