diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..14ebc6f --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..5bace1c --- /dev/null +++ b/scripts/install.sh @@ -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 diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh index 5885742..ac59025 100755 --- a/scripts/prepare-test-mount.sh +++ b/scripts/prepare-test-mount.sh @@ -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 diff --git a/scripts/publish-self-contained.sh b/scripts/publish-self-contained.sh index 79afc8f..affd879 100755 --- a/scripts/publish-self-contained.sh +++ b/scripts/publish-self-contained.sh @@ -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 diff --git a/src/DnsmasqWebUI.Tests/ConfigValueSourceTests.cs b/src/DnsmasqWebUI.Tests/ConfigValueSourceTests.cs new file mode 100644 index 0000000..0c92e60 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/ConfigValueSourceTests.cs @@ -0,0 +1,54 @@ +using DnsmasqWebUI.Models.EffectiveConfig; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for and . +/// +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); + } +} diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs index 908e2ac..91083de 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs @@ -1,228 +1,52 @@ using DnsmasqWebUI.Models.Config; -using DnsmasqWebUI.Models.Dhcp; -using DnsmasqWebUI.Models.EffectiveConfig; using DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Tests; /// -/// 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. /// 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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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); - } - - /// dnsmasq read_file: strip from first '#' that is at word start (after whitespace) to EOL. [Fact] public void StripComment_CommentAfterSpace_StripsToEnd() { @@ -230,13 +54,12 @@ public class DnsmasqConfDirectiveParserTests Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # comment")); } - /// TryParseKeyValue uses StripComment; value should not include # or rest of line. [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); } } diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs index 1387a51..04df989 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs @@ -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]); diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs deleted file mode 100644 index 1fd0a4f..0000000 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -using DnsmasqWebUI.Models.Config; - -namespace DnsmasqWebUI.Tests; - -/// -/// Tests for DnsmasqConfOptionRegistry. Maps dnsmasq .conf option names to DnsmasqOptionKind. -/// -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); - } -} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor new file mode 100644 index 0000000..0e051c1 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor @@ -0,0 +1,18 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.EffectiveConfig + +@if (Descriptor.IsMultiValue) +{ + var items = Descriptor.GetItems(); + +} +else +{ + var value = Descriptor.GetValue(); + var displayValue = value == null ? "(not set)" : (value is IReadOnlyList list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString()); + +} + +@code { + [Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!; +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor new file mode 100644 index 0000000..a30f6ba --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor @@ -0,0 +1,15 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.EffectiveConfig + + + @Value + @if (Source?.IsReadOnly == true) + { + readonly + } + + +@code { + [Parameter] public string Value { get; set; } = ""; + [Parameter] public ConfigValueSource? Source { get; set; } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor index fcd71af..0fa6d66 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor @@ -1,10 +1,24 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.EffectiveConfig
  • - @Label: @Content + @Label: + @if (Items?.Count > 0) + { + @for (var i = 0; i < Items.Count; i++) + { + if (i > 0) { , } + var item = Items[i]; + + } + } + else + { + (none) + }
  • @code { [Parameter] public string Label { get; set; } = ""; - [Parameter] public MarkupString Content { get; set; } + [Parameter] public IReadOnlyList? Items { get; set; } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor index 4cf33a5..5b7177b 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor @@ -5,136 +5,24 @@ @if (Status != null) {
    - @* Hosts *@ -
    - -
    -
    -
      - -
    • - addn-hosts: - @if (Status.AddnHostsPaths?.Count > 0) - { - var srcList = Status.EffectiveConfigSources?.AddnHostsPaths; - if (srcList != null && srcList.Count == Status.AddnHostsPaths.Count) - { - @foreach (var entry in srcList) - { - @entry.Path @(entry.Source?.IsReadOnly == true ? " (readonly)" : "") - } - } - else - { - var deduped = (Status.AddnHostsPaths ?? Array.Empty()).Distinct().ToList(); - @(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped)) - } - } - else { (none) } -
    • - @if (Status.ManagedHostsFilePath != null) {
    • Managed hosts file (editable): @Status.ManagedHostsFilePath @(Status.ManagedHostsPathExists ? "✓" : "(not created yet)")
    • } - @if (Status.SystemHostsPath != null) {
    • System hosts (read-only): @Status.SystemHostsPath @(Status.SystemHostsPathExists ? "✓" : "(missing)")
    • } -
    -
    -
    -
    - - @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 *@ -
    - -
    -
    -
      - - - - - - "0 (no DNS listener)", _ => ec.Port.Value.ToString() })" Source="@(src?.Port)" /> - @if (ec.ServerLocalValues?.Count > 0) { } - @if (ec.AddressValues?.Count > 0) { } - @if (ec.ResolvFiles?.Count > 0) { } -
    -
    -
    -
    - - @* DHCP *@ -
    - -
    -
    -
      - - - - @if (ec.DhcpLeaseMax.HasValue) { } - @if (ec.DhcpTtl.HasValue) { } - @if (ec.DhcpRanges?.Count > 0) { } - @if (ec.DhcpHostLines?.Count > 0) { } - @if (ec.DhcpOptionLines?.Count > 0) { } -
    -
    -
    -
    - - @* Cache *@ -
    - -
    -
    -
      - @if (ec.CacheSize.HasValue) { } - @if (ec.LocalTtl.HasValue) { } - - @if (ec.NegTtl.HasValue) { } - @if (ec.MaxTtl.HasValue) { } - @if (ec.MaxCacheTtl.HasValue) { } - @if (ec.MinCacheTtl.HasValue) { } -
    -
    -
    -
    - - @* Process & networking *@ -
    - -
    -
    -
      - - - @if (ec.Interfaces?.Count > 0) { } - @if (ec.ListenAddresses?.Count > 0) { } - @if (ec.ExceptInterfaces?.Count > 0) { } - @if (ec.PidFilePath != null) { } - @if (ec.User != null) { } - @if (ec.Group != null) { } - @if (ec.LogFacility != null) { } -
    -
    -
    -
    + var sectionDescriptors = descriptors.Where(d => d.SectionId == sectionId).ToList(); + if (sectionDescriptors.Count > 0) + { + + } }
    } @@ -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? values, IReadOnlyList? 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} readonly"; - } - return valueEnc; - }); - return (MarkupString)string.Join(", ", parts); - } - return (MarkupString)System.Net.WebUtility.HtmlEncode(string.Join(", ", values)); - } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css index f8ea30c..2855024 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css @@ -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; } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor new file mode 100644 index 0000000..20cde5f --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor @@ -0,0 +1,29 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.EffectiveConfig + +
    + +
    +
    +
      + @foreach (var f in Descriptors) + { + + } +
    +
    +
    +
    + +@code { + [Parameter] public string SectionId { get; set; } = ""; + [Parameter] public string Title { get; set; } = ""; + [Parameter] public IReadOnlyList Descriptors { get; set; } = null!; + [Parameter] public bool IsOpen { get; set; } + [Parameter] public EventCallback OnToggle { get; set; } + + private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId); +} diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs deleted file mode 100644 index 0045c78..0000000 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqConfOptionRegistry.cs +++ /dev/null @@ -1,73 +0,0 @@ -namespace DnsmasqWebUI.Models.Config; - -/// -/// Registry of dnsmasq .conf option names to their . -/// Used by to dispatch each line to the correct backing model. -/// Add entries as we add parsers for more options. -/// -public static class DnsmasqConfOptionRegistry -{ - /// Option name (config key) to kind. Keys are lowercase; config file uses same as long option without "--". - public static IReadOnlyDictionary OptionKindByKey { get; } = - new Dictionary(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 }, - }; - - /// Well-known flag options (no value). - public static IReadOnlySet FlagOptions { get; } = - new HashSet(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", - }; - - /// Resolve option key (e.g. "addn-hosts") to kind; returns Raw if unknown. - 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; - } -} diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionKind.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionKind.cs deleted file mode 100644 index 103a60a..0000000 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionKind.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace DnsmasqWebUI.Models.Config; - -/// -/// 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. -/// -public enum DnsmasqOptionKind -{ - /// Unknown or not-yet-typed option; store as key=value. - Raw, - - // --- Include (main config only) --- - /// conf-file=path → - ConfFile, - /// conf-dir=path[,suffix] → - ConfDir, - - // --- Path-valued (single or cumulative) --- - /// addn-hosts=path (cumulative) → - AddnHosts, - /// dhcp-leasefile= or dhcp-lease-file= (last wins) → - DhcpLeaseFile, - /// resolv-file=, dhcp-hostsfile=, etc. → path model - Path, - - // --- Simple string --- - /// domain=value → - Domain, - /// user=, group=, port=, etc. → RawOption - String, - /// Flag (no value): domain-needed, bogus-priv, no-hosts, expand-hosts, etc. - Flag, - - // --- DHCP (structured) --- - /// dhcp-range=... → (complex) - DhcpRange, - /// dhcp-host=... → DhcpHostEntry (in DnsmasqWebUI.Models) - DhcpHost, - /// dhcp-option=... (complex) - DhcpOption, - - // --- DNS (structured) --- - /// server=/domain/ip or server=ip → server model - Server, - /// local=/domain/ - Local, - /// address=/domain/ip - Address, -} diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionModels.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionModels.cs deleted file mode 100644 index 3a13a51..0000000 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionModels.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace DnsmasqWebUI.Models.Config; - -/// Backing model for conf-file=path. Parsed from main config only. -public record ConfFileOption(string Path, int LineNumber, string SourceFilePath); - -/// Backing model for conf-dir=path[,suffix]. Parsed from main config only. -public record ConfDirOption(string Path, string? Suffix, int LineNumber, string SourceFilePath); - -/// Backing model for addn-hosts=path. Cumulative across files. -public record AddnHostsOption(string Path, int LineNumber, string SourceFilePath); - -/// Backing model for dhcp-leasefile= or dhcp-lease-file=. Last wins. -public record DhcpLeaseFileOption(string Path, int LineNumber, string SourceFilePath); - -/// Backing model for domain=value. Last wins (or first per-subnet if domain=name,subnet). -public record DomainOption(string Value, int LineNumber, string SourceFilePath); - -/// Generic path-valued option (resolv-file, dhcp-hostsfile, etc.). -public record PathOption(string Key, string Path, int LineNumber, string SourceFilePath); - -/// Generic key=value option (port, user, cache-size, etc.) or unknown option. -public record RawOption(string Key, string Value, int LineNumber, string SourceFilePath); - -/// Backing model for dhcp-range=... (start,end,netmask,lease or tag:..., set:..., etc.). Parsed by DhcpRangeOptionParser when needed. -public record DhcpRangeOption(string RawValue, int LineNumber, string SourceFilePath); - -/// Backing model for dhcp-option=... (option:value or option:value,value). Raw value for now. -public record DhcpOptionOption(string RawValue, int LineNumber, string SourceFilePath); - -/// Backing model for server=/domain/ip or server=ip. Raw value for now. -public record ServerOption(string RawValue, int LineNumber, string SourceFilePath); - -/// Backing model for local=/domain/. Raw value for now. -public record LocalOption(string RawValue, int LineNumber, string SourceFilePath); - -/// Backing model for address=/domain/ip. Raw value for now. -public record AddressOption(string RawValue, int LineNumber, string SourceFilePath); diff --git a/src/DnsmasqWebUI/Models/Config/ParsedDirective.cs b/src/DnsmasqWebUI/Models/Config/ParsedDirective.cs deleted file mode 100644 index b4cbcaa..0000000 --- a/src/DnsmasqWebUI/Models/Config/ParsedDirective.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace DnsmasqWebUI.Models.Config; - -/// -/// 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. -/// -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 | ... -} diff --git a/src/DnsmasqWebUI/Models/EffectiveConfig/ConfigValueSource.cs b/src/DnsmasqWebUI/Models/EffectiveConfig/ConfigValueSource.cs index b8ddaba..94357cc 100644 --- a/src/DnsmasqWebUI/Models/EffectiveConfig/ConfigValueSource.cs +++ b/src/DnsmasqWebUI/Models/EffectiveConfig/ConfigValueSource.cs @@ -1,26 +1,31 @@ namespace DnsmasqWebUI.Models.EffectiveConfig; /// -/// 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. /// /// Absolute path of the config file that set this value. Use for "edit this file" or tooltips. /// Filename only (e.g. dnsmasq.conf, 01-default.conf) for compact display. /// True when this file is the app's managed config file (editable from UI). +/// 1-based line number in the file where the value was set; null if unknown. /// /// When is true: the user can change or remove the value from the UI (we read/write the managed file). /// When false ( 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). /// -public record ConfigValueSource(string FilePath, string FileName, bool IsManaged) +public record ConfigValueSource(string FilePath, string FileName, bool IsManaged, int? LineNumber = null) { /// 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. public bool IsReadOnly => !IsManaged; - /// Returns a short tooltip for readonly values (which file and how to change). Null when (editable). + /// Returns a short tooltip for readonly values (which file, optional line, and how to change). Null when (editable). 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; } /// Value plus its config file source. Used for multi-value options (server/local, address, etc.) so JSON serializes as "value" and "source". diff --git a/src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveConfigFieldBuilder.cs b/src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveConfigFieldBuilder.cs new file mode 100644 index 0000000..97e9892 --- /dev/null +++ b/src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveConfigFieldBuilder.cs @@ -0,0 +1,113 @@ +using DnsmasqWebUI.Models.Config; +using DnsmasqWebUI.Models.Status; + +namespace DnsmasqWebUI.Models.EffectiveConfig; + +/// +/// Builds field descriptors for viewing and editing. Mapping is done once here via delegates passed to each descriptor. +/// +public static class EffectiveConfigFieldBuilder +{ + private static EffectiveDnsmasqConfig? Config(DnsmasqServiceStatus? s) => s?.EffectiveConfig; + private static EffectiveConfigSources? Sources(DnsmasqServiceStatus? s) => s?.EffectiveConfigSources; + + /// Builds getItems from config/source list selectors; used by Multi() for fields that have both. + private static Func?> Items( + Func?> getValues, + Func?> 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 BuildFieldDescriptors(DnsmasqServiceStatus? status) + { + if (status == null || status.EffectiveConfig == null) + return Array.Empty(); + + var list = new List(); + + // --- 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? getValue, + Func? getSource, + Func?>? getItems) + { + return new EffectiveConfigFieldDescriptor(sectionId, optionName, false, status, getValue, getSource, getItems); + } + + private static IReadOnlyList? ToValueWithSourceList(IReadOnlyList? paths, IReadOnlyList? 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?>? getItems) + { + return new EffectiveConfigFieldDescriptor(sectionId, optionName, true, status, null, null, getItems); + } +} diff --git a/src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveConfigFieldDescriptor.cs b/src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveConfigFieldDescriptor.cs new file mode 100644 index 0000000..3824096 --- /dev/null +++ b/src/DnsmasqWebUI/Models/EffectiveConfig/EffectiveConfigFieldDescriptor.cs @@ -0,0 +1,21 @@ +using DnsmasqWebUI.Models.Status; + +namespace DnsmasqWebUI.Models.EffectiveConfig; + +/// +/// Glue for rendering one effective-config field: metadata (section, option name) plus delegates that resolve value, source, and items from Status at render time. +/// +public record EffectiveConfigFieldDescriptor( + string SectionId, + string OptionName, + bool IsMultiValue, + DnsmasqServiceStatus? Status, + Func? ResolveValue, + Func? ResolveSource, + Func?>? ResolveItems +) +{ + public object? GetValue() => ResolveValue?.Invoke(Status); + public ConfigValueSource? GetSource() => ResolveSource?.Invoke(Status); + public IReadOnlyList? GetItems() => ResolveItems?.Invoke(Status); +} diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs index 1753091..7368016 100644 --- a/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs +++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfDirectiveParser.cs @@ -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; /// -/// Parses a single line from a dnsmasq .conf file into a using -/// 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 to build effective config. +/// StripComment and TryParseKeyValue handle comments and key=value (or key-only) lines. /// public static class DnsmasqConfDirectiveParser { @@ -64,60 +61,4 @@ public static class DnsmasqConfDirectiveParser errorPosition = result.ErrorPosition; return false; } - - /// Parse one non-blank, non-comment .conf line into a typed directive. Returns null if line is empty or comment. - 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 - }; - } } diff --git a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs index c432bee..1bace32 100644 --- a/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs +++ b/src/DnsmasqWebUI/Parsers/DnsmasqConfIncludeParser.cs @@ -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); /// Like but returns which file set the value (for readonly/editable UI). 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;