mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
350df71b4f | ||
|
|
43ba5116ac | ||
|
|
b24ca17779 | ||
|
|
e86a2b6bd9 | ||
|
|
002e9a5db5 | ||
|
|
02a840799b | ||
|
|
2e713f0e3c | ||
|
|
9627926a6a | ||
|
|
eae29de045 | ||
|
|
f0b4ea97a4 | ||
|
|
1bbab1c8c3 | ||
|
|
5702844cea | ||
|
|
84c58ccec7 | ||
|
|
a3dc5ac45c | ||
|
|
31c0f65c86 | ||
|
|
c7e7844ce7 | ||
|
|
333566554c | ||
|
|
1b3a09996f | ||
|
|
3ca935e551 | ||
|
|
e33402467f | ||
|
|
9809ce9f7b | ||
|
|
181cbbed48 | ||
|
|
36a160dfc7 | ||
|
|
1bf33f1a9b | ||
|
|
b4d1f39762 | ||
|
|
214d6c686e | ||
|
|
4c1c19b792 | ||
|
|
98d1bc560e | ||
|
|
fd16d50107 | ||
|
|
de8afed68c | ||
|
|
9e42a4e2df | ||
|
|
d910688742 | ||
|
|
a03e93bcd3 | ||
|
|
6cf4c07695 | ||
|
|
f2e73107d1 | ||
|
|
ff94be87b2 | ||
|
|
a9f8961c32 | ||
|
|
baaba38e23 | ||
|
|
5f728900dc | ||
|
|
87179f0195 | ||
|
|
3b22e4fb7d | ||
|
|
a3cc3968af | ||
|
|
8ed66cdf48 | ||
|
|
a776f445b6 | ||
|
|
b0ce7afb58 | ||
|
|
2cfbaf000c | ||
|
|
f8754d8d6e | ||
|
|
71a8a61cb3 | ||
|
|
bf1f6a0d82 | ||
|
|
91beb9d181 | ||
|
|
4e122ba439 | ||
|
|
6a7c0f62e1 | ||
|
|
f22b039e74 | ||
|
|
906e222ec1 | ||
|
|
ecd9e5cc7b | ||
|
|
e7c2560d07 | ||
|
|
439192058e | ||
|
|
7965fc669b | ||
|
|
dfee96650b | ||
|
|
cefc488712 | ||
|
|
5ac2006b80 | ||
|
|
1bdd176c18 | ||
|
|
b70aa02017 | ||
|
|
d06d6ef6c9 | ||
|
|
1dffdcf2ed | ||
|
|
3ae556168e | ||
|
|
b44dde2c77 | ||
|
|
23bf6bb9e0 | ||
|
|
dfb0b94e4a | ||
|
|
4317efff68 |
@@ -29,22 +29,22 @@ jobs:
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${{ github.workspace }}:/src" -w /src \
|
||||
mcr.microsoft.com/dotnet/sdk:9.0 \
|
||||
./scripts/publish-self-contained.sh ${{ matrix.rid }}
|
||||
mcr.microsoft.com/dotnet/sdk:10.0 \
|
||||
./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Publish for ${{ matrix.rid }} (musl on host)
|
||||
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
|
||||
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }}
|
||||
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
|
||||
|
||||
- name: Zip publish output
|
||||
run: |
|
||||
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net9.0/${{ matrix.rid }}/publish"
|
||||
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net10.0/${{ matrix.rid }}/publish"
|
||||
(cd "$PUBLISH_DIR" && zip -r "../../../../../../../dnsmasq-webui-${{ matrix.rid }}.zip" .)
|
||||
|
||||
- name: Upload artifact
|
||||
|
||||
+2
-1
@@ -43,6 +43,7 @@ testdata-mount/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# User-specific
|
||||
# User-specific / runtime
|
||||
appsettings.*.local.json
|
||||
appsettings.Overrides.json
|
||||
*.local
|
||||
|
||||
+40
-10
@@ -2,13 +2,21 @@
|
||||
# Both run as root; file permissions work because config dirs are in the container.
|
||||
# For dnsmasq on the host, use the self-contained publish (scripts/publish-self-contained.sh)
|
||||
# or run the app on the host; see DnsmasqOptions XML doc for permissions and ReloadCommand scope.
|
||||
#
|
||||
# Dnsmasq install mode for the test harness:
|
||||
# - DNSMASQ_VERSION=latest (default): build the latest stable upstream dnsmasq release.
|
||||
# - DNSMASQ_VERSION=<version> : build that exact upstream release (e.g. 2.91).
|
||||
# - DNSMASQ_VERSION=distro : use the distro package from apt.
|
||||
#
|
||||
# This keeps day-to-day harness use close to upstream while still allowing pinned-version testing.
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
ARG DNSMASQ_VERSION=
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"]
|
||||
RUN dotnet restore "src/DnsmasqWebUI/DnsmasqWebUI.csproj"
|
||||
@@ -19,15 +27,37 @@ RUN dotnet build "DnsmasqWebUI.csproj" -c Release -o /app/build
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS app
|
||||
FROM base AS final
|
||||
ARG DNSMASQ_VERSION=latest
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
FROM app AS final
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY scripts/entrypoint.sh .
|
||||
COPY scripts/dnsmasq-status.sh .
|
||||
# Install dnsmasq either from the distro package or by building an upstream release.
|
||||
RUN if [ "$DNSMASQ_VERSION" = "distro" ]; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
else \
|
||||
apt-get update && apt-get install -y --no-install-recommends procps curl build-essential xz-utils ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& if [ "$DNSMASQ_VERSION" = "latest" ]; then \
|
||||
DNSMASQ_VERSION="$(curl -fsSL https://thekelleys.org.uk/dnsmasq/ | grep -o 'dnsmasq-[0-9][0-9.]*\.tar\.xz' | sed 's/^dnsmasq-//; s/\.tar\.xz$//' | sort -V | tail -n1)"; \
|
||||
fi \
|
||||
&& test -n "$DNSMASQ_VERSION" \
|
||||
&& echo "Building dnsmasq $DNSMASQ_VERSION from upstream source" \
|
||||
&& curl -fsSL "https://thekelleys.org.uk/dnsmasq/dnsmasq-${DNSMASQ_VERSION}.tar.xz" -o /tmp/dnsmasq.tar.xz \
|
||||
&& tar -C /tmp -xJf /tmp/dnsmasq.tar.xz \
|
||||
&& make -C /tmp/dnsmasq-${DNSMASQ_VERSION} -j"$(nproc)" \
|
||||
&& make -C /tmp/dnsmasq-${DNSMASQ_VERSION} install PREFIX=/usr \
|
||||
&& rm -rf /tmp/dnsmasq-${DNSMASQ_VERSION} /tmp/dnsmasq.tar.xz \
|
||||
&& apt-get purge -y build-essential curl xz-utils \
|
||||
&& apt-get autoremove -y --purge \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
COPY scripts/entrypoint.sh scripts/dnsmasq-status.sh ./
|
||||
RUN chmod +x entrypoint.sh dnsmasq-status.sh
|
||||
# curl for HEALTHCHECK (aspnet image has no curl/wget)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
# .NET publish last - only this layer invalidates when code changes
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f -s http://localhost:8080/healthz/ready || exit 1
|
||||
|
||||
@@ -19,6 +19,8 @@ A self-hosted web UI for managing [dnsmasq](https://thekelleys.org.uk/dnsmasq/do
|
||||
- **DHCP host entries** (reservations) if you use DHCP; edit in the UI, written into the managed config.
|
||||
- **Reload dnsmasq** after config changes (configurable command, e.g. `systemctl reload dnsmasq` or `pkill -HUP -x dnsmasq`).
|
||||
- Optional **status** and **recent logs** commands (e.g. systemctl/journalctl or custom scripts) shown on the Dnsmasq page.
|
||||
- **Minimum dnsmasq version** check (configurable; e.g. 2.91). The UI can refuse to start or block config save if the detected version is too old. Version is shown on the Dnsmasq page with a link to release notes.
|
||||
- **Readiness endpoint** `GET /healthz/ready` for orchestration (Kubernetes, Docker, load balancers). Returns healthy when dnsmasq version meets the minimum and, if configured, dnsmasq is running. The Docker image includes a HEALTHCHECK that uses this endpoint.
|
||||
- **Self-contained Linux binaries** per OS/arch (RID); no .NET install required. Can run in Docker or directly on the host.
|
||||
|
||||
<!-- SUGGESTED SCREENSHOT: MAIN CONFIG OR CONFIG EDITOR PAGE IN THE BROWSER -->
|
||||
@@ -39,7 +41,7 @@ The recommended way to get a prebuilt binary is the **install script**. It picks
|
||||
curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/master/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
**From a git clone** (repo is detected from `git remote origin`):
|
||||
**From a git clone:**
|
||||
|
||||
```bash
|
||||
./scripts/install.sh
|
||||
@@ -102,8 +104,6 @@ sudo ./scripts/install.sh --uninstall --purge --system # Also remove /opt/dnsm
|
||||
./scripts/install.sh --version v1.0.0
|
||||
```
|
||||
|
||||
**Installing from a fork:** Pass the repo explicitly: `./scripts/install.sh --repo owner/dnsmasq-webui` or set `GITHUB_REPO=owner/dnsmasq-webui`. The default repo is `alexhopeoconnor/dnsmasq-webui`.
|
||||
|
||||
**If the binary fails to run** (e.g. `TypeLoadException` or glibc errors): use the install script with `--build-from-source` so the app is built for your machine. This requires the .NET SDK and a **git clone** (it does not work when installing via `curl ... | sh`). Clone the repo, then run `./scripts/install.sh --build-from-source`. The script checks that .NET is installed, detects your OS/arch, and builds (on Ubuntu it may use a distro-specific RID for a better match). Other distros use the portable RID and may still hit runtime issues; building from source on the target machine is the most reliable.
|
||||
|
||||
**Configuration:** Set at least `Dnsmasq__MainConfigPath` to your main dnsmasq config. See [Configuration](#configuration) for all options.
|
||||
@@ -165,7 +165,7 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
```
|
||||
|
||||
Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via environment variables or an `appsettings.json` in `/app`. Mount your config dir and expose 8080.
|
||||
Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via environment variables or an `appsettings.json` in `/app`. Mount your config dir and expose 8080. The image includes a **HEALTHCHECK** that calls `GET /healthz/ready`; orchestration (e.g. Kubernetes readiness probe, or Compose `depends_on: condition: service_healthy`) can use the same endpoint.
|
||||
|
||||
**Config summary for containers:**
|
||||
|
||||
@@ -175,8 +175,11 @@ Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via e
|
||||
| `Dnsmasq__MainConfigPath` | `/data/dnsmasq.conf` | Main dnsmasq config path (required) |
|
||||
| `Dnsmasq__ReloadCommand` | `pkill -HUP -x dnsmasq` | Run after config changes |
|
||||
| `Dnsmasq__StatusShowCommand` | `/app/dnsmasq-status.sh` | Optional status output (e.g. our script in test harness) |
|
||||
| `Dnsmasq__MinimumVersion` | `2.91` | Minimum dnsmasq version (optional; default 2.91). Set `Dnsmasq__EnforceMinimumVersion=false` to allow older versions. |
|
||||
| `ASPNETCORE_URLS` | `http://+:8080` | Port the app listens on |
|
||||
|
||||
**Readiness:** `GET http://localhost:8080/healthz/ready` returns JSON `{"status":"ok"}` when dnsmasq version meets the minimum and (if `StatusCommand` is set) dnsmasq is running. Use this for Kubernetes readiness probes, Docker HEALTHCHECK, or load balancer health checks.
|
||||
|
||||
See [Configuration](#configuration) for all options.
|
||||
|
||||
<!-- SUGGESTED SCREENSHOT: EXAMPLE DOCKERFILE SNIPPET OR DOCKER RUN COMMAND SHOWING DNSMASQ + APP IN ONE CONTAINER -->
|
||||
@@ -207,6 +210,9 @@ The app is configured via **appsettings.json**, **environment variables**, and *
|
||||
| `StatusCommand` | Optional: check if dnsmasq is running | `pgrep -x dnsmasq` |
|
||||
| `StatusShowCommand` | Optional: full status output (e.g. systemctl status) | `systemctl status dnsmasq --no-pager` |
|
||||
| `LogsCommand` | Optional: recent logs (e.g. journalctl) | `journalctl -u dnsmasq -n 100 --no-pager` |
|
||||
| `VersionCommand` | Command to probe dnsmasq version (used for minimum-version check and UI display) | `dnsmasq --version` |
|
||||
| `MinimumVersion` | Minimum dnsmasq version required (e.g. 2.91). Some options need newer dnsmasq. | `2.91` |
|
||||
| `EnforceMinimumVersion` | If true, app fails to start when version probe fails or version is below minimum. If false, only save and readiness checks enforce. | `true` |
|
||||
|
||||
**Application options** (use `Application__` prefix for env, `Application` section in JSON):
|
||||
|
||||
@@ -238,7 +244,6 @@ All scripts live under `scripts/` and are intended for Linux (WSL may work but i
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--repo OWNER/REPO` | GitHub owner/repo (or set `GITHUB_REPO`). When run from a clone, repo is detected from `git remote origin`. Default repo: `alexhopeoconnor/dnsmasq-webui`. |
|
||||
| `--list` | List available releases (tag, name, published_at) and exit. |
|
||||
| `--version TAG` | Install from release TAG (e.g. v1.0.0). Default: latest. |
|
||||
| `--update` | Reinstall latest into the default user directory (~/.local/share/dnsmasq-webui). |
|
||||
@@ -291,18 +296,14 @@ Invalid combinations (script errors with a clear message): `--purge` without `--
|
||||
Script lists release tags and dates; list-only mode cannot be combined with install/uninstall so you do not accidentally change the system.
|
||||
**Command:** `./scripts/install.sh --list`
|
||||
|
||||
10. **Install from a fork or a specific GitHub repo** instead of the default.
|
||||
You pass the repo; script uses it for the API. Without a clone or `--repo`/GITHUB_REPO it errors so you fix the command instead of getting a confusing API failure.
|
||||
**Command:** `./scripts/install.sh --repo owner/dnsmasq-webui` or `GITHUB_REPO=owner/dnsmasq-webui ./scripts/install.sh`
|
||||
|
||||
11. **Install a specific release tag** (e.g. an older or pinned version) instead of latest.
|
||||
10. **Install a specific release tag** (e.g. an older or pinned version) instead of latest.
|
||||
Script fetches that tag's release and the asset for your RID. If no asset matches your OS/arch it errors and prints the asset names so you can pick another tag or report a missing build.
|
||||
**Command:** `./scripts/install.sh --version v1.0.0`
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Install latest (from clone: repo auto-detected; from curl: uses alexhopeoconnor/dnsmasq-webui by default, or use --repo)
|
||||
# Install latest
|
||||
./scripts/install.sh
|
||||
|
||||
# Update to latest release
|
||||
@@ -329,8 +330,8 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
# Switch to another release (overwrites that dir)
|
||||
./scripts/install.sh --dir ~/.local/share/dnsmasq-webui --version v0.9.0
|
||||
|
||||
# List releases (from fork)
|
||||
./scripts/install.sh --repo alexhopeoconnor/dnsmasq-webui --list
|
||||
# List releases
|
||||
./scripts/install.sh --list
|
||||
```
|
||||
|
||||
**How the script works and avoids mistakes**
|
||||
@@ -351,7 +352,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
`--uninstall` removes only systemd units and symlinks unless you add `--purge`. With `--purge`, you must say *which* install to remove: default user dir (no extra flags), `--dir DIR`, or `--system` for /opt. That avoids accidentally deleting the wrong directory.
|
||||
|
||||
6. **Repo and release**
|
||||
When not in a git clone, the script needs a repo (e.g. `REPO_DEFAULT` in the one-liner or `--repo` / `GITHUB_REPO`). It errors with a clear message instead of hitting the GitHub API with an empty repo. If no asset matches your OS/arch (RID), it prints the available asset names so you can pick a different release or report a missing build.
|
||||
If no asset matches your OS/arch (RID), the script prints the available asset names so you can pick a different release or report a missing build.
|
||||
|
||||
7. **Dependencies**
|
||||
The script requires `curl`, `jq`, and `unzip`. It checks for `jq` before calling the GitHub API and errors with an install hint. `--service` requires `systemctl` (systemd) and errors on non-systemd systems instead of writing a unit that won’t be used.
|
||||
@@ -392,13 +393,13 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
./scripts/publish-self-contained.sh --no-clean ubuntu.24.04-x64
|
||||
```
|
||||
|
||||
**Output:** `src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/`
|
||||
**Output:** `src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/`
|
||||
|
||||
---
|
||||
|
||||
### scripts/prepare-test-mount.sh
|
||||
|
||||
**Purpose:** Prepare the testdata mount and optionally start or stop the Docker test harness (app + dnsmasq + DHCP clients). Syncs a source dir (default: `testdata`) into a mount dir (default: `testdata-mount`), cleans up previous test data, then runs `docker compose -f docker-compose.test.yml up -d` (or only prepares the mount with `--prepare-only`).
|
||||
**Purpose:** Prepare the testdata mount and optionally start or stop the Docker test harness (app + dnsmasq + DHCP clients). Syncs a source dir (default: `testdata`) into a mount dir (default: `testdata-mount`), then runs `docker compose -f docker-compose.test.yml up -d` (or only prepares the mount with `--prepare-only`). Fixture sync skips `leases` and app-managed filenames (`zz-dnsmasq-webui.conf`, `zz-dnsmasq-webui.hosts`) so rebuilds keep DHCP state and UI-written files unless you pass `--clear` or `--reset-managed`. By default the script resolves and uses the **latest stable upstream dnsmasq version** when building images; you can pin a specific version (or use the distro package) for compatibility testing.
|
||||
|
||||
**Usage:** `./scripts/prepare-test-mount.sh [OPTIONS] [--]`
|
||||
|
||||
@@ -408,9 +409,12 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
|--------|-------------|
|
||||
| `--source DIR` | Source to copy from (default: `testdata`). |
|
||||
| `--mount DIR` | Target mount directory (default: `testdata-mount`). Compose uses `TESTDATA_MOUNT`; script exports it when you use `--mount`. |
|
||||
| `--no-clear` | Do not clear mount dir; only sync over existing contents (e.g. preserve leases between runs). |
|
||||
| `--dnsmasq-version V` | Dnsmasq version for the harness image: `latest` (default), `distro`, or an exact upstream version like `2.91`. |
|
||||
| `--clear` | Clear the mount dir completely before sync for a clean run. Default: preserve existing contents and sync over them. |
|
||||
| `--reset-managed` | After sync, remove `*dnsmasq-webui*.conf` and `*dnsmasq-webui*.hosts` under the mount so the app recreates them; leaves leases and other files. |
|
||||
| `--prepare-only` | Only prepare the mount; do not run docker compose. |
|
||||
| `--build` | Pass `--build` to docker compose (rebuild images). Default: use existing images. |
|
||||
| `--no-cache-build` | Run `docker compose build --pull --no-cache` before start. Use to force a fresh image build and refresh the resolved latest dnsmasq version. |
|
||||
| `--no-build` | Do not rebuild (default). |
|
||||
| `--recreate` | Pass `--force-recreate` to docker compose. |
|
||||
| `--stop` | Stop the harness: `docker compose down` (no prepare, no start). |
|
||||
@@ -420,18 +424,28 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Full run: clear mount, sync testdata, start containers (no rebuild)
|
||||
# Default: preserve mount (fixtures sync over; managed conf/hosts and leases kept), start (no image rebuild)
|
||||
./scripts/prepare-test-mount.sh
|
||||
|
||||
# Rebuild images then start
|
||||
# Rebuild images then start (mount state preserved unless you pass --clear or --reset-managed)
|
||||
./scripts/prepare-test-mount.sh --build
|
||||
|
||||
# Force a fresh image build with no Docker build cache
|
||||
./scripts/prepare-test-mount.sh --no-cache-build
|
||||
|
||||
# Rebuild with a specific dnsmasq version
|
||||
./scripts/prepare-test-mount.sh --dnsmasq-version 2.91 --build
|
||||
|
||||
# Use the distro package instead of an upstream build
|
||||
./scripts/prepare-test-mount.sh --dnsmasq-version distro --build
|
||||
|
||||
# Only prepare the mount; start manually later
|
||||
./scripts/prepare-test-mount.sh --prepare-only
|
||||
# Then: TESTDATA_MOUNT=./testdata-mount docker compose -f docker-compose.test.yml up -d
|
||||
# Then use the version printed by the script, e.g.:
|
||||
# TESTDATA_MOUNT=./testdata-mount DNSMASQ_VERSION=<resolved-version> docker compose -f docker-compose.test.yml up -d
|
||||
|
||||
# Preserve mount contents, sync over it, start
|
||||
./scripts/prepare-test-mount.sh --no-clear
|
||||
# Fresh managed config/hosts after a rebuild (fixtures still synced; leases kept)
|
||||
./scripts/prepare-test-mount.sh --reset-managed --build
|
||||
|
||||
# Stop the harness
|
||||
./scripts/prepare-test-mount.sh --stop
|
||||
@@ -456,6 +470,21 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
|
||||
**Usage:** Set `DNSMASQ_CONF` to the path of the dnsmasq config file inside the container (e.g. `/data/dnsmasq-test.conf`). No CLI arguments.
|
||||
|
||||
---
|
||||
|
||||
### scripts/extract-option-help.sh
|
||||
|
||||
**Purpose:** Fetch the dnsmasq man page HTML and extract per-option help fragments into `src/DnsmasqWebUI/wwwroot/option-help/*.html`. The Effective Config UI loads these when the user clicks an option label (tooltip is from code; full help is from these files). Requires Docker (uses a temporary `python:3-slim` container). When adding new options to the UI, add the option key to the script’s `OPTION_KEYS` list (keep in sync with `EffectiveConfigSections` / `DnsmasqOptionTooltips`), then run this script so help is available for the new option (if it exists in the man page).
|
||||
|
||||
**Usage:** `./scripts/extract-option-help.sh [--url URL] [--output-dir DIR]`
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--url URL` | Man page URL (default: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html). |
|
||||
| `--output-dir DIR` | Output directory (default: `src/DnsmasqWebUI/wwwroot/option-help`). |
|
||||
|
||||
Run from the repo root. Writes one `.html` file per option that appears in the man page OPTIONS section; options in `OPTION_KEYS` but not in the man page are skipped.
|
||||
|
||||
<!-- SUGGESTED SCREENSHOT: TERMINAL WITH PREPARE-TEST-MOUNT.SH USAGE OR DOCKER COMPOSE UP OUTPUT -->
|
||||
|
||||
---
|
||||
@@ -477,11 +506,11 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
|
||||
**Codebase overview:**
|
||||
|
||||
- **Entry and config:** `Program.cs` wires the host; configuration (e.g. `Dnsmasq__*`) is in `Configuration/` (`DnsmasqOptions`, `ApplicationOptions`). Options are validated at startup.
|
||||
- **Config and hosts:** Services in `Services/` and `Services/Abstractions/` read and write dnsmasq config and hosts. A **managed** config file and optional managed hosts file are written by the app; the main config must include the managed file (e.g. via a final `conf-file=` line). Caches (`ConfigSetCache`, `HostsCache`) keep in-memory snapshots and refresh on file changes or staleness.
|
||||
- **Parsers and models:** `Parsers/` parses dnsmasq config lines and files (`DnsmasqConfIncludeParser`, `DnsmasqConfDirectiveParser`, etc.). Models in `Models/` represent config sets, config, sources, DHCP entries, hosts, leases.
|
||||
- **Entry and config:** `Program.cs` wires the host; configuration (e.g. `Dnsmasq__*`) lives in `Models/Config/` (`DnsmasqOptions`, `ApplicationOptions`, `DnsmasqOptionsValidator`). Options are validated at startup.
|
||||
- **Infrastructure:** Under `Infrastructure/`: **Client/** (HTTP API clients and `Abstractions/`), **Serialization/Parsers/** (Dnsmasq version/compile, DnsmasqConfig conf-line and include parsers, EffectiveConfig syntax helpers), **Services/** (caches, config/hosts/leases services, reload, hosted services; EffectiveConfig has Metadata, Rendering, Editing, Validation, Readonly), **Helpers/Config/** (file encoding for config/hosts), **Helpers/Http/** (HTTP helpers). A **managed** config file and optional managed hosts file are written by the app; the main config must include the managed file (e.g. via a final `conf-file=` line). Caches (`ConfigSetCache`, `HostsCache`) keep in-memory snapshots and refresh on file changes or staleness.
|
||||
- **Models:** `Models/Config/` (options, validators, `DnsmasqConfLine`); `Models/Contracts/` (snapshots and DTOs such as `ConfigSetSnapshot`, `HostsSnapshot`, `ManagedConfigContent`, `ProcessRunResult`, `ReloadResult`); `Models/Dnsmasq/` (status, `SaveWithReloadResult`, and `EffectiveConfig/` for effective config and sources); `Models/Client/`, `Models/Dhcp/`, `Models/Hosts/` (UI and API DTOs).
|
||||
- **API and UI:** `Controllers/` expose API endpoints; `Components/` contains Blazor Server components for config editor, hosts, DHCP, Dnsmasq status, and app settings. Static assets in `wwwroot/`.
|
||||
- **Tests:** `DnsmasqWebUI.Tests` contains unit tests for parsers, config set service, config, and related logic. Run with `dotnet test`.
|
||||
- **Tests:** `DnsmasqWebUI.Tests` contains unit tests organised to mirror the main project: `Serialization/Parsers/` (Dnsmasq, DnsmasqConfig), `Services/Dnsmasq/Config/`, `Services/EffectiveConfig/`, `Models/`, and `Helpers/` (TestDataHelper). Run with `dotnet test`.
|
||||
|
||||
<!-- SUGGESTED SCREENSHOT: SOLUTION EXPLORER OR FOLDER TREE OF SRC/ -->
|
||||
|
||||
@@ -506,7 +535,7 @@ Use `appsettings.json` or launch settings to set `Dnsmasq__MainConfigPath` and o
|
||||
```bash
|
||||
./scripts/prepare-test-mount.sh
|
||||
```
|
||||
This clears the mount dir (by default `testdata-mount`), syncs `testdata/` into it, and starts the stack with `docker-compose.test.yml`. Use `--prepare-only` to only prepare the mount, then start compose manually; use `--build` after changing the app or Dockerfile.
|
||||
This syncs `testdata/` into the mount (preserving existing managed config, hosts, and `leases` unless you use `--clear` or `--reset-managed`), then starts the stack with `docker-compose.test.yml`. Use `--prepare-only` to only prepare the mount, then start compose manually; use `--build` after changing the app or Dockerfile.
|
||||
|
||||
2. Open the UI (e.g. http://localhost:8080). The app and dnsmasq use the mounted config; you can edit config/hosts in the UI and trigger reload.
|
||||
|
||||
|
||||
@@ -17,17 +17,21 @@ From the repo root:
|
||||
|
||||
This will:
|
||||
|
||||
1. Clear `testdata-mount/` (unless you pass `--no-clear`)
|
||||
2. Sync `testdata/` → `testdata-mount/`
|
||||
3. Remove any `*dnsmasq-webui*.conf` in the mount so dnsmasq starts clean
|
||||
4. Run `docker compose -f docker-compose.test.yml up -d --build`
|
||||
1. Run `docker compose down`, then sync `testdata/` → `testdata-mount/` (preserving mount contents unless you pass `--clear`). Sync skips `leases` and `zz-dnsmasq-webui.conf` / `zz-dnsmasq-webui.hosts` so rebuilds keep DHCP leases and UI-written managed files.
|
||||
2. Optionally remove managed `*dnsmasq-webui*.conf` / `*dnsmasq-webui*.hosts` in the mount **only** if you pass `--reset-managed`.
|
||||
3. Resolve the requested dnsmasq version (`latest` by default) to a concrete value when building images.
|
||||
4. Run `docker compose -f docker-compose.test.yml up -d` (add `--build` / `--no-cache-build` when you need a rebuild).
|
||||
|
||||
- **Quick restart (no image rebuild):** `./scripts/prepare-test-mount.sh --no-build`
|
||||
- **Force a fresh image build:** `./scripts/prepare-test-mount.sh --no-cache-build`
|
||||
- **Pin dnsmasq version:** `./scripts/prepare-test-mount.sh --dnsmasq-version 2.91 --build`
|
||||
- **Use distro package:** `./scripts/prepare-test-mount.sh --dnsmasq-version distro --build`
|
||||
- **Prepare mount only (no start):** `./scripts/prepare-test-mount.sh --prepare-only`
|
||||
Then start manually: `TESTDATA_MOUNT=./testdata-mount docker compose -f docker-compose.test.yml up -d [--build]`
|
||||
- **Preserve mount (e.g. keep leases):** `./scripts/prepare-test-mount.sh --no-clear --no-build`
|
||||
Then start manually with the version printed by the script: `TESTDATA_MOUNT=./testdata-mount DNSMASQ_VERSION=<resolved-version> docker compose -f docker-compose.test.yml up -d [--build]`
|
||||
- **Preserve mount (e.g. keep leases and managed config):** default; also use `./scripts/prepare-test-mount.sh --no-build` for a quick up without rebuilding images.
|
||||
- **Drop managed conf/hosts only (keep leases):** `./scripts/prepare-test-mount.sh --reset-managed` (often combined with `--build`).
|
||||
|
||||
App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory.
|
||||
App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory. The app service has a healthcheck (`GET /healthz/ready`); DHCP client services use `depends_on: app: condition: service_healthy` so they start only after the app (and dnsmasq) is ready.
|
||||
|
||||
### Stop the harness
|
||||
|
||||
@@ -49,6 +53,9 @@ Stops the harness and deletes the contents of `testdata-mount/` so the next run
|
||||
|
||||
- `--source DIR` — Source to sync from (default: `testdata`)
|
||||
- `--mount DIR` — Mount directory (default: `testdata-mount`). The script exports `TESTDATA_MOUNT=./DIR` when you use `--mount`, so compose uses it.
|
||||
- `--dnsmasq-version V` — Dnsmasq version for the harness image: `latest` (default), `distro`, or an exact upstream version like `2.91`.
|
||||
- `--reset-managed` — After sync, delete `*dnsmasq-webui*.conf` and `*dnsmasq-webui*.hosts` under the mount so the app recreates them.
|
||||
- `--no-cache-build` — Force a fresh image build with `docker compose build --pull --no-cache` before start.
|
||||
|
||||
Example: sync from a custom dir and start:
|
||||
|
||||
@@ -76,5 +83,12 @@ Build only: `dotnet build`
|
||||
|
||||
- **testdata/** — Source for the harness mount. Synced to `testdata-mount/` by `prepare-test-mount.sh`. Unit tests read from the copy in the test output dir. See **testdata/README** for the full layout.
|
||||
- **testdata/dnsmasq-test.conf** — Harness main config (`/data/dnsmasq-test.conf`). Richer config: multiple `server=`, `addn-hosts=`, `address=`, `listen-address=`, `conf-dir=/data/dnsmasq.d`.
|
||||
- **testdata/dnsmasq.d/** — Included configs (01-other.conf, 02-servers.conf, dhcp.conf). App creates `zz-dnsmasq-webui.conf` here when running.
|
||||
- **testdata/dnsmasq.d/** — Included configs (01-other.conf, 02-servers.conf, 03-more.conf, dhcp.conf). App creates `zz-dnsmasq-webui.conf` here when running.
|
||||
- **testdata/hosts**, **testdata/hosts.extra** — Addn-hosts files so effective config has multiple addn-hosts; UI can show source per path.
|
||||
|
||||
## When adding new effective-config options
|
||||
|
||||
When adding a new dnsmasq option to the Effective Config UI (new key in `DnsmasqConfKeys`, section, kind map, parser/write behavior, tooltip, etc.):
|
||||
|
||||
1. **Option help:** Add the option key (lowercase, e.g. `no-round-robin`) to the `OPTION_KEYS` list in `scripts/extract-option-help.sh` (keep in sync with `EffectiveConfigSections`). Then run `./scripts/extract-option-help.sh` so `wwwroot/option-help/<key>.html` is generated if the option exists in the dnsmasq man page.
|
||||
2. **Testdata (optional):** To show the option in the UI when using the harness or testdata, add a line to e.g. `testdata/dnsmasq.d/03-more.conf` and update `testdata/README` if needed.
|
||||
|
||||
+24
-7
@@ -1,7 +1,8 @@
|
||||
# Run the app with real dnsmasq and several DHCP clients so hosts/leases are real.
|
||||
# Some clients also make periodic DNS requests so the DNS cache and logs show activity.
|
||||
# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
|
||||
# Prepare mount: ./scripts/prepare-test-mount.sh [--clear | --reset-managed | --minimal-conf | --prepare-only | ...]
|
||||
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
|
||||
# Set TEST_DNSMASQ_CONF to override main config path (default: /data/dnsmasq-test.conf; use /data/dnsmasq-test-minimal.conf for minimal).
|
||||
#
|
||||
# No systemd in container (dnsmasq runs as a process from entrypoint, like most dnsmasq images).
|
||||
# StatusShowCommand/LogsCommand use ps so the Service status / Recent logs sections show something
|
||||
@@ -13,19 +14,31 @@ services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
DNSMASQ_VERSION: ${DNSMASQ_VERSION:-latest}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "-s", "http://localhost:8080/healthz/ready"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
DNSMASQ_CONF: /data/dnsmasq-test.conf
|
||||
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
|
||||
DNSMASQ_CONF: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
|
||||
Dnsmasq__MainConfigPath: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
|
||||
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf
|
||||
# No systemd in container: restart by killing and re-running dnsmasq (same as entrypoint.sh). Config path must match DNSMASQ_CONF/MainConfigPath.
|
||||
Dnsmasq__RestartCommand: "pkill -x dnsmasq || true; sleep 1; dnsmasq --test --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} && dnsmasq --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}"
|
||||
Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
|
||||
Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
|
||||
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
|
||||
Dnsmasq__StatusShowCommand: "/app/dnsmasq-status.sh"
|
||||
Dnsmasq__LogsCommand: "tail -n 100 /data/dnsmasq.log 2>/dev/null || echo '(no log file yet)'"
|
||||
# Persist runtime log level across container restarts
|
||||
RuntimeOverrides__FilePath: "/data/appsettings.Overrides.json"
|
||||
volumes:
|
||||
- ${TESTDATA_MOUNT:-./testdata-mount}:/data
|
||||
networks:
|
||||
@@ -39,7 +52,8 @@ services:
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
# DHCP + periodic DNS lookups (every 20s) so DNS cache/logs show activity.
|
||||
dhcp-client-dns-a:
|
||||
@@ -58,7 +72,8 @@ services:
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
# DHCP + periodic DNS lookups (every 45s); different interval for variety.
|
||||
dhcp-client-dns-b:
|
||||
@@ -77,7 +92,8 @@ services:
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
# Third DHCP-only client so the leases table has more entries.
|
||||
dhcp-client-2:
|
||||
@@ -86,7 +102,8 @@ services:
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
testnet:
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
#!/bin/sh
|
||||
# Simulates "systemctl status dnsmasq" output when run in a container without systemd.
|
||||
# Uses ps/pgrep. Memory is RSS. Uptime line omitted; Active line has break after semicolon with continuation indented.
|
||||
# When dnsmasq is not running, runs dnsmasq --test to show config/startup errors (use DNSMASQ_CONF for config path).
|
||||
|
||||
pid=$(pgrep -x dnsmasq)
|
||||
if [ -z "$pid" ]; then
|
||||
echo "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server"
|
||||
echo " Loaded: (container, no systemd)"
|
||||
echo " Active: inactive (dead)"
|
||||
conf="${DNSMASQ_CONF:-/etc/dnsmasq.conf}"
|
||||
if [ -f "$conf" ]; then
|
||||
err=$(dnsmasq --test --conf-file="$conf" 2>&1)
|
||||
if [ -n "$err" ]; then
|
||||
echo ""
|
||||
echo " Config test (dnsmasq --test):"
|
||||
echo "$err" | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
Executable
+307
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env bash
|
||||
# Extract per-option HTML from the dnsmasq man page for use as field help in the web UI.
|
||||
#
|
||||
# Runs a temporary Python (BeautifulSoup) container to fetch the page, parse the OPTIONS
|
||||
# <DL>/<DT>/<DD> blocks, and write one .html file per option. The container is removed
|
||||
# when done. If the page URL or structure changes, pass a new --url.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/extract-option-help.sh [--url URL] [--output-dir DIR]
|
||||
#
|
||||
# Requires: Docker (python:3-slim image will be pulled if missing)
|
||||
|
||||
set -e
|
||||
|
||||
DEFAULT_URL="https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html"
|
||||
DEFAULT_OUTPUT_DIR="src/DnsmasqWebUI/wwwroot/option-help"
|
||||
URL="$DEFAULT_URL"
|
||||
OUTPUT_DIR="$DEFAULT_OUTPUT_DIR"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--url) URL="$2"; shift 2 ;;
|
||||
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$OUTPUT_DIR" != /* ]] && OUTPUT_DIR="$PWD/$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
SCRIPT_FILE="$(mktemp)"
|
||||
trap 'rm -f "$SCRIPT_FILE"' EXIT
|
||||
|
||||
# Embed Python script (uses requests + BeautifulSoup; runs inside container)
|
||||
cat << 'PYTHON_SCRIPT' > "$SCRIPT_FILE"
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError:
|
||||
sys.stderr.write("Installing requests and beautifulsoup4...\n")
|
||||
import subprocess
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "requests", "beautifulsoup4"])
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
OUT_DIR = "/out"
|
||||
URL = os.environ.get("URL", "https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html")
|
||||
|
||||
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections.
|
||||
OPTION_KEYS = [
|
||||
"no-hosts",
|
||||
"addn-hosts",
|
||||
"hostsdir",
|
||||
"read-ethers",
|
||||
"server",
|
||||
"local",
|
||||
"rev-server",
|
||||
"address",
|
||||
"resolv-file",
|
||||
"no-resolv",
|
||||
"domain-needed",
|
||||
"port",
|
||||
"log-queries",
|
||||
"strict-order",
|
||||
"all-servers",
|
||||
"auth-ttl",
|
||||
"edns-packet-max",
|
||||
"query-port",
|
||||
"port-limit",
|
||||
"min-port",
|
||||
"max-port",
|
||||
"dns-loop-detect",
|
||||
"stop-dns-rebind",
|
||||
"rebind-localhost-ok",
|
||||
"clear-on-reload",
|
||||
"expand-hosts",
|
||||
"bogus-priv",
|
||||
"rebind-domain-ok",
|
||||
"bogus-nxdomain",
|
||||
"ignore-address",
|
||||
"alias",
|
||||
"filter-rr",
|
||||
"filterwin2k",
|
||||
"filter-A",
|
||||
"filter-AAAA",
|
||||
"localise-queries",
|
||||
"fast-dns-retry",
|
||||
"ipset",
|
||||
"nftset",
|
||||
"domain",
|
||||
"cname",
|
||||
"mx-host",
|
||||
"srv-host",
|
||||
"ptr-record",
|
||||
"txt-record",
|
||||
"naptr-record",
|
||||
"host-record",
|
||||
"dynamic-host",
|
||||
"interface-name",
|
||||
"mx-target",
|
||||
"localmx",
|
||||
"selfmx",
|
||||
"dhcp-authoritative",
|
||||
"dhcp-rapid-commit",
|
||||
"leasefile-ro",
|
||||
"dhcp-script",
|
||||
"dhcp-leasefile",
|
||||
"dhcp-lease-max",
|
||||
"dhcp-ttl",
|
||||
"dhcp-range",
|
||||
"dhcp-host",
|
||||
"dhcp-option",
|
||||
"dhcp-option-force",
|
||||
"dhcp-match",
|
||||
"dhcp-mac",
|
||||
"dhcp-name-match",
|
||||
"dhcp-ignore-names",
|
||||
"dhcp-hostsfile",
|
||||
"dhcp-optsfile",
|
||||
"dhcp-hostsdir",
|
||||
"dhcp-optsdir",
|
||||
"dhcp-boot",
|
||||
"dhcp-ignore",
|
||||
"dhcp-vendorclass",
|
||||
"dhcp-userclass",
|
||||
"ra-param",
|
||||
"slaac",
|
||||
"enable-tftp",
|
||||
"tftp-secure",
|
||||
"tftp-no-fail",
|
||||
"tftp-no-blocksize",
|
||||
"tftp-root",
|
||||
"pxe-prompt",
|
||||
"pxe-service",
|
||||
"dnssec",
|
||||
"dnssec-check-unsigned",
|
||||
"proxy-dnssec",
|
||||
"trust-anchor",
|
||||
"cache-rr",
|
||||
"cache-size",
|
||||
"local-ttl",
|
||||
"no-negcache",
|
||||
"neg-ttl",
|
||||
"max-ttl",
|
||||
"max-cache-ttl",
|
||||
"min-cache-ttl",
|
||||
"no-poll",
|
||||
"bind-interfaces",
|
||||
"bind-dynamic",
|
||||
"log-debug",
|
||||
"log-async",
|
||||
"local-service",
|
||||
"interface",
|
||||
"listen-address",
|
||||
"except-interface",
|
||||
"auth-server",
|
||||
"no-dhcp-interface",
|
||||
"no-dhcpv4-interface",
|
||||
"no-dhcpv6-interface",
|
||||
"pid-file",
|
||||
"user",
|
||||
"group",
|
||||
"log-facility",
|
||||
"enable-dbus",
|
||||
"enable-ubus",
|
||||
"enable-ra",
|
||||
"log-dhcp",
|
||||
"keep-in-foreground",
|
||||
"no-daemon",
|
||||
"conntrack",
|
||||
"connmark-allowlist-enable",
|
||||
"no-round-robin",
|
||||
"dns-forward-max",
|
||||
"connmark-allowlist",
|
||||
"do-0x20-encode",
|
||||
"caa-record",
|
||||
"dns-rr",
|
||||
"synth-domain",
|
||||
"auth-zone",
|
||||
"auth-soa",
|
||||
"auth-sec-servers",
|
||||
"auth-peer",
|
||||
"leasequery",
|
||||
"dhcp-generate-names",
|
||||
"dhcp-broadcast",
|
||||
"dhcp-sequential-ip",
|
||||
"dhcp-ignore-clid",
|
||||
"bootp-dynamic",
|
||||
"no-ping",
|
||||
"script-arp",
|
||||
"script-on-renewal",
|
||||
"dhcp-no-override",
|
||||
"dhcp-alternate-port",
|
||||
"dhcp-duid",
|
||||
"dhcp-luascript",
|
||||
"dhcp-scriptuser",
|
||||
"dhcp-relay",
|
||||
"dhcp-circuitid",
|
||||
"dhcp-remoteid",
|
||||
"dhcp-subscrid",
|
||||
"dhcp-proxy",
|
||||
"tag-if",
|
||||
"bridge-interface",
|
||||
"shared-network",
|
||||
"dhcp-pxe-vendor",
|
||||
"dhcp-option-pxe",
|
||||
"dnssec-no-timecheck",
|
||||
"dnssec-debug",
|
||||
"add-cpe-id",
|
||||
"dnssec-timestamp",
|
||||
"dnssec-limits",
|
||||
"dumpfile",
|
||||
"dumpmask",
|
||||
"use-stale-cache",
|
||||
"add-mac",
|
||||
"strip-mac",
|
||||
"add-subnet",
|
||||
"strip-subnet",
|
||||
"umbrella",
|
||||
"quiet-dhcp",
|
||||
"quiet-dhcp6",
|
||||
"quiet-ra",
|
||||
"quiet-tftp",
|
||||
]
|
||||
|
||||
long_opt_re = re.compile(r"--([a-z][a-z0-9-]*)(?:=[^,\s]*)?", re.IGNORECASE)
|
||||
|
||||
def option_names_from_dt(dt):
|
||||
return [m.group(1).lower() for m in long_opt_re.finditer(dt.get_text())]
|
||||
|
||||
def main():
|
||||
r = requests.get(URL, timeout=30)
|
||||
r.raise_for_status()
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
h2 = None
|
||||
for el in soup.find_all("h2"):
|
||||
if el.get_text(strip=True).upper() == "OPTIONS":
|
||||
h2 = el
|
||||
break
|
||||
if not h2:
|
||||
sys.stderr.write("Could not find OPTIONS H2\n")
|
||||
sys.exit(1)
|
||||
dl = h2.find_next("dl")
|
||||
if not dl:
|
||||
sys.stderr.write("Could not find DL after OPTIONS\n")
|
||||
sys.exit(1)
|
||||
# Build option -> (dt_html, dd_html) from raw HTML; split by <dt> so we get one block per option.
|
||||
raw_dl = str(dl)
|
||||
option_to_block = {}
|
||||
blocks = re.split(r"<dt\b", raw_dl, flags=re.IGNORECASE)
|
||||
for block in blocks[1:]:
|
||||
p_dt = block.find("</dt>") if "</dt>" in block.lower() else len(block) + 1
|
||||
m_dd = re.search(r"<dd\b", block, re.IGNORECASE)
|
||||
p_dd = m_dd.start() if m_dd else len(block) + 1
|
||||
end_pos = min(p_dt, p_dd)
|
||||
if end_pos > len(block):
|
||||
continue
|
||||
dt_part = block[:end_pos].strip()
|
||||
rest = block[end_pos:]
|
||||
if rest.lower().startswith("</dt>"):
|
||||
rest = rest[6:].lstrip()
|
||||
if ">" in dt_part:
|
||||
dt_part = dt_part.split(">", 1)[1]
|
||||
dt_html = "<dt>" + dt_part + "</dt>"
|
||||
dd_match = re.search(r"<dd\b[^>]*>(.*)", rest, re.DOTALL | re.IGNORECASE)
|
||||
dd_body = dd_match.group(1) if dd_match else ""
|
||||
dd_body = re.split(r"</dd>|<dt\s|<dt>", dd_body, flags=re.IGNORECASE)[0].strip()
|
||||
dd_html = "<dd>" + dd_body + "</dd>" if dd_body else "<dd></dd>"
|
||||
soup_dt = BeautifulSoup(dt_html, "html.parser").find("dt")
|
||||
if not soup_dt:
|
||||
continue
|
||||
for n in option_names_from_dt(soup_dt):
|
||||
if n not in option_to_block:
|
||||
option_to_block[n] = (dt_html, dd_html)
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
written = 0
|
||||
for key in OPTION_KEYS:
|
||||
norm = key.lower()
|
||||
if norm not in option_to_block:
|
||||
continue
|
||||
dt_html, dd_html = option_to_block[norm]
|
||||
frag = "\n".join([dt_html, dd_html])
|
||||
safe = re.sub(r"[^a-z0-9-]", "", norm) or key
|
||||
path = os.path.join(OUT_DIR, f"{safe}.html")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(frag)
|
||||
written += 1
|
||||
print(f"Wrote {written} files to {OUT_DIR}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
PYTHON_SCRIPT
|
||||
|
||||
echo "Fetching man page and extracting option help (Docker python:3-slim) ..."
|
||||
docker run --rm \
|
||||
-v "$OUTPUT_DIR:/out" \
|
||||
-v "$SCRIPT_FILE:/script.py:ro" \
|
||||
-e "URL=$URL" \
|
||||
-e "PIP_ROOT_USER_ACTION=ignore" \
|
||||
python:3-slim \
|
||||
sh -c "pip install -q requests beautifulsoup4 && python /script.py"
|
||||
|
||||
echo "Done. Files in $OUTPUT_DIR"
|
||||
+71
-92
@@ -1,12 +1,10 @@
|
||||
#!/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/alexhopeoconnor/dnsmasq-webui/main/scripts/install.sh | sh
|
||||
# When run from a clone, repo is detected from git remote origin.
|
||||
# curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/master/scripts/install.sh | sh
|
||||
set -e
|
||||
|
||||
GITHUB_REPO="${GITHUB_REPO:-}"
|
||||
REPO_DEFAULT="${REPO_DEFAULT:-alexhopeoconnor/dnsmasq-webui}"
|
||||
GITHUB_REPO="alexhopeoconnor/dnsmasq-webui"
|
||||
|
||||
RELEASE_TAG=""
|
||||
LIST=false
|
||||
@@ -37,10 +35,6 @@ usage() {
|
||||
echo "Download and install dnsmasq-webui from a GitHub release for this machine's OS/arch."
|
||||
echo "Supports install, update, and switch-release (re-run with same --dir and different --version)."
|
||||
echo ""
|
||||
echo "Repo (when not in a clone):"
|
||||
echo " --repo OWNER/REPO GitHub owner/repo. Or set GITHUB_REPO. Or set REPO_DEFAULT in the script."
|
||||
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 TAG (e.g. v1.0.0). Default: latest."
|
||||
@@ -72,7 +66,7 @@ usage() {
|
||||
echo " sudo $0 --uninstall --purge --system # also remove /opt/dnsmasq-webui"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Install latest (clone: auto repo; curl: use REPO_DEFAULT or --repo)"
|
||||
echo " $0 # Install latest"
|
||||
echo " $0 --update # Reinstall latest to default dir (upgrade)"
|
||||
echo " $0 --version v1.0.0 # Install v1.0.0 to default dir"
|
||||
echo " $0 --dir /opt/dnsmasq-webui # Install to custom dir"
|
||||
@@ -90,25 +84,26 @@ usage() {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Trim whitespace and carriage return (e.g. from script downloaded on Windows or with CRLF).
|
||||
trim_repo() {
|
||||
echo "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
# Build CONFIG_VARS_FILE from env (DNSMASQ_WEBUI_*) then --set lines. Call before do_install when installing.
|
||||
# Result: key=value per line (env first, then --set so --set overrides). Safe for values with = in them.
|
||||
collect_config() {
|
||||
CONFIG_VARS_FILE="$(mktemp)"
|
||||
export CONFIG_VARS_FILE
|
||||
# Env vars: strip DNSMASQ_WEBUI_ prefix; key=value per line
|
||||
env | grep '^DNSMASQ_WEBUI_' | while IFS= read -r line; do
|
||||
key="${line%%=*}"
|
||||
key="${key#DNSMASQ_WEBUI_}"
|
||||
value="${line#*=}"
|
||||
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_VARS_FILE"
|
||||
done
|
||||
# Env vars: strip DNSMASQ_WEBUI_ prefix; key=value per line. set +e so grep (no match) / read (EOF) / [ -f ] do not trigger exit.
|
||||
set +e
|
||||
env | grep '^DNSMASQ_WEBUI_' > "${CONFIG_VARS_FILE}.env" 2>/dev/null
|
||||
if [ -f "${CONFIG_VARS_FILE}.env" ] && [ -s "${CONFIG_VARS_FILE}.env" ]; then
|
||||
while IFS= read -r line; do
|
||||
key="${line%%=*}"
|
||||
key="${key#DNSMASQ_WEBUI_}"
|
||||
value="${line#*=}"
|
||||
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_VARS_FILE"
|
||||
done < "${CONFIG_VARS_FILE}.env"
|
||||
fi
|
||||
rm -f "${CONFIG_VARS_FILE}.env"
|
||||
set -e
|
||||
# --set lines (last occurrence of each key wins when systemd reads the file)
|
||||
[ -s "$CONFIG_SET_FILE" ] && cat "$CONFIG_SET_FILE" >> "$CONFIG_VARS_FILE"
|
||||
[ -s "$CONFIG_SET_FILE" ] && cat "$CONFIG_SET_FILE" >> "$CONFIG_VARS_FILE" || true
|
||||
}
|
||||
|
||||
# Write env file for the app/service. $1 = path. Adds ASPNETCORE_URLS=http://0.0.0.0:8080 if not in config (for service).
|
||||
@@ -139,30 +134,6 @@ write_env_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect owner/repo from git remote, REPO_DEFAULT, or require --repo/GITHUB_REPO.
|
||||
detect_repo() {
|
||||
if [ -n "$GITHUB_REPO" ]; then
|
||||
GITHUB_REPO="$(trim_repo "$GITHUB_REPO")"
|
||||
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
|
||||
GITHUB_REPO="$(echo "$origin" | sed -E 's|^https://github\.com/||; s|^git@github\.com:||; s|\.git$||; s|/$||')"
|
||||
GITHUB_REPO="$(trim_repo "$GITHUB_REPO")"
|
||||
if [ -n "$GITHUB_REPO" ]; then
|
||||
return
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "$REPO_DEFAULT" ]; then
|
||||
GITHUB_REPO="$(trim_repo "$REPO_DEFAULT")"
|
||||
return
|
||||
fi
|
||||
echo "Error: GitHub repo not set. Use --repo owner/repo, set GITHUB_REPO, or run from a clone. For the one-liner, set REPO_DEFAULT in the script." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Require jq for parsing GitHub API JSON.
|
||||
check_jq() {
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
@@ -172,6 +143,14 @@ check_jq() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Safely run jq on release JSON (avoids echo mangling). $1 = JSON, $2+ = jq args.
|
||||
jq_release() {
|
||||
local json
|
||||
json="$1"
|
||||
shift
|
||||
printf '%s' "$json" | jq -r "$@"
|
||||
}
|
||||
|
||||
# Detect RID (same logic as publish-self-contained.sh).
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
@@ -219,6 +198,7 @@ print_env_detection() {
|
||||
}
|
||||
|
||||
# GET release (latest or by tag). Output raw JSON to stdout.
|
||||
# Use </dev/null so curl does not consume stdin (script when run via curl | sh).
|
||||
fetch_release() {
|
||||
local api_url
|
||||
if [ -z "$RELEASE_TAG" ] || [ "$RELEASE_TAG" = "latest" ]; then
|
||||
@@ -226,41 +206,46 @@ fetch_release() {
|
||||
else
|
||||
api_url="https://api.github.com/repos/$GITHUB_REPO/releases/tags/$RELEASE_TAG"
|
||||
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')"
|
||||
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url" </dev/null)"
|
||||
code="$(printf '%s' "$resp" | tail -n1)"
|
||||
body="$(printf '%s' "$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
|
||||
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
|
||||
printf '%s' "$body" | jq -r '.message // .' 2>/dev/null || printf '%s' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$body"
|
||||
# GitHub API can return release body with unescaped control chars; jq rejects them. Strip control chars (keep \n \r).
|
||||
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
|
||||
printf '%s' "$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')"
|
||||
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url" </dev/null)"
|
||||
code="$(printf '%s' "$resp" | tail -n1)"
|
||||
body="$(printf '%s' "$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)"'
|
||||
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
|
||||
printf '%s' "$body" | jq -r '.[] | "\(.tag_name) \(.name) \(.published_at // .created_at)"'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Find asset download URL whose name contains the given RID.
|
||||
# Find asset download URL whose name contains the given RID. Output trimmed single line or empty.
|
||||
find_asset_url() {
|
||||
local release_json rid
|
||||
local release_json rid raw
|
||||
release_json="$1"
|
||||
rid="$2"
|
||||
echo "$release_json" | jq -r --arg rid "$rid" '
|
||||
raw="$(jq_release "$release_json" --arg rid "$rid" '
|
||||
.assets[] | select(.name | test($rid)) | .browser_download_url
|
||||
' | head -n1
|
||||
' | head -n1)"
|
||||
# Trim CR and leading/trailing whitespace (defensive for API quirks)
|
||||
printf '%s' "$raw" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
# Remove user systemd unit (stop, disable, rm file). No root. Safe if not present.
|
||||
@@ -361,7 +346,7 @@ do_install_from_source() {
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v dotnet >/dev/null 2>&1; then
|
||||
echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-9.0)." >&2
|
||||
echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-10.0)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -386,7 +371,7 @@ do_install_from_source() {
|
||||
echo "Error: Could not determine RID from build output." >&2
|
||||
exit 1
|
||||
fi
|
||||
publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net9.0/$rid/publish"
|
||||
publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net10.0/$rid/publish"
|
||||
if [ ! -d "$publish_dir" ] || [ ! -f "$publish_dir/DnsmasqWebUI" ]; then
|
||||
echo "Error: Build output not found at $publish_dir" >&2
|
||||
exit 1
|
||||
@@ -440,8 +425,8 @@ do_install() {
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Installing dnsmasq-webui..."
|
||||
check_jq
|
||||
detect_repo
|
||||
|
||||
if [ "$SYSTEM_INSTALL" = true ]; then
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
@@ -468,20 +453,20 @@ do_install() {
|
||||
print_env_detection "$rid"
|
||||
echo "Fetching release..."
|
||||
release_json="$(fetch_release)"
|
||||
tag="$(echo "$release_json" | jq -r '.tag_name')"
|
||||
tag="$(jq_release "$release_json" '.tag_name')"
|
||||
echo "Release: $tag"
|
||||
url="$(find_asset_url "$release_json" "$rid" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
||||
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
|
||||
jq_release "$release_json" '.assets[].name' | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
tmpzip="${TMPDIR:-/tmp}/dnsmasq-webui-$rid.zip"
|
||||
tmpzip="$(mktemp)"
|
||||
echo "Downloading $url ..."
|
||||
curl -sSL -A "dnsmasq-webui-install/1.0" -o "$tmpzip" "$url"
|
||||
curl -sSL -A "dnsmasq-webui-install/1.0" -o "$tmpzip" "$url" </dev/null
|
||||
echo "Extracting to $INSTALL_DIR ..."
|
||||
unzip -o -q "$tmpzip" -d "$INSTALL_DIR"
|
||||
rm -f "$tmpzip"
|
||||
@@ -619,12 +604,6 @@ while [ $# -gt 0 ]; do
|
||||
-h|-?|--help)
|
||||
usage
|
||||
;;
|
||||
--repo)
|
||||
shift
|
||||
[ $# -gt 0 ] || { echo "Error: --repo requires OWNER/REPO" >&2; exit 1; }
|
||||
GITHUB_REPO="$1"
|
||||
shift
|
||||
;;
|
||||
--list)
|
||||
LIST=true
|
||||
shift
|
||||
@@ -689,26 +668,26 @@ if [ "$PURGE" = true ] && [ "$UNINSTALL" != true ]; then
|
||||
echo "Error: --purge must be used with --uninstall. E.g. $0 --uninstall --purge" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$UNINSTALL" = true ]; then
|
||||
if [ "$UPDATE" = true ] || [ -n "$RELEASE_TAG" ] || [ "$SERVICE" = true ] || [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with install/update options (--version, --update, --service, --build-from-source). Run uninstall alone, then install if needed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$UNINSTALL" = true ]; then
|
||||
if [ "$UPDATE" = true ] || [ -n "$RELEASE_TAG" ] || [ "$SERVICE" = true ] || [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with install/update options (--version, --update, --service, --build-from-source). Run uninstall alone, then install if needed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --build-from-source cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$RELEASE_TAG" ] || [ "$UPDATE" = true ]; then
|
||||
echo "Error: --build-from-source builds from current source; do not use --version or --update." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --build-from-source cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$RELEASE_TAG" ] || [ "$UPDATE" = true ]; then
|
||||
echo "Error: --build-from-source builds from current source; do not use --version or --update." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$LIST" = true ]; then
|
||||
if [ "$UNINSTALL" = true ] || [ "$UPDATE" = true ] || [ "$SERVICE" = true ] || [ -n "$RELEASE_TAG" ]; then
|
||||
echo "Error: --list lists releases and exits; do not combine with install/uninstall options." >&2
|
||||
|
||||
+147
-25
@@ -3,8 +3,8 @@
|
||||
# (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, clean up previous test data,
|
||||
# then docker compose up -d [--build] [--force-recreate].
|
||||
# Start (default): sync source -> mount (preserving existing unless --clear); optional --reset-managed
|
||||
# removes app-written files in the mount. Then docker compose up -d [--build] [--force-recreate].
|
||||
# --stop: docker compose down (stop and remove containers/networks).
|
||||
# --tidy: docker compose down, then clear the mount directory for a clean next run.
|
||||
set -e
|
||||
@@ -15,40 +15,54 @@ COMPOSE_FILE="docker-compose.test.yml"
|
||||
|
||||
SOURCE_DIR=""
|
||||
MOUNT_DIR=""
|
||||
DNSMASQ_VERSION=""
|
||||
PREPARE_ONLY=false
|
||||
BUILD=false
|
||||
NO_CACHE_BUILD=false
|
||||
RECREATE=false
|
||||
NO_CLEAR=false
|
||||
CLEAR=false
|
||||
RESET_MANAGED=false
|
||||
STOP=false
|
||||
TIDY=false
|
||||
MINIMAL_CONF=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS] [--]"
|
||||
echo ""
|
||||
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 "(app + dnsmasq + DHCP client). By default: sync testdata -> mount (preserving existing), then up -d (no rebuild)."
|
||||
echo ""
|
||||
echo "Steps (when starting):"
|
||||
echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount."
|
||||
echo " 2. Clean up previous test data (e.g. managed config) so the harness starts clean."
|
||||
echo " 1. Optionally clear mount dir (only with --clear), then sync source -> mount."
|
||||
echo " Sync skips app-managed filenames (zz-dnsmasq-webui.conf / .hosts) so rebuilds keep UI state."
|
||||
echo " 2. Optionally remove managed *dnsmasq-webui*.conf / *dnsmasq-webui*.hosts (only with --reset-managed)."
|
||||
echo " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
|
||||
echo ""
|
||||
echo "Path options:"
|
||||
echo " --source DIR Source to copy from (default: testdata)"
|
||||
echo " --mount DIR Target mount directory (default: testdata-mount)"
|
||||
echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount."
|
||||
echo " --dnsmasq-version V Dnsmasq version for the harness image:"
|
||||
echo " latest (default), distro, or an exact upstream version like 2.91."
|
||||
echo " Non-default values require --build or --no-cache-build; script fails with --no-build."
|
||||
echo ""
|
||||
echo "Mount behaviour:"
|
||||
echo " (default) Clear mount dir completely, then sync. Use for a clean run."
|
||||
echo " --no-clear Do not clear mount dir; only sync over existing contents."
|
||||
echo " Use to preserve leases or debug files between runs."
|
||||
echo " (default) Preserve mount dir; sync source over existing contents."
|
||||
echo " --clear Clear mount dir completely before sync. Use for a clean run."
|
||||
echo " --reset-managed After sync, delete *dnsmasq-webui*.conf and *dnsmasq-webui*.hosts under the"
|
||||
echo " mount so the app recreates managed config/hosts (leases and other files stay)."
|
||||
echo ""
|
||||
echo "Compose behaviour:"
|
||||
echo " --minimal-conf Use minimal dnsmasq config (dnsmasq-test-minimal.conf) so effective"
|
||||
echo " config has few readonly options; good for testing reload/restart failure flows."
|
||||
echo " --prepare-only Only prepare the mount; do not run docker compose."
|
||||
echo " Use to inspect or edit the mount before starting 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-cache-build Rebuild images with --pull --no-cache before starting."
|
||||
echo " Use to force a fresh image build and refresh 'latest' dnsmasq."
|
||||
echo " --no-build Do not rebuild (default). Use existing images for a quick restart."
|
||||
echo " With --no-build, 'latest' is not resolved (no network)."
|
||||
echo " --recreate Pass --force-recreate to docker compose (recreate containers)."
|
||||
echo " Use to ensure fresh container state and mounts."
|
||||
echo ""
|
||||
@@ -62,19 +76,25 @@ usage() {
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0"
|
||||
echo " Full run: clear mount, sync testdata, start containers (no rebuild)."
|
||||
echo " Sync testdata to mount (preserving existing), start containers (no rebuild)."
|
||||
echo ""
|
||||
echo " $0 --build"
|
||||
echo " Clear and sync, then rebuild images and start containers."
|
||||
echo " Sync, rebuild images, and start containers."
|
||||
echo ""
|
||||
echo " $0 --recreate"
|
||||
echo " Clear and sync, then up -d --force-recreate (fresh containers)."
|
||||
echo " Sync, then up -d --force-recreate (fresh containers)."
|
||||
echo ""
|
||||
echo " $0 --no-clear"
|
||||
echo " Preserve mount contents, sync over it, start (no rebuild)."
|
||||
echo " $0 --no-cache-build"
|
||||
echo " Force a fresh image build (no Docker build cache), then start containers."
|
||||
echo ""
|
||||
echo " $0 --clear"
|
||||
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
|
||||
echo ""
|
||||
echo " $0 --reset-managed --build"
|
||||
echo " Rebuild images; sync fixtures; drop managed conf/hosts so app writes fresh."
|
||||
echo ""
|
||||
echo " $0 --prepare-only"
|
||||
echo " Only clear and sync testdata -> testdata-mount; no containers."
|
||||
echo " Only sync testdata -> testdata-mount; no containers."
|
||||
echo " Then run: docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
echo ""
|
||||
echo " $0 --source myfixtures --mount mymount --prepare-only"
|
||||
@@ -86,6 +106,38 @@ usage() {
|
||||
echo ""
|
||||
echo " $0 --tidy"
|
||||
echo " Stop harness and clear testdata-mount for a clean next run."
|
||||
echo ""
|
||||
echo " $0 --minimal-conf"
|
||||
echo " Use dnsmasq-test-minimal.conf (single file, few options) and start."
|
||||
echo ""
|
||||
echo " $0 --dnsmasq-version 2.91 --build"
|
||||
echo " Rebuild the harness image with dnsmasq 2.91."
|
||||
}
|
||||
|
||||
fetch_url() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$1"
|
||||
return $?
|
||||
fi
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- "$1"
|
||||
return $?
|
||||
fi
|
||||
echo "Error: need curl or wget to resolve the latest upstream dnsmasq version." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_dnsmasq_version() {
|
||||
if [ "$DNSMASQ_VERSION" != "latest" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
RESOLVED_VERSION="$(fetch_url "https://thekelleys.org.uk/dnsmasq/" | sed -n 's/.*LATEST_IS_\([0-9][0-9.]*\).*/\1/p' | head -n1)"
|
||||
if [ -z "$RESOLVED_VERSION" ]; then
|
||||
echo "Error: could not resolve the latest upstream dnsmasq version." >&2
|
||||
exit 1
|
||||
fi
|
||||
DNSMASQ_VERSION="$RESOLVED_VERSION"
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -106,8 +158,18 @@ while [ $# -gt 0 ]; do
|
||||
MOUNT_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
--no-clear)
|
||||
NO_CLEAR=true
|
||||
--dnsmasq-version)
|
||||
shift
|
||||
[ $# -gt 0 ] || { echo "Error: --dnsmasq-version requires VERSION" >&2; exit 1; }
|
||||
DNSMASQ_VERSION="$1"
|
||||
shift
|
||||
;;
|
||||
--clear)
|
||||
CLEAR=true
|
||||
shift
|
||||
;;
|
||||
--reset-managed)
|
||||
RESET_MANAGED=true
|
||||
shift
|
||||
;;
|
||||
--prepare-only)
|
||||
@@ -118,14 +180,24 @@ while [ $# -gt 0 ]; do
|
||||
BUILD=true
|
||||
shift
|
||||
;;
|
||||
--no-cache-build)
|
||||
NO_CACHE_BUILD=true
|
||||
BUILD=true
|
||||
shift
|
||||
;;
|
||||
--no-build)
|
||||
BUILD=false
|
||||
NO_CACHE_BUILD=false
|
||||
shift
|
||||
;;
|
||||
--recreate)
|
||||
RECREATE=true
|
||||
shift
|
||||
;;
|
||||
--minimal-conf)
|
||||
MINIMAL_CONF=true
|
||||
shift
|
||||
;;
|
||||
--stop)
|
||||
STOP=true
|
||||
shift
|
||||
@@ -161,6 +233,7 @@ cd "$REPO_ROOT"
|
||||
|
||||
: "${SOURCE_DIR:=testdata}"
|
||||
: "${MOUNT_DIR:=testdata-mount}"
|
||||
: "${DNSMASQ_VERSION:=latest}"
|
||||
|
||||
# Stop and/or tidy: no prepare, no start
|
||||
if [ "$STOP" = true ] || [ "$TIDY" = true ]; then
|
||||
@@ -183,26 +256,44 @@ if [ ! -d "$SOURCE_DIR" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve "latest" only when we will build; avoid network when doing a no-build run.
|
||||
WILL_BUILD=false
|
||||
[ "$BUILD" = true ] || [ "$NO_CACHE_BUILD" = true ] && WILL_BUILD=true
|
||||
if [ "$WILL_BUILD" = false ] && [ "$DNSMASQ_VERSION" != "latest" ] && [ "$DNSMASQ_VERSION" != "distro" ]; then
|
||||
echo "Error: --dnsmasq-version $DNSMASQ_VERSION has no effect without --build or --no-cache-build. Add --build or omit --dnsmasq-version for a no-build run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$WILL_BUILD" = true ]; then
|
||||
resolve_dnsmasq_version
|
||||
fi
|
||||
|
||||
# Take the harness down so all containers (including one-shot DHCP clients) are recreated on up.
|
||||
echo "Stopping test harness: docker compose -f $COMPOSE_FILE down"
|
||||
docker compose -f "$COMPOSE_FILE" down
|
||||
|
||||
mkdir -p "$MOUNT_DIR"
|
||||
|
||||
if [ "$NO_CLEAR" = false ]; then
|
||||
if [ "$CLEAR" = true ]; then
|
||||
echo "Clearing mount directory: $MOUNT_DIR"
|
||||
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Exclude sample leases (harness creates real lease file) and app-managed filenames so rebuilds preserve
|
||||
# UI-written config/hosts; use --reset-managed or --clear when you want those removed.
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --exclude='leases' "$SOURCE_DIR/" "$MOUNT_DIR/"
|
||||
rsync -a \
|
||||
--exclude='leases' \
|
||||
--exclude='zz-dnsmasq-webui.conf' \
|
||||
--exclude='zz-dnsmasq-webui.hosts' \
|
||||
"$SOURCE_DIR/" "$MOUNT_DIR/"
|
||||
else
|
||||
cp -r "$SOURCE_DIR/." "$MOUNT_DIR/"
|
||||
rm -f "$MOUNT_DIR/leases"
|
||||
(cd "$SOURCE_DIR" && tar cf - --exclude=leases --exclude=zz-dnsmasq-webui.conf --exclude=zz-dnsmasq-webui.hosts .) | (cd "$MOUNT_DIR" && tar xf -)
|
||||
fi
|
||||
|
||||
# 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
|
||||
if [ "$RESET_MANAGED" = true ]; then
|
||||
echo "Removing app-managed files (*dnsmasq-webui*.conf / *dnsmasq-webui*.hosts) under $MOUNT_DIR"
|
||||
find "$MOUNT_DIR" \( -name '*dnsmasq-webui*.conf' -o -name '*dnsmasq-webui*.hosts' \) -type f -delete 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR)."
|
||||
|
||||
@@ -211,7 +302,16 @@ if [ "$PREPARE_ONLY" = true ]; then
|
||||
/*) 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]"
|
||||
START_PREFIX="TESTDATA_MOUNT=$MOUNT_EXPORT DNSMASQ_VERSION=$DNSMASQ_VERSION"
|
||||
if [ "$MINIMAL_CONF" = true ]; then
|
||||
START_PREFIX="$START_PREFIX TEST_DNSMASQ_CONF=/data/dnsmasq-test-minimal.conf"
|
||||
fi
|
||||
if [ "$NO_CACHE_BUILD" = true ]; then
|
||||
START_CMD="$START_PREFIX docker compose -f $COMPOSE_FILE build --pull --no-cache && $START_PREFIX docker compose -f $COMPOSE_FILE up -d"
|
||||
else
|
||||
START_CMD="$START_PREFIX docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
fi
|
||||
echo "To start the harness: $START_CMD"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -221,8 +321,30 @@ case "$MOUNT_DIR" in
|
||||
*) export TESTDATA_MOUNT="./$MOUNT_DIR" ;;
|
||||
esac
|
||||
|
||||
export DNSMASQ_VERSION
|
||||
|
||||
case "$DNSMASQ_VERSION" in
|
||||
distro)
|
||||
echo "Using distro-packaged dnsmasq for the harness image."
|
||||
;;
|
||||
*)
|
||||
echo "Using dnsmasq $DNSMASQ_VERSION for the harness image."
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$MINIMAL_CONF" = true ]; then
|
||||
export TEST_DNSMASQ_CONF="/data/dnsmasq-test-minimal.conf"
|
||||
echo "Using minimal config: $TEST_DNSMASQ_CONF"
|
||||
fi
|
||||
|
||||
if [ "$NO_CACHE_BUILD" = true ]; then
|
||||
BUILD_CMD="docker compose -f $COMPOSE_FILE build --pull --no-cache"
|
||||
echo "Running: $BUILD_CMD"
|
||||
$BUILD_CMD
|
||||
fi
|
||||
|
||||
COMPOSE_CMD="docker compose -f $COMPOSE_FILE up -d"
|
||||
if [ "$BUILD" = true ]; then
|
||||
if [ "$BUILD" = true ] && [ "$NO_CACHE_BUILD" != true ]; then
|
||||
COMPOSE_CMD="$COMPOSE_CMD --build"
|
||||
fi
|
||||
if [ "$RECREATE" = true ]; then
|
||||
|
||||
@@ -59,14 +59,17 @@ default_rid() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Optional version override (e.g. v0.0.4 from CI tag). When set, passed to dotnet publish for assembly metadata.
|
||||
VERSION_OVERRIDE=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|-?|--help)
|
||||
echo "Usage: $0 [OPTIONS] [RID]"
|
||||
echo "Usage: $0 [OPTIONS] [RID] [VERSION]"
|
||||
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 linux-x64)."
|
||||
echo "Pass options first, then RID, then optional VERSION (e.g. v0.0.4 from a release tag)."
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --trim Enable trimming (smaller output; can cause 404/routing issues with Blazor)"
|
||||
@@ -85,7 +88,7 @@ while [ $# -gt 0 ]; do
|
||||
echo " ubuntu.24.04-x64 ubuntu.24.04-arm64"
|
||||
echo " ubuntu.22.04-x64 ubuntu.22.04-arm64"
|
||||
echo ""
|
||||
echo "Output: src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/"
|
||||
echo "Output: src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Publish for current machine (auto-detect RID)"
|
||||
@@ -112,6 +115,10 @@ while [ $# -gt 0 ]; do
|
||||
*)
|
||||
RID="$1"
|
||||
shift
|
||||
if [ $# -gt 0 ] && [ -n "$1" ] && [ "${1#-}" = "$1" ]; then
|
||||
VERSION_OVERRIDE="$1"
|
||||
shift
|
||||
fi
|
||||
break
|
||||
;;
|
||||
esac
|
||||
@@ -148,22 +155,32 @@ if [ "$CLEAN" = true ]; then
|
||||
dotnet clean "$PROJECT" -c Release -nologo -v q
|
||||
fi
|
||||
|
||||
VERSION_ARGS=""
|
||||
if [ -n "$VERSION_OVERRIDE" ]; then
|
||||
# Strip leading 'v' (e.g. v0.0.4 -> 0.0.4) for assembly metadata
|
||||
VER="${VERSION_OVERRIDE#v}"
|
||||
VERSION_ARGS="-p:Version=$VER -p:InformationalVersion=$VER"
|
||||
echo "Using version: $VER (from $VERSION_OVERRIDE)"
|
||||
fi
|
||||
|
||||
if [ "$TRIM" = true ]; then
|
||||
echo "Publishing self-contained (trimmed, folder) for $RID..."
|
||||
dotnet publish "$PROJECT" \
|
||||
-c Release \
|
||||
-r "$RID" \
|
||||
--self-contained true \
|
||||
-p:PublishTrimmed=true
|
||||
-p:PublishTrimmed=true \
|
||||
$VERSION_ARGS
|
||||
else
|
||||
echo "Publishing self-contained (no trim, folder) for $RID..."
|
||||
dotnet publish "$PROJECT" \
|
||||
-c Release \
|
||||
-r "$RID" \
|
||||
--self-contained true
|
||||
--self-contained true \
|
||||
$VERSION_ARGS
|
||||
fi
|
||||
|
||||
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net9.0/$RID/publish"
|
||||
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish"
|
||||
echo ""
|
||||
echo "Done. Output: $OUT_DIR"
|
||||
echo "Run on this host: $OUT_DIR/DnsmasqWebUI"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using DnsmasqWebUI.Components.DnsRecords.Editors;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Components.DnsRecords;
|
||||
|
||||
public class DnsRecordEditorSyncGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddMode_SameEmptyKey_ReseedsOnce()
|
||||
{
|
||||
string? k = null;
|
||||
Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null));
|
||||
Assert.False(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddMode_DifferentAddOption_ReseedsAgain()
|
||||
{
|
||||
string? k = null;
|
||||
Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null, "add:naptr-record"));
|
||||
Assert.False(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null, "add:naptr-record"));
|
||||
Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null, "add:caa-record"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditMode_UsesRowId()
|
||||
{
|
||||
var row = new DnsRecordRow(
|
||||
"cname:0",
|
||||
"cname:0",
|
||||
DnsmasqConfKeys.Cname,
|
||||
DnsRecordFamily.Cname,
|
||||
0,
|
||||
"a,b",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
new CnamePayload(["a"], "b", null),
|
||||
[],
|
||||
"s");
|
||||
string? k = null;
|
||||
Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, row));
|
||||
Assert.False(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransitionFromAddToEdit_Reseeds()
|
||||
{
|
||||
string? k = null;
|
||||
DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null);
|
||||
var row = new DnsRecordRow(
|
||||
"txt:1",
|
||||
"txt:1",
|
||||
DnsmasqConfKeys.TxtRecord,
|
||||
DnsRecordFamily.Txt,
|
||||
1,
|
||||
"x,y",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
new TxtPayload("x", "y"),
|
||||
[],
|
||||
"s");
|
||||
Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, row));
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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,184 +0,0 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfDhcpHostLineParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore]
|
||||
/// Comma-separated; ## = deleted, # = comment; trailing # comment allowed.
|
||||
/// </summary>
|
||||
public class DnsmasqConfDhcpHostLineParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseLine_NotDhcpHost_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("domain=local", 1));
|
||||
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-range=192.168.1.1,192.168.1.100", 1));
|
||||
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("# comment", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_BasicMacIpNameLease_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.False(e!.IsComment);
|
||||
Assert.False(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", e.MacAddresses[0]);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal("testpc", e.Name);
|
||||
Assert.Equal("infinite", e.Lease);
|
||||
Assert.Empty(e.Extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_CommentedLine_ParsesWithIsComment()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.False(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DeletedLine_ParsesWithIsDeleted()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.True(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TrailingComment_ParsesComment()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("main laptop", e!.Comment);
|
||||
Assert.Single(e.MacAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_MultipleMacs_SameIp_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(2, e!.MacAddresses.Count);
|
||||
Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]);
|
||||
Assert.Equal("12:34:56:78:90:12", e.MacAddresses[1]);
|
||||
Assert.Equal("192.168.0.2", e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_WithSetTag_PutsInExtra()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=AA:BB:CC:DD:CC:BB,redhost1,192.168.1.41,infinite,set:red", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.41", e!.Address);
|
||||
Assert.Equal("redhost1", e.Name);
|
||||
Assert.Contains(e.Extra, x => x == "set:red");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_HostnameOnly_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=lap,192.168.0.199", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("lap", e!.Name);
|
||||
Assert.Equal("192.168.0.199", e.Address);
|
||||
Assert.Empty(e.MacAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Ignore_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=00:20:e0:3b:13:af,ignore", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.Ignore);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("00:20:e0:3b:13:af", e.MacAddresses[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_NumericLease_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,pc,3600", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("3600", e!.Lease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_WithIdClient_PutsInExtra()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=id:01:02:03:04,192.168.1.50,myhost", 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal("192.168.1.50", e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Roundtrip_Basic()
|
||||
{
|
||||
var line = "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("dhcp-host=", back);
|
||||
Assert.Contains("aa:bb:cc:dd:ee:ff", back);
|
||||
Assert.Contains("192.168.1.10", back);
|
||||
Assert.Contains("testpc", back);
|
||||
Assert.Contains("infinite", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Roundtrip_WithComment()
|
||||
{
|
||||
var line = "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("dhcp-host=", back);
|
||||
Assert.Contains("main laptop", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_CommentedEntry_Prefix()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc", 1);
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("#dhcp-host=", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_DeletedEntry_DoubleHash()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,old", 1);
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("##dhcp-host=", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_testdata_dhcp_conf_ParsesDhcpHostLines()
|
||||
{
|
||||
var lines = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf");
|
||||
var dhcpEntries = new List<DhcpHostEntry>();
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(lines[i], i + 1);
|
||||
if (e != null)
|
||||
dhcpEntries.Add(e);
|
||||
}
|
||||
Assert.Single(dhcpEntries);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpEntries[0].MacAddresses[0]);
|
||||
Assert.Equal("172.28.0.100", dhcpEntries[0].Address);
|
||||
Assert.Equal("testpc", dhcpEntries[0].Name);
|
||||
Assert.Equal("infinite", dhcpEntries[0].Lease);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfDirectiveParser. Key/value parsing (TryParseKeyValue) and comment stripping (StripComment)
|
||||
/// used by DnsmasqConfIncludeParser to build effective config.
|
||||
/// </summary>
|
||||
public class DnsmasqConfDirectiveParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParseKeyValue_Empty_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue(""));
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_Comment_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("# comment"));
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("# addn-hosts=/etc/hosts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_KeyValue_ReturnsKeyAndValue()
|
||||
{
|
||||
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 TryParseKeyValue_KeyOnly_ReturnsEmptyValue()
|
||||
{
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("domain-needed");
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.DomainNeeded, kv!.Value.key);
|
||||
Assert.Equal("", kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_CommentedLine_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("#addn-hosts=/etc/hosts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripComment_CommentAfterSpace_StripsToEnd()
|
||||
{
|
||||
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # DNS port"));
|
||||
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # comment"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_LineWithComment_ValueExcludesComment()
|
||||
{
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53 # DNS port");
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
|
||||
Assert.Equal("53", kv.Value.value);
|
||||
}
|
||||
}
|
||||
@@ -1,600 +0,0 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfIncludeParser. Discovers conf-file= and conf-dir= from main dnsmasq config
|
||||
/// to build ordered list of included file paths and first conf-dir for managed file.
|
||||
/// </summary>
|
||||
public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath()
|
||||
{
|
||||
var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainOnly_ReturnsSinglePath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var includePath = Path.Combine(dir, "extra.conf");
|
||||
File.WriteAllText(includePath, "# extra\n");
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-file=extra.conf\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(includePath), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, "zz.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "aa.conf"), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "aa.conf")), paths[1]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "zz.conf")), paths[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_NoConfDir_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\nconf-file=other.conf\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.Null(first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var subDir = Path.Combine(baseDir, "dnsmasq.d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=dnsmasq.d\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(Path.GetFullPath(subDir), first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_CommentsAndBlanks_Ignored()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
File.WriteAllText(extra, "");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(extra), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_testdata_dnsmasq_conf_ReturnsMainOnlyWhenConfDirMissing()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq.conf");
|
||||
if (!File.Exists(mainPath))
|
||||
return; // testdata not copied
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(mainPath), paths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
if (!File.Exists(mainPath))
|
||||
return;
|
||||
var paths = new[] { mainPath };
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.GetFullPath("/data/leases"), result);
|
||||
}
|
||||
|
||||
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
if (!File.Exists(mainPath))
|
||||
return;
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
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);
|
||||
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
|
||||
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
|
||||
Assert.Single(listenAddrs);
|
||||
Assert.Equal("172.28.0.2", listenAddrs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, "dhcp-leasefile=/var/first.leases\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, "dhcp-leasefile=/var/second.leases\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(Path.GetFullPath("/var/second.leases"), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "dhcp-leasefile=subdir/leases\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "subdir", "leases")), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty()
|
||||
{
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty<string>());
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, "addn-hosts=/etc/hosts.d/first\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, "addn-hosts=/data/hosts\naddn-hosts=/data/extra.hosts\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.d/first"), result[0]);
|
||||
Assert.Equal(Path.GetFullPath("/data/hosts"), result[1]);
|
||||
Assert.Equal(Path.GetFullPath("/data/extra.hosts"), result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "addn-hosts=hosts.d/app.hosts\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
|
||||
Assert.Single(result);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "hosts.d", "app.hosts")), result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.True(result);
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "port=53\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, "cache-size=100\n");
|
||||
File.WriteAllText(f2, "cache-size=200\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal("200", value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "log-facility=/data/dnsmasq.log\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal("/data/dnsmasq.log", value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
try
|
||||
{
|
||||
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 }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal("/data/dnsmasq.log", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Relative_ResolvesAgainstDir()
|
||||
{
|
||||
var dir = Path.GetTempPath();
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath("sub/pid.pid", dir);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "sub", "pid.pid")), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Absolute_ReturnsAsIs()
|
||||
{
|
||||
var abs = Path.Combine(Path.GetTempPath(), "absolute.pid");
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(abs, "/some/dir");
|
||||
Assert.Equal(Path.GetFullPath(abs), result);
|
||||
}
|
||||
|
||||
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
|
||||
|
||||
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts=1\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "Port=53\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
|
||||
Assert.Null(value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
try
|
||||
{
|
||||
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, DnsmasqConfKeys.Port);
|
||||
Assert.Equal("54", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, "a.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "b.txt"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "c.conf"), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,*.conf\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal));
|
||||
Assert.Contains(paths, p => p.EndsWith("c.conf", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith("b.txt", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, "a.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "b.conf.bak"), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,.bak\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith("b.conf.bak", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var managedPath = Path.Combine(dir, "managed.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, "addn-hosts=/etc/hosts.a\n");
|
||||
File.WriteAllText(f2, "addn-hosts=/etc/hosts.b\naddn-hosts=/etc/hosts.c\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
|
||||
Assert.Equal(3, result.Count);
|
||||
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
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedPath = Path.Combine(dir, "zz-managed.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "addn-hosts=/other/hosts\n");
|
||||
File.WriteAllText(managedPath, "addn-hosts=/managed/hosts\n");
|
||||
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
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
try
|
||||
{
|
||||
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 }, DnsmasqConfKeys.ServerLocalKeys);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal("1.1.1.1", result[0]);
|
||||
Assert.Equal("/local/", result[1]);
|
||||
Assert.Equal("/example.com/192.168.1.1", result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
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 }, 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]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqLeasesFileLineParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated
|
||||
/// fields — expiry (epoch), MAC, IP, hostname (or *), client-id (or *).
|
||||
/// </summary>
|
||||
public class DnsmasqLeasesFileLineParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseLine_Empty_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(""));
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ValidFiveFields_Parses()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05");
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(946689575, e!.Epoch);
|
||||
Assert.Equal("00:00:00:00:00:05", e.Mac);
|
||||
Assert.Equal("192.168.1.155", e.Address);
|
||||
Assert.Equal("wdt", e.Name);
|
||||
Assert.Equal("01:00:00:00:00:00:05", e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_UnknownHostname_Asterisk()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689522 00:00:00:00:00:04 192.168.1.237 * 01:00:00:00:00:00:04");
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("*", e!.Name);
|
||||
Assert.Equal("192.168.1.237", e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_UnknownClientId_Asterisk()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689351 00:0f:b0:3a:b5:0b 192.168.1.208 colinux *");
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("colinux", e!.Name);
|
||||
Assert.Equal("*", e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_MultipleSpacesBetweenFields_Parses()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(" 946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 ");
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(946689575, e!.Epoch);
|
||||
Assert.Equal("00:00:00:00:00:05", e.Mac);
|
||||
Assert.Equal("192.168.1.155", e.Address);
|
||||
Assert.Equal("wdt", e.Name);
|
||||
Assert.Equal("01:00:00:00:00:00:05", e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TooFewFields_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155"));
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TrailingJunk_ReturnsNull()
|
||||
{
|
||||
// Parser uses .End() so extra text after fifth field fails
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 extra"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_InvalidEpoch_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("notanum 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When DHCPv6 is in use, dnsmasq writes a "duid ..." line; we must skip it (first field is not numeric).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseLine_DuidLine_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("duid 00:11:22:33:44:55"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timestamp_ConvertsEpochToDateTime()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05");
|
||||
Assert.NotNull(e);
|
||||
var dt = e!.Timestamp;
|
||||
Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_testdata_leases_ParsesAllLines()
|
||||
{
|
||||
var lines = TestDataHelper.ReadAllLines("leases");
|
||||
var entries = new List<LeaseEntry>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
if (e != null)
|
||||
entries.Add(e);
|
||||
}
|
||||
Assert.Equal(2, entries.Count);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", entries[0].Mac);
|
||||
Assert.Equal("192.168.1.10", entries[0].Address);
|
||||
Assert.Equal("testpc", entries[0].Name);
|
||||
Assert.Equal("01:02:03:04:05:06", entries[0].ClientId);
|
||||
Assert.Equal("11:22:33:44:55:66", entries[1].Mac);
|
||||
Assert.Equal("192.168.1.11", entries[1].Address);
|
||||
Assert.Equal("*", entries[1].Name);
|
||||
Assert.Equal("*", entries[1].ClientId);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
using DnsmasqWebUI.Models.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for EffectiveDnsmasqConfig, including GetLogsPath (log-facility as file path).
|
||||
/// </summary>
|
||||
public class EffectiveDnsmasqConfigTests
|
||||
{
|
||||
private static EffectiveDnsmasqConfig CreateConfig(string? logFacility = null) =>
|
||||
new(
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: Array.Empty<string>(),
|
||||
ServerLocalValues: Array.Empty<string>(),
|
||||
AddressValues: Array.Empty<string>(),
|
||||
Interfaces: Array.Empty<string>(),
|
||||
ListenAddresses: Array.Empty<string>(),
|
||||
ExceptInterfaces: Array.Empty<string>(),
|
||||
DhcpRanges: Array.Empty<string>(),
|
||||
DhcpHostLines: Array.Empty<string>(),
|
||||
DhcpOptionLines: Array.Empty<string>(),
|
||||
ResolvFiles: Array.Empty<string>(),
|
||||
ExpandHosts: false,
|
||||
BogusPriv: false,
|
||||
StrictOrder: false,
|
||||
NoResolv: false,
|
||||
DomainNeeded: false,
|
||||
NoPoll: false,
|
||||
BindInterfaces: false,
|
||||
NoNegcache: false,
|
||||
DhcpAuthoritative: false,
|
||||
LeasefileRo: false,
|
||||
DhcpLeaseFilePath: null,
|
||||
CacheSize: null,
|
||||
Port: null,
|
||||
LocalTtl: null,
|
||||
PidFilePath: null,
|
||||
User: null,
|
||||
Group: null,
|
||||
LogFacility: logFacility,
|
||||
DhcpLeaseMax: null,
|
||||
NegTtl: null,
|
||||
MaxTtl: null,
|
||||
MaxCacheTtl: null,
|
||||
MinCacheTtl: null,
|
||||
DhcpTtl: null
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_NullConfig_ReturnsNull()
|
||||
{
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_NullLogFacility_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: null);
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_EmptyLogFacility_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "");
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_WhitespaceLogFacility_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: " ");
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_AbsolutePath_ReturnsPath()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "/data/dnsmasq.log");
|
||||
Assert.Equal("/data/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_AbsolutePathWithTrim_ReturnsPath()
|
||||
{
|
||||
var config = CreateConfig(logFacility: " /var/log/dnsmasq.log ");
|
||||
Assert.Equal("/var/log/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_RelativePathWithSlash_ReturnsPath()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "logs/dnsmasq.log");
|
||||
Assert.Equal("logs/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_SyslogFacilityName_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "local0");
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_Stderr_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "-");
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Sample config line per option for consistent parser coverage. When adding new options to the app,
|
||||
/// add an entry here so the option-coverage test continues to exercise every option.
|
||||
/// </summary>
|
||||
public static class OptionCoverageData
|
||||
{
|
||||
/// <summary>Option key and a minimal valid config line (without newline).</summary>
|
||||
public static IReadOnlyList<(string OptionKey, string ConfigLine)> GetParserCoverageEntries()
|
||||
{
|
||||
return new (string, string)[]
|
||||
{
|
||||
// LastWins
|
||||
(DnsmasqConfKeys.Port, "port=53"),
|
||||
(DnsmasqConfKeys.CacheSize, "cache-size=500"),
|
||||
(DnsmasqConfKeys.LocalTtl, "local-ttl=300"),
|
||||
(DnsmasqConfKeys.MxTarget, "mx-target=mail.example.com"),
|
||||
(DnsmasqConfKeys.DhcpLeasefile, "dhcp-leasefile=/var/lib/dnsmasq.leases"),
|
||||
(DnsmasqConfKeys.DhcpLeaseMax, "dhcp-lease-max=1000"),
|
||||
(DnsmasqConfKeys.Hostsdir, "hostsdir=/etc/dnsmasq.d"),
|
||||
(DnsmasqConfKeys.PidFile, "pid-file=/run/dnsmasq.pid"),
|
||||
(DnsmasqConfKeys.User, "user=nobody"),
|
||||
(DnsmasqConfKeys.TftpRoot, "tftp-root=/var/lib/tftpboot"),
|
||||
// Flag
|
||||
(DnsmasqConfKeys.NoHosts, "no-hosts"),
|
||||
(DnsmasqConfKeys.ExpandHosts, "expand-hosts"),
|
||||
(DnsmasqConfKeys.BogusPriv, "bogus-priv"),
|
||||
(DnsmasqConfKeys.NoResolv, "no-resolv"),
|
||||
(DnsmasqConfKeys.DomainNeeded, "domain-needed"),
|
||||
(DnsmasqConfKeys.StrictOrder, "strict-order"),
|
||||
(DnsmasqConfKeys.AllServers, "all-servers"),
|
||||
(DnsmasqConfKeys.BindInterfaces, "bind-interfaces"),
|
||||
(DnsmasqConfKeys.DhcpAuthoritative, "dhcp-authoritative"),
|
||||
(DnsmasqConfKeys.EnableTftp, "enable-tftp"),
|
||||
(DnsmasqConfKeys.Dnssec, "dnssec"),
|
||||
(DnsmasqConfKeys.Conntrack, "conntrack"),
|
||||
(DnsmasqConfKeys.Do0x20Encode, "do-0x20-encode"),
|
||||
(DnsmasqConfKeys.No0x20Encode, "no-0x20-encode"),
|
||||
// Multi - resolver / DNS
|
||||
(DnsmasqConfKeys.Server, "server=1.1.1.1"),
|
||||
(DnsmasqConfKeys.Local, "local=/lan/"),
|
||||
(DnsmasqConfKeys.RevServer, "rev-server=192.168.1.0/24,10.0.0.1"),
|
||||
(DnsmasqConfKeys.Address, "address=/example.com/127.0.0.1"),
|
||||
(DnsmasqConfKeys.Domain, "domain=home.lan,192.168.1.0/24"),
|
||||
(DnsmasqConfKeys.Cname, "cname=www.example.com,example.com"),
|
||||
(DnsmasqConfKeys.MxHost, "mx-host=example.com,mail.example.com,10"),
|
||||
(DnsmasqConfKeys.Srv, "srv-host=_http._tcp.example.com,host.example.com,80"),
|
||||
(DnsmasqConfKeys.PtrRecord, "ptr-record=1.168.192.in-addr.arpa,router.lan"),
|
||||
(DnsmasqConfKeys.TxtRecord, "txt-record=example.com,text"),
|
||||
(DnsmasqConfKeys.NaptrRecord, "naptr-record=example.com,0,0,a,s,r"),
|
||||
(DnsmasqConfKeys.DnsRr, "dns-rr=example.com,16,01:02"),
|
||||
(DnsmasqConfKeys.HostRecord, "host-record=host.example.com,1.2.3.4"),
|
||||
(DnsmasqConfKeys.DynamicHost, "dynamic-host=example.com"),
|
||||
(DnsmasqConfKeys.CaaRecord, "caa-record=example.com,0,issue,letsencrypt.org"),
|
||||
(DnsmasqConfKeys.RebindDomainOk, "rebind-domain-ok=example.com"),
|
||||
(DnsmasqConfKeys.BogusNxdomain, "bogus-nxdomain=192.168.1.1"),
|
||||
(DnsmasqConfKeys.IgnoreAddress, "ignore-address=192.168.1.1"),
|
||||
(DnsmasqConfKeys.Alias, "alias=0.0.0.0,example.com"),
|
||||
(DnsmasqConfKeys.FilterRr, "filter-rr=0.0.0.0,A"),
|
||||
(DnsmasqConfKeys.CacheRr, "cache-rr=0.0.0.0,A"),
|
||||
(DnsmasqConfKeys.Ipset, "ipset=/example.com/set1"),
|
||||
(DnsmasqConfKeys.Nftset, "nftset=/example.com/table/set"),
|
||||
(DnsmasqConfKeys.ConnmarkAllowlist, "connmark-allowlist=0xff,example.com"),
|
||||
// Multi - auth / synth
|
||||
(DnsmasqConfKeys.AuthServer, "auth-server=zone.example.com,eth0"),
|
||||
(DnsmasqConfKeys.SynthDomain, "synth-domain=dynamic.example.com,192.168.2.1,192.168.2.100"),
|
||||
(DnsmasqConfKeys.AuthZone, "auth-zone=example.com"),
|
||||
(DnsmasqConfKeys.TrustAnchor, "trust-anchor=.,20326,8,2,E4F1769B8A0E3142E125223716F878F0A3B2C3D4"),
|
||||
// Multi - DHCP
|
||||
(DnsmasqConfKeys.DhcpRange, "dhcp-range=192.168.1.50,192.168.1.150,12h"),
|
||||
(DnsmasqConfKeys.DhcpHost, "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10"),
|
||||
(DnsmasqConfKeys.DhcpOption, "dhcp-option=3,192.168.1.1"),
|
||||
(DnsmasqConfKeys.DhcpOptionForce, "dhcp-option-force=option:dns-server,192.168.1.1"),
|
||||
(DnsmasqConfKeys.DhcpMatch, "dhcp-match=set:efi,option:client-arch,6"),
|
||||
(DnsmasqConfKeys.DhcpMac, "dhcp-mac=set:vendor,aa:bb:cc:dd:ee:ff"),
|
||||
(DnsmasqConfKeys.DhcpNameMatch, "dhcp-name-match=set:name,hostname*"),
|
||||
(DnsmasqConfKeys.DhcpIgnoreNames, "dhcp-ignore-names=tag:guest"),
|
||||
(DnsmasqConfKeys.DhcpBoot, "dhcp-boot=pxelinux.0"),
|
||||
(DnsmasqConfKeys.Leasequery, "leasequery=10.0.0.0/24"),
|
||||
(DnsmasqConfKeys.RaParam, "ra-param=eth0,high"),
|
||||
(DnsmasqConfKeys.Slaac, "slaac=eth0"),
|
||||
(DnsmasqConfKeys.PxeService, "pxe-service=x86PC,Install Linux"),
|
||||
(DnsmasqConfKeys.DhcpRelay, "dhcp-relay=192.168.1.1,eth0"),
|
||||
(DnsmasqConfKeys.DhcpCircuitid, "dhcp-circuitid=eth0,010203"),
|
||||
(DnsmasqConfKeys.DhcpRemoteid, "dhcp-remoteid=010203"),
|
||||
(DnsmasqConfKeys.DhcpSubscrid, "dhcp-subscrid=010203"),
|
||||
(DnsmasqConfKeys.TagIf, "tag-if=set:tag,eth0"),
|
||||
(DnsmasqConfKeys.SharedNetwork, "shared-network=name,192.168.1.0/24"),
|
||||
(DnsmasqConfKeys.DhcpOptionPxe, "dhcp-option-pxe=set:known,1"),
|
||||
(DnsmasqConfKeys.DhcpVendorclass, "dhcp-vendorclass=set:pxe,PXEClient"),
|
||||
(DnsmasqConfKeys.DhcpUserclass, "dhcp-userclass=set:efi,EFI"),
|
||||
(DnsmasqConfKeys.AddnHosts, "addn-hosts=/etc/hosts.extra"),
|
||||
(DnsmasqConfKeys.Interface, "interface=eth0"),
|
||||
(DnsmasqConfKeys.ListenAddress, "listen-address=127.0.0.1"),
|
||||
(DnsmasqConfKeys.ResolvFile, "resolv-file=/etc/resolv.dnsmasq"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
/// <summary>Expected parser/effective state for a real-world corpus case. Counts and booleans only.</summary>
|
||||
public sealed class CorpusExpected
|
||||
{
|
||||
public int? ServerCountMin { get; set; }
|
||||
public int? LocalCountMin { get; set; }
|
||||
public int? AddressCountMin { get; set; }
|
||||
public int? CacheSize { get; set; }
|
||||
public int? AddnHostsCountMin { get; set; }
|
||||
public int? DomainCountMin { get; set; }
|
||||
public bool? NoHosts { get; set; }
|
||||
public bool? NoResolv { get; set; }
|
||||
public bool? BogusPriv { get; set; }
|
||||
public string? Do0x20State { get; set; }
|
||||
public int? Port { get; set; }
|
||||
public bool? ExpandHosts { get; set; }
|
||||
public int? CnameCountMin { get; set; }
|
||||
public int? TxtRecordCountMin { get; set; }
|
||||
public int? SrvCountMin { get; set; }
|
||||
public int? PtrRecordCountMin { get; set; }
|
||||
public int? MxHostCountMin { get; set; }
|
||||
public int? AuthZoneCountMin { get; set; }
|
||||
public int? SynthDomainCountMin { get; set; }
|
||||
public int? AuthServerCountMin { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Single real-world corpus case from dnsmasq-real-cases.json.</summary>
|
||||
public sealed class CorpusCase
|
||||
{
|
||||
public string File { get; set; } = "";
|
||||
public CorpusExpected Expected { get; set; } = new();
|
||||
public List<string> SemanticInvalidOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Loads real-world corpus index and resolves paths via TestDataHelper.</summary>
|
||||
public static class RealWorldCasesHelper
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public static IReadOnlyList<CorpusCase> LoadCases()
|
||||
{
|
||||
var path = TestDataHelper.GetPath("real-world/dnsmasq-real-cases.json");
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<List<CorpusCase>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
|
||||
public static string Resolve(CorpusCase c) => TestDataHelper.GetPath(c.File);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
/// <summary>Ensures corpus index and files stay in sync; fails fast when testdata drifts.</summary>
|
||||
public class RealWorldCorpusIntegrityTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryCorpusCase_PointsToExistingFile()
|
||||
{
|
||||
var cases = RealWorldCasesHelper.LoadCases();
|
||||
Assert.NotEmpty(cases);
|
||||
foreach (var c in cases)
|
||||
{
|
||||
var path = RealWorldCasesHelper.Resolve(c);
|
||||
Assert.True(File.Exists(path), $"Corpus file missing: {c.File}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryBadCase_DeclaresSemanticInvalidOptions()
|
||||
{
|
||||
var cases = RealWorldCasesHelper.LoadCases();
|
||||
var badFiles = new[] { "bad/", "real-world/bad/" };
|
||||
foreach (var c in cases)
|
||||
{
|
||||
var isBad = badFiles.Any(p => c.File.Contains(p, StringComparison.Ordinal));
|
||||
if (isBad)
|
||||
Assert.NotEmpty(c.SemanticInvalidOptions);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SemanticInvalidOptions_ArePresentInParsedValues()
|
||||
{
|
||||
foreach (var c in RealWorldCasesHelper.LoadCases())
|
||||
{
|
||||
if (c.SemanticInvalidOptions.Count == 0)
|
||||
continue;
|
||||
var mainPath = RealWorldCasesHelper.Resolve(c);
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
|
||||
foreach (var option in c.SemanticInvalidOptions)
|
||||
{
|
||||
var values = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, option);
|
||||
Assert.NotEmpty(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
namespace DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves paths to testdata files. Files are copied to output (testdata/) when the project builds.
|
||||
@@ -0,0 +1,43 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.Config;
|
||||
|
||||
public sealed class DnsmasqManagedPathSetTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromOptions_WithoutManagedFilesDirectory_UsesMainConfigDirectory()
|
||||
{
|
||||
var options = new DnsmasqOptions
|
||||
{
|
||||
MainConfigPath = "/etc/dnsmasq.conf",
|
||||
ManagedFileName = "zz-webui.conf",
|
||||
ManagedHostsFileName = "zz-webui.hosts"
|
||||
};
|
||||
|
||||
var paths = DnsmasqManagedPathSet.FromOptions(options);
|
||||
|
||||
Assert.Equal("/etc/dnsmasq.conf", paths.MainConfigPath);
|
||||
Assert.Equal("/etc", paths.ManagedFilesDirectory);
|
||||
Assert.Equal("/etc/zz-webui.conf", paths.ManagedFilePath);
|
||||
Assert.Equal("/etc/zz-webui.hosts", paths.ManagedHostsFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromOptions_WithManagedFilesDirectory_UsesOverrideDirectory()
|
||||
{
|
||||
var options = new DnsmasqOptions
|
||||
{
|
||||
MainConfigPath = "/etc/dnsmasq.conf",
|
||||
ManagedFilesDirectory = "/srv/repo/dnsmasq",
|
||||
ManagedFileName = "zz-webui.conf",
|
||||
ManagedHostsFileName = "zz-webui.hosts"
|
||||
};
|
||||
|
||||
var paths = DnsmasqManagedPathSet.FromOptions(options);
|
||||
|
||||
Assert.Equal("/etc/dnsmasq.conf", paths.MainConfigPath);
|
||||
Assert.Equal("/srv/repo/dnsmasq", paths.ManagedFilesDirectory);
|
||||
Assert.Equal("/srv/repo/dnsmasq/zz-webui.conf", paths.ManagedFilePath);
|
||||
Assert.Equal("/srv/repo/dnsmasq/zz-webui.hosts", paths.ManagedHostsFilePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.Config;
|
||||
|
||||
public sealed class DnsmasqOptionsValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_WithRelativeManagedFilesDirectory_Fails()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-validator-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
|
||||
try
|
||||
{
|
||||
var validator = new DnsmasqOptionsValidator();
|
||||
var options = new DnsmasqOptions
|
||||
{
|
||||
MainConfigPath = mainPath,
|
||||
ManagedFilesDirectory = "repo/dnsmasq"
|
||||
};
|
||||
|
||||
var result = validator.Validate(null, options);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.NotNull(result.Failures);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("ManagedFilesDirectory", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithManagedFilesDirectoryAndFileNames_Succeeds()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-validator-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
|
||||
try
|
||||
{
|
||||
var validator = new DnsmasqOptionsValidator();
|
||||
var options = new DnsmasqOptions
|
||||
{
|
||||
MainConfigPath = mainPath,
|
||||
ManagedFilesDirectory = Path.Combine(dir, "repo"),
|
||||
ManagedFileName = "zz-webui.conf",
|
||||
ManagedHostsFileName = "zz-webui.hosts"
|
||||
};
|
||||
|
||||
var result = validator.Validate(null, options);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.Dhcp;
|
||||
|
||||
public sealed class DhcpManagedHostSelectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Single_removal_shifts_higher_indices()
|
||||
{
|
||||
var s = new HashSet<int> { 0, 2, 4 };
|
||||
DhcpManagedHostSelection.RemapAfterRemovals(s, new[] { 1 });
|
||||
Assert.Equal(new[] { 0, 1, 3 }, s.Order().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bulk_removal_removes_and_shifts()
|
||||
{
|
||||
var s = new HashSet<int> { 0, 2, 4 };
|
||||
DhcpManagedHostSelection.RemapAfterRemovals(s, new[] { 1, 3, 5 });
|
||||
Assert.Equal(new[] { 0, 1, 2 }, s.Order().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Removing_edited_index_drops_it_from_selection()
|
||||
{
|
||||
var s = new HashSet<int> { 1, 3 };
|
||||
DhcpManagedHostSelection.RemapAfterRemovals(s, new[] { 1 });
|
||||
Assert.Equal(new[] { 2 }, s.Order().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_removed_is_noop()
|
||||
{
|
||||
var s = new HashSet<int> { 1, 2 };
|
||||
DhcpManagedHostSelection.RemapAfterRemovals(s, Array.Empty<int>());
|
||||
Assert.Equal(new[] { 1, 2 }, s.Order().ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Linq;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.Dhcp;
|
||||
|
||||
public class DhcpOptionDraftTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromRaw_WithPrefixes_RoundTrips()
|
||||
{
|
||||
const string raw = "tag:guest,option:router,192.168.1.1";
|
||||
var d = DhcpOptionDraft.FromRaw(raw);
|
||||
Assert.True(d.IsStructured);
|
||||
Assert.Equal("option:router", d.Selector);
|
||||
Assert.Equal(raw, d.ToRaw());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preset_Router_HasExpectedSelector()
|
||||
{
|
||||
var p = DhcpOptionPresets.All.First(x => x.Key == "router");
|
||||
Assert.Equal("option:router", p.Selector);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRaw_WithTagPrefix_PreservesOrder()
|
||||
{
|
||||
var d = new DhcpOptionDraft
|
||||
{
|
||||
PrefixTokens = new[] { "tag:guest" },
|
||||
Selector = "option:dns-server",
|
||||
ValueTokens = new List<string> { "8.8.8.8", "8.8.4.4" }
|
||||
};
|
||||
const string expected = "tag:guest,option:dns-server,8.8.8.8,8.8.4.4";
|
||||
Assert.Equal(expected, d.ToRaw());
|
||||
var roundTrip = DhcpOptionDraft.FromRaw(expected);
|
||||
Assert.True(roundTrip.IsStructured);
|
||||
Assert.Equal(expected, roundTrip.ToRaw());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.Dhcp;
|
||||
|
||||
public class DhcpRangeDraftTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromRaw_Structured_RoundTrips()
|
||||
{
|
||||
const string raw = "tag:guest,192.168.1.50,192.168.1.150,12h";
|
||||
var d = DhcpRangeDraft.FromRaw(raw);
|
||||
Assert.True(d.IsStructured);
|
||||
Assert.Equal(raw, d.ToRaw());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromRaw_Unparseable_UsesFallback()
|
||||
{
|
||||
const string raw = "not-a-valid-range";
|
||||
var d = DhcpRangeDraft.FromRaw(raw);
|
||||
Assert.False(d.IsStructured);
|
||||
Assert.Equal(raw, d.ToRaw());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromRaw_ConstructorIpv4Range_IsStructured()
|
||||
{
|
||||
const string raw = "192.168.1.50,192.168.1.150,255.255.255.0,12h";
|
||||
var d = DhcpRangeDraft.FromRaw(raw);
|
||||
Assert.True(d.IsStructured);
|
||||
Assert.Equal(raw, d.ToRaw());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.DnsRecords;
|
||||
|
||||
public sealed class DnsRecordsFileFilterBuilderTests
|
||||
{
|
||||
private static DnsRecordRow Row(
|
||||
string id,
|
||||
string sourcePath,
|
||||
bool isEditable,
|
||||
bool isDraftOnly = false) =>
|
||||
new(
|
||||
Id: id,
|
||||
OccurrenceId: id,
|
||||
OptionName: "cname",
|
||||
Family: DnsRecordFamily.Cname,
|
||||
IndexInOption: 0,
|
||||
RawValue: "alias,target",
|
||||
Source: null,
|
||||
SourcePath: sourcePath,
|
||||
SourceLabel: Path.GetFileName(sourcePath),
|
||||
IsDraftOnly: isDraftOnly,
|
||||
IsEditable: isEditable,
|
||||
Payload: new CnamePayload(["alias"], "target", null),
|
||||
Issues: [],
|
||||
Summary: "alias -> target");
|
||||
|
||||
[Fact]
|
||||
public void Build_ClassifiesOnlyManagedConfigPathAsManaged()
|
||||
{
|
||||
var managed = "/etc/dnsmasq-webui/managed.conf";
|
||||
var rows = new[]
|
||||
{
|
||||
Row("managed", managed, isEditable: true),
|
||||
Row("draft-other", "/tmp/custom-writable.conf", isEditable: true, isDraftOnly: true)
|
||||
};
|
||||
|
||||
var builder = new DnsRecordsFileFilterBuilder(rows, managed);
|
||||
var model = builder.Build();
|
||||
|
||||
Assert.Equal(2, model.Sections.Count);
|
||||
Assert.Equal("managed", model.Sections[0].Kind);
|
||||
Assert.Equal(managed, model.Sections[0].Options[0].Value);
|
||||
Assert.Equal("other", model.Sections[1].Kind);
|
||||
Assert.Equal("/tmp/custom-writable.conf", model.Sections[1].Options[0].Value);
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Editing;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
|
||||
|
||||
public class EffectiveConfigMultiDisplayContextTests
|
||||
{
|
||||
private static EffectiveMultiValueConfigFieldDescriptor DomainDescriptor(
|
||||
IReadOnlyList<ValueWithSource> baseline) =>
|
||||
new(
|
||||
EffectiveConfigSections.SectionHosts,
|
||||
DnsmasqConfKeys.Domain,
|
||||
Status: null,
|
||||
ResolveItems: _ => baseline,
|
||||
Behavior: new DefaultMultiValueEditBehavior(),
|
||||
Validator: null);
|
||||
|
||||
private static ConfigValueSource ReadonlySource() =>
|
||||
new("/etc/dnsmasq.conf", "dnsmasq.conf", IsManaged: false, LineNumber: 1);
|
||||
|
||||
[Fact]
|
||||
public void From_BaselineOnly_UsesBaselineItems()
|
||||
{
|
||||
var baseline = new List<ValueWithSource>
|
||||
{
|
||||
new("a.example", null),
|
||||
new("b.example", null)
|
||||
};
|
||||
var d = DomainDescriptor(baseline);
|
||||
|
||||
var ctx = EffectiveConfigMultiDisplayContext.From(
|
||||
d, baseline, pendingList: null, hasPendingChange: false, draftList: null,
|
||||
isEditMode: false, isActive: false, fieldKey: "k");
|
||||
|
||||
Assert.Same(baseline, ctx.BaselineItems);
|
||||
Assert.Equal(["a.example", "b.example"], ctx.EffectiveValues);
|
||||
Assert.False(ctx.HasPendingChange);
|
||||
Assert.False(ctx.HasDraftChange);
|
||||
Assert.False(ctx.HasPendingOrDraft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_PendingOverridesBaseline_PreservesSourcesWhereValuesMatch()
|
||||
{
|
||||
var ro = ReadonlySource();
|
||||
var baseline = new List<ValueWithSource>
|
||||
{
|
||||
new("a.example", ro),
|
||||
new("b.example", null)
|
||||
};
|
||||
var d = DomainDescriptor(baseline);
|
||||
var pending = new List<string> { "a.example", "c.example" };
|
||||
|
||||
var ctx = EffectiveConfigMultiDisplayContext.From(
|
||||
d, baseline, pending, hasPendingChange: true, draftList: null,
|
||||
isEditMode: false, isActive: false, fieldKey: "k");
|
||||
|
||||
Assert.Equal(["a.example", "c.example"], ctx.EffectiveValues);
|
||||
Assert.True(ctx.HasPendingChange);
|
||||
Assert.Equal(ro, ctx.EffectiveItems[0].Source);
|
||||
Assert.Null(ctx.EffectiveItems[1].Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_DraftOverridesPending()
|
||||
{
|
||||
var baseline = new List<ValueWithSource> { new("a.example", null) };
|
||||
var d = DomainDescriptor(baseline);
|
||||
var pending = new List<string> { "b.example" };
|
||||
var draft = new List<string> { "c.example" };
|
||||
|
||||
var ctx = EffectiveContext(d, baseline, pending, draft);
|
||||
|
||||
Assert.Equal(["c.example"], ctx.EffectiveValues);
|
||||
Assert.True(ctx.HasDraftChange);
|
||||
Assert.True(ctx.HasPendingOrDraft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_AllReadonlyNotEditMode_SetsRowSourceFromFirstItem()
|
||||
{
|
||||
var ro = ReadonlySource();
|
||||
var baseline = new List<ValueWithSource>
|
||||
{
|
||||
new("a.example", ro)
|
||||
};
|
||||
var d = DomainDescriptor(baseline);
|
||||
|
||||
var ctx = EffectiveConfigMultiDisplayContext.From(
|
||||
d, baseline, null, false, null,
|
||||
isEditMode: false, isActive: false, fieldKey: "k");
|
||||
|
||||
Assert.Equal(ro, ctx.RowSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_EditModeOrMixedWritable_ClearsRowSource()
|
||||
{
|
||||
var ro = ReadonlySource();
|
||||
var baseline = new List<ValueWithSource>
|
||||
{
|
||||
new("a.example", ro)
|
||||
};
|
||||
var d = DomainDescriptor(baseline);
|
||||
|
||||
var ctxEdit = EffectiveConfigMultiDisplayContext.From(
|
||||
d, baseline, null, false, null,
|
||||
isEditMode: true, isActive: false, fieldKey: "k");
|
||||
|
||||
Assert.Null(ctxEdit.RowSource);
|
||||
|
||||
var mixed = new List<ValueWithSource>
|
||||
{
|
||||
new("a.example", ro),
|
||||
new("b.example", null)
|
||||
};
|
||||
var d2 = DomainDescriptor(mixed);
|
||||
var ctxMixed = EffectiveConfigMultiDisplayContext.From(
|
||||
d2, mixed, null, false, null,
|
||||
isEditMode: false, isActive: false, fieldKey: "k");
|
||||
|
||||
Assert.Null(ctxMixed.RowSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void From_IsActiveEditor_RespectsCapabilityDisabled()
|
||||
{
|
||||
var baseline = new List<ValueWithSource> { new("a.example", null) };
|
||||
var d = DomainDescriptor(baseline) with { IsCapabilityDisabled = true };
|
||||
|
||||
var ctx = EffectiveConfigMultiDisplayContext.From(
|
||||
d, baseline, null, false, null,
|
||||
isEditMode: true, isActive: true, fieldKey: "k");
|
||||
|
||||
Assert.False(ctx.IsActiveEditor);
|
||||
Assert.False(ctx.ShowEditableBadge);
|
||||
}
|
||||
|
||||
private static EffectiveConfigMultiDisplayContext EffectiveContext(
|
||||
EffectiveMultiValueConfigFieldDescriptor d,
|
||||
IReadOnlyList<ValueWithSource> baseline,
|
||||
IReadOnlyList<string>? pending,
|
||||
IReadOnlyList<string>? draft) =>
|
||||
EffectiveConfigMultiDisplayContext.From(
|
||||
d, baseline, pending, pending != null, draft,
|
||||
isEditMode: false, isActive: false, fieldKey: "k");
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
|
||||
|
||||
public class ManagedHostsPendingChangeDiffTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_same_entries_returns_empty()
|
||||
{
|
||||
var e = new HostEntry { Id = "a", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "h" } };
|
||||
var oldL = new List<HostEntry> { e };
|
||||
var newL = new List<HostEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = e.Id,
|
||||
LineNumber = e.LineNumber,
|
||||
Address = e.Address,
|
||||
Names = new List<string>(e.Names)
|
||||
}
|
||||
};
|
||||
Assert.Empty(ManagedHostsPendingChangeDiff.Build(oldL, newL));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_added_entry()
|
||||
{
|
||||
var oldL = new List<HostEntry>();
|
||||
var newL = new List<HostEntry>
|
||||
{
|
||||
new() { Id = "new:x", LineNumber = 1, Address = "192.168.0.1", Names = new List<string> { "router" } }
|
||||
};
|
||||
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
|
||||
var a = Assert.Single(d);
|
||||
Assert.Equal(ManagedHostsLineDiffKind.Added, a.Kind);
|
||||
Assert.Contains("192.168.0.1", a.AfterLine, StringComparison.Ordinal);
|
||||
Assert.Contains("router", a.AfterLine, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_removed_entry()
|
||||
{
|
||||
var oldL = new List<HostEntry>
|
||||
{
|
||||
new() { Id = "1", LineNumber = 1, Address = "10.0.0.2", Names = new List<string> { "x" } }
|
||||
};
|
||||
var newL = new List<HostEntry>();
|
||||
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
|
||||
var r = Assert.Single(d);
|
||||
Assert.Equal(ManagedHostsLineDiffKind.Removed, r.Kind);
|
||||
Assert.Contains("10.0.0.2", r.BeforeLine, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_modified_address()
|
||||
{
|
||||
var oldL = new List<HostEntry>
|
||||
{
|
||||
new() { Id = "k", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "h" } }
|
||||
};
|
||||
var newL = new List<HostEntry>
|
||||
{
|
||||
new() { Id = "k", LineNumber = 1, Address = "10.0.0.9", Names = new List<string> { "h" } }
|
||||
};
|
||||
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
|
||||
var m = Assert.Single(d);
|
||||
Assert.Equal(ManagedHostsLineDiffKind.Modified, m.Kind);
|
||||
Assert.Contains("10.0.0.1", m.BeforeLine, StringComparison.Ordinal);
|
||||
Assert.Contains("10.0.0.9", m.AfterLine, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_order_only()
|
||||
{
|
||||
var oldL = new List<HostEntry>
|
||||
{
|
||||
new() { Id = "a", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "a" } },
|
||||
new() { Id = "b", LineNumber = 2, Address = "10.0.0.2", Names = new List<string> { "b" } }
|
||||
};
|
||||
var newL = new List<HostEntry>
|
||||
{
|
||||
new() { Id = "b", LineNumber = 1, Address = "10.0.0.2", Names = new List<string> { "b" } },
|
||||
new() { Id = "a", LineNumber = 2, Address = "10.0.0.1", Names = new List<string> { "a" } }
|
||||
};
|
||||
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
|
||||
var o = Assert.Single(d);
|
||||
Assert.Equal(ManagedHostsLineDiffKind.OrderOnly, o.Kind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
|
||||
|
||||
public class PendingManagedHostsChangeTests
|
||||
{
|
||||
[Fact]
|
||||
public void HasChanges_WhenEntriesDiffer_ReturnsTrue()
|
||||
{
|
||||
var oldEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string> { "host1" } } };
|
||||
var newEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.2", Names = new List<string> { "host1" } } };
|
||||
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
|
||||
Assert.True(pending.HasChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasChanges_WhenEntriesSame_ReturnsFalse()
|
||||
{
|
||||
var entries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string> { "host1" } } };
|
||||
var clone = entries.Select(e => new HostEntry { Id = e.Id, Address = e.Address, Names = new List<string>(e.Names) }).ToList();
|
||||
var pending = new PendingManagedHostsChange(entries, clone, "/etc/hosts");
|
||||
Assert.False(pending.HasChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasChanges_WhenCountDiffers_ReturnsTrue()
|
||||
{
|
||||
var oldEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string>() } };
|
||||
var newEntries = new List<HostEntry>
|
||||
{
|
||||
new() { Id = "1", Address = "192.168.1.1", Names = new List<string>() },
|
||||
new() { Id = "2", Address = "192.168.1.2", Names = new List<string>() }
|
||||
};
|
||||
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
|
||||
Assert.True(pending.HasChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasChanges_WhenInlineCommentDiffers_ReturnsTrue()
|
||||
{
|
||||
var oldEntries = new List<HostEntry>
|
||||
{
|
||||
new() { Id = "1", Address = "10.0.0.1", Names = new List<string> { "a" }, InlineComment = "old" }
|
||||
};
|
||||
var newEntries = new List<HostEntry>
|
||||
{
|
||||
new() { Id = "1", Address = "10.0.0.1", Names = new List<string> { "a" }, InlineComment = "new" }
|
||||
};
|
||||
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
|
||||
Assert.True(pending.HasChanges);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.Hosts;
|
||||
|
||||
public class HostsEffectiveNamesDomainRulesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Expand_UsesMatchingScopedDomainForIpv4()
|
||||
{
|
||||
var names = new[] { "router" };
|
||||
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
|
||||
|
||||
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.20.1.9");
|
||||
|
||||
Assert.Equal(new[] { "router", "router.corp.lan" }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_FallsBackToDefaultDomainWhenNoScopedMatch()
|
||||
{
|
||||
var names = new[] { "cache" };
|
||||
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
|
||||
|
||||
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.99.1.9");
|
||||
|
||||
Assert.Equal(new[] { "cache", "cache.home.lan" }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_UsesLastParsedScopedRuleWhenRangesOverlap()
|
||||
{
|
||||
var names = new[] { "app" };
|
||||
var domains = new[]
|
||||
{
|
||||
"home.lan",
|
||||
"corp-wide.lan,10.0.0.0/8",
|
||||
"corp-subnet.lan,10.20.0.0/16"
|
||||
};
|
||||
|
||||
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.20.1.5");
|
||||
|
||||
Assert.Equal(new[] { "app", "app.corp-subnet.lan" }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_UsesDefaultDomainForIpv6Address()
|
||||
{
|
||||
var names = new[] { "node" };
|
||||
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
|
||||
|
||||
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "2001:db8::1");
|
||||
|
||||
Assert.Equal(new[] { "node", "node.home.lan" }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplainSuffixSource_ScopedRuleMentionsRawLine()
|
||||
{
|
||||
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
|
||||
var msg = HostsEffectiveNames.ExplainSuffixSource(
|
||||
expandHosts: true,
|
||||
domains,
|
||||
"10.20.1.9",
|
||||
new[] { "router" });
|
||||
|
||||
Assert.Equal("Suffix from scoped rule: corp.lan,10.20.0.0/16", msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplainSuffixSource_DefaultDomainWhenNoScopedMatch()
|
||||
{
|
||||
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
|
||||
var msg = HostsEffectiveNames.ExplainSuffixSource(
|
||||
expandHosts: true,
|
||||
domains,
|
||||
"10.99.1.9",
|
||||
new[] { "cache" });
|
||||
|
||||
Assert.Equal("Suffix from default rule: home.lan", msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplainSuffixSource_ExpandHostsOff()
|
||||
{
|
||||
var msg = HostsEffectiveNames.ExplainSuffixSource(
|
||||
expandHosts: false,
|
||||
new[] { "home.lan" },
|
||||
"10.0.0.1",
|
||||
new[] { "a" });
|
||||
|
||||
Assert.Contains("expand-hosts is off", msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.Hosts;
|
||||
|
||||
public class HostsEntryEditValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Empty_is_invalid()
|
||||
{
|
||||
var e = HostsEntryEditValidator.GetErrors("", Array.Empty<string>());
|
||||
Assert.NotEmpty(e);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Valid_ipv4_and_name_ok()
|
||||
{
|
||||
Assert.True(HostsEntryEditValidator.IsValid("192.168.1.1", new[] { "router" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_names_invalid()
|
||||
{
|
||||
var e = HostsEntryEditValidator.GetErrors("10.0.0.1", Array.Empty<string>());
|
||||
Assert.Contains(e, x => x.Contains("hostname", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Field_errors_split_address_and_names()
|
||||
{
|
||||
Assert.NotEmpty(HostsEntryEditValidator.GetAddressFieldErrors(""));
|
||||
Assert.Empty(HostsEntryEditValidator.GetHostnameFieldErrors(new[] { "h" }));
|
||||
Assert.NotEmpty(HostsEntryEditValidator.GetHostnameFieldErrors(Array.Empty<string>()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using DnsmasqWebUI.Models.Ui;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Models.Hosts;
|
||||
|
||||
public sealed class HostsFileFilterBuilderTests
|
||||
{
|
||||
private static HostsPageRow Row(HostsRowSourceKind kind, string path, bool isComment = false) =>
|
||||
new(
|
||||
Id: "id",
|
||||
SourceKind: kind,
|
||||
SourcePath: path,
|
||||
IsEditable: false,
|
||||
IsActive: true,
|
||||
InactiveReason: null,
|
||||
Address: "127.0.0.1",
|
||||
Names: [],
|
||||
EffectiveNames: [],
|
||||
IsComment: isComment,
|
||||
LineNumber: 1);
|
||||
|
||||
[Fact]
|
||||
public void Build_InjectsManagedPath_WhenNotInRows()
|
||||
{
|
||||
var managed = "/data/managed.hosts";
|
||||
var builder = new HostsFileFilterBuilder([], managed);
|
||||
var model = builder.Build();
|
||||
|
||||
Assert.NotNull(model.AllOption);
|
||||
var managedSection = model.Sections.Single();
|
||||
Assert.Equal("Managed hosts", managedSection.Label);
|
||||
Assert.Equal("managed", managedSection.Kind);
|
||||
Assert.Single(managedSection.Options);
|
||||
Assert.Equal(managed, managedSection.Options[0].Value);
|
||||
Assert.Equal(0, managedSection.Options[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_OrdersGroupsManagedSystemAddn_AndCountsNonComments()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row(HostsRowSourceKind.SystemHosts, "/etc/hosts"),
|
||||
Row(HostsRowSourceKind.Managed, "/m/a.hosts"),
|
||||
Row(HostsRowSourceKind.Managed, "/m/a.hosts", isComment: true),
|
||||
Row(HostsRowSourceKind.AddnHosts, "/extra/b.hosts"),
|
||||
};
|
||||
|
||||
var builder = new HostsFileFilterBuilder(rows, managedHostsPath: null);
|
||||
var model = builder.Build();
|
||||
|
||||
Assert.Equal(3, model.Sections.Count);
|
||||
Assert.Equal("managed", model.Sections[0].Kind);
|
||||
Assert.Equal("system", model.Sections[1].Kind);
|
||||
Assert.Equal("addn", model.Sections[2].Kind);
|
||||
|
||||
Assert.Single(model.Sections[0].Options);
|
||||
Assert.Equal(1, model.Sections[0].Options[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummaryText_AllFiles_ShowsRecordCount()
|
||||
{
|
||||
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/x.hosts") };
|
||||
var builder = new HostsFileFilterBuilder(rows, null);
|
||||
Assert.Equal("All files · 1 records", builder.GetSummaryText(""));
|
||||
Assert.Equal("All files · 1 records", builder.GetSummaryText(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummaryText_SelectedFile_UsesShortLabelAndPrefix()
|
||||
{
|
||||
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/my.hosts") };
|
||||
var builder = new HostsFileFilterBuilder(rows, null);
|
||||
Assert.Equal("Managed · my.hosts (1)", builder.GetSummaryText("/m/my.hosts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummaryText_UnknownPath_FallsBackToShortLabel()
|
||||
{
|
||||
var builder = new HostsFileFilterBuilder([], null);
|
||||
Assert.Equal("orphan.txt", builder.GetSummaryText("/tmp/orphan.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTriggerSummary_SingleSource_SplitsPrefixPrimaryAndCount()
|
||||
{
|
||||
var rows = new[] { Row(HostsRowSourceKind.AddnHosts, "/etc/extra.d/hosts") };
|
||||
var builder = new HostsFileFilterBuilder(rows, null);
|
||||
var s = builder.GetTriggerSummary("/etc/extra.d/hosts");
|
||||
|
||||
Assert.Equal(GroupedSelectTriggerSummaryKind.SingleSource, s.Kind);
|
||||
Assert.Equal("Additional", s.CategoryPrefix);
|
||||
Assert.Equal("hosts", s.PrimaryLabel);
|
||||
Assert.Equal("1", s.SecondaryMeta);
|
||||
Assert.Equal("Additional · hosts (1)", s.AccessibleFullText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTriggerSummary_AllSources_WithRecords_HasSecondaryMeta()
|
||||
{
|
||||
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/x.hosts") };
|
||||
var builder = new HostsFileFilterBuilder(rows, null);
|
||||
var s = builder.GetTriggerSummary("");
|
||||
|
||||
Assert.Equal(GroupedSelectTriggerSummaryKind.AllSources, s.Kind);
|
||||
Assert.Null(s.CategoryPrefix);
|
||||
Assert.Equal("All files", s.PrimaryLabel);
|
||||
Assert.Equal("1 records", s.SecondaryMeta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using DnsmasqWebUI.Infrastructure.Serialization;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization;
|
||||
|
||||
public class DnsmasqConfTextTests
|
||||
{
|
||||
[Fact]
|
||||
public void DirectivePrefix_ReturnsOptionEquals()
|
||||
{
|
||||
Assert.Equal("dhcp-host=", DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.DhcpHost));
|
||||
Assert.Equal("server=", DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.Server));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectiveLine_WhenValueNullOrEmpty_ReturnsOptionOnly()
|
||||
{
|
||||
Assert.Equal("dhcp-host", DnsmasqConfText.DirectiveLine("dhcp-host", null));
|
||||
Assert.Equal("dhcp-host", DnsmasqConfText.DirectiveLine("dhcp-host", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectiveLine_WhenValueProvided_ReturnsOptionEqualsValue()
|
||||
{
|
||||
Assert.Equal("dhcp-host=11:22:33:44:55:66,192.168.1.1", DnsmasqConfText.DirectiveLine("dhcp-host", "11:22:33:44:55:66,192.168.1.1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripDirectivePrefix_WhenLineStartsWithPrefix_ReturnsRest()
|
||||
{
|
||||
var line = "dhcp-host=mac,ip";
|
||||
Assert.Equal("mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripDirectivePrefix_WhenLineDoesNotStartWithPrefix_ReturnsFullLine()
|
||||
{
|
||||
var line = "other=value";
|
||||
Assert.Equal("other=value", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripDirectivePrefix_WhenLineIsCommented_PreservesLeadingHashInResult()
|
||||
{
|
||||
Assert.Equal("#mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, "#dhcp-host=mac,ip"));
|
||||
Assert.Equal("##mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, "##dhcp-host=mac,ip"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasDirectivePrefix_MatchesOptionAndCommentedForms()
|
||||
{
|
||||
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "dhcp-host=value"));
|
||||
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "#dhcp-host=value"));
|
||||
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "##dhcp-host=value"));
|
||||
Assert.False(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "server=8.8.8.8"));
|
||||
Assert.False(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "other"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using DnsmasqWebUI.Infrastructure.Serialization;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization;
|
||||
|
||||
public class EffectiveConfigDirectiveSerializerTests
|
||||
{
|
||||
private readonly EffectiveConfigDirectiveSerializer _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void SerializeSingle_Flag_True_WritesBareKey()
|
||||
{
|
||||
var result = _sut.SerializeSingle(DnsmasqConfKeys.Conntrack, true);
|
||||
Assert.Equal(DnsmasqConfKeys.Conntrack, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeSingle_Flag_False_WritesEmpty()
|
||||
{
|
||||
var result = _sut.SerializeSingle(DnsmasqConfKeys.Conntrack, false);
|
||||
Assert.Equal("", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeSingle_SingleValue_WritesKeyEqualsValue()
|
||||
{
|
||||
var result = _sut.SerializeSingle(DnsmasqConfKeys.Port, 5353);
|
||||
Assert.Equal("port=5353", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeSingle_KeyOnlyOrValue_Empty_WritesBareKey()
|
||||
{
|
||||
var result = _sut.SerializeSingle(DnsmasqConfKeys.UseStaleCache, "");
|
||||
Assert.Equal(DnsmasqConfKeys.UseStaleCache, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeMulti_MultiValue_WritesEachDirective()
|
||||
{
|
||||
var result = _sut.SerializeMulti(DnsmasqConfKeys.Server, ["1.1.1.1", "8.8.8.8"]);
|
||||
Assert.Equal(["server=1.1.1.1", "server=8.8.8.8"], result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeMulti_MultiKeyOnlyOrValue_WritesMixedForms()
|
||||
{
|
||||
var result = _sut.SerializeMulti(DnsmasqConfKeys.Leasequery, ["", "net:tag"]);
|
||||
Assert.Equal(["leasequery", "leasequery=net:tag"], result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeSingle_InversePair_Enabled_WritesPositiveKey()
|
||||
{
|
||||
var result = _sut.SerializeSingle(DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Enabled);
|
||||
Assert.Equal(DnsmasqConfKeys.Do0x20Encode, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeSingle_InversePair_Disabled_WritesNegativeKey()
|
||||
{
|
||||
var result = _sut.SerializeSingle(DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Disabled);
|
||||
Assert.Equal(DnsmasqConfKeys.No0x20Encode, result);
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.OptionHandlers;
|
||||
|
||||
public class DhcpHostOptionValueHandlerTests
|
||||
{
|
||||
private readonly DhcpHostOptionValueHandler _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void OptionName_EqualsDnsmasqConfKeysDhcpHost()
|
||||
{
|
||||
Assert.Equal(DnsmasqConfKeys.DhcpHost, _sut.OptionName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeValue_EmitsValueOnlyForm()
|
||||
{
|
||||
var entry = new DhcpHostEntry
|
||||
{
|
||||
MacAddresses = new List<string> { "11:22:33:44:55:66" },
|
||||
Address = "192.168.1.10"
|
||||
};
|
||||
var value = _sut.SerializeValue(entry);
|
||||
Assert.Equal("11:22:33:44:55:66, 192.168.1.10", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeValue_IgnoreEntry_EmitsIgnoreToken()
|
||||
{
|
||||
var entry = new DhcpHostEntry
|
||||
{
|
||||
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
|
||||
Ignore = true
|
||||
};
|
||||
var value = _sut.SerializeValue(entry);
|
||||
Assert.Contains("ignore", value);
|
||||
Assert.Contains("aa:bb:cc:dd:ee:ff", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeValue_EmptyEntry_ReturnsEmptyString()
|
||||
{
|
||||
var entry = new DhcpHostEntry();
|
||||
var value = _sut.SerializeValue(entry);
|
||||
Assert.Equal("", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseValue_RoundTripsStructuredFields()
|
||||
{
|
||||
var input = new DhcpHostEntry
|
||||
{
|
||||
MacAddresses = new List<string> { "11:22:33:44:55:66" },
|
||||
Address = "192.168.1.10",
|
||||
Extra = new List<string> { "id:01:02:03", "set:test" },
|
||||
Comment = "note",
|
||||
IsComment = true
|
||||
};
|
||||
var value = _sut.SerializeValue(input);
|
||||
var ok = _sut.TryParseValue(value, 1, out var output);
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(output);
|
||||
Assert.True(output!.IsComment);
|
||||
Assert.Contains("id:01:02:03", output.Extra!);
|
||||
Assert.Equal("note", output.Comment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseValue_IsComment_RoundTrips()
|
||||
{
|
||||
var entry = new DhcpHostEntry
|
||||
{
|
||||
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
|
||||
Address = "192.168.1.1",
|
||||
IsComment = true
|
||||
};
|
||||
var value = _sut.SerializeValue(entry);
|
||||
Assert.StartsWith("#", value);
|
||||
var ok = _sut.TryParseValue(value, 1, out var roundTripped);
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(roundTripped);
|
||||
Assert.True(roundTripped!.IsComment);
|
||||
Assert.Single(roundTripped.MacAddresses!);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", roundTripped.MacAddresses[0]);
|
||||
Assert.Equal("192.168.1.1", roundTripped.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseValue_Comment_EmitsTrailingComment_AndRoundTrips()
|
||||
{
|
||||
var entry = new DhcpHostEntry
|
||||
{
|
||||
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
|
||||
Address = "192.168.1.1",
|
||||
Comment = " my note "
|
||||
};
|
||||
var value = _sut.SerializeValue(entry);
|
||||
Assert.Contains(" # ", value);
|
||||
var ok = _sut.TryParseValue(value, 1, out var roundTripped);
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(roundTripped);
|
||||
Assert.Equal("my note", roundTripped!.Comment?.Trim());
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
using System.Reflection;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.OptionHandlers;
|
||||
|
||||
/// <summary>Ensures structured handlers use DnsmasqConfKeys and registry has no duplicate option names.</summary>
|
||||
public class StructuredOptionValueHandlerRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void DhcpHostOptionValueHandler_OptionName_EqualsDnsmasqConfKeysDhcpHost()
|
||||
{
|
||||
var handler = new DhcpHostOptionValueHandler();
|
||||
Assert.Equal(DnsmasqConfKeys.DhcpHost, handler.OptionName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DhcpHostOptionValueHandler_OptionName_IsFromDnsmasqConfKeys()
|
||||
{
|
||||
var handler = new DhcpHostOptionValueHandler();
|
||||
var validKeys = GetDnsmasqConfKeysValues();
|
||||
Assert.Contains(handler.OptionName, validKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_WithDhcpHostHandler_ReturnsHandlerForDhcpHost()
|
||||
{
|
||||
var handler = new DhcpHostOptionValueHandler();
|
||||
var registry = new StructuredOptionValueHandlerRegistry(new[] { handler });
|
||||
var resolved = registry.Get<DhcpHostEntry>(DnsmasqConfKeys.DhcpHost);
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal(DnsmasqConfKeys.DhcpHost, resolved.OptionName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_IsStructured_WhenHandlerExists_ReturnsTrue()
|
||||
{
|
||||
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
|
||||
Assert.True(registry.IsStructured(DnsmasqConfKeys.DhcpHost));
|
||||
Assert.False(registry.IsStructured(DnsmasqConfKeys.Server));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_GetRequired_WhenHandlerExists_ReturnsTypedHandler()
|
||||
{
|
||||
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
|
||||
var handler = registry.GetRequired<DhcpHostEntry>(DnsmasqConfKeys.DhcpHost);
|
||||
Assert.Equal(DnsmasqConfKeys.DhcpHost, handler.OptionName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_GetRequired_WhenOptionIsNotStructured_Throws()
|
||||
{
|
||||
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registry.GetRequired<DhcpHostEntry>(DnsmasqConfKeys.Server));
|
||||
Assert.Contains("not declared as a structured option", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_DuplicateOptionNames_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
new StructuredOptionValueHandlerRegistry([
|
||||
new DhcpHostOptionValueHandler(),
|
||||
new DuplicateDhcpHostHandler()
|
||||
]));
|
||||
Assert.Contains("Duplicate structured option handlers", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Semantics_DhcpHost_HasStructuredValueTypeMatchingHandler()
|
||||
{
|
||||
var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(DnsmasqConfKeys.DhcpHost);
|
||||
Assert.NotNull(structuredType);
|
||||
Assert.Equal(typeof(DhcpHostEntry), structuredType);
|
||||
var handler = new DhcpHostOptionValueHandler();
|
||||
Assert.Equal(structuredType, handler.ValueType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_HandlerWithoutStructuredMetadata_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
new StructuredOptionValueHandlerRegistry([new InvalidServerHandler()]));
|
||||
Assert.Contains("no StructuredValueType", ex.Message);
|
||||
}
|
||||
|
||||
private static HashSet<string> GetDnsmasqConfKeysValues()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var field in typeof(DnsmasqConfKeys).GetFields(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (field.FieldType == typeof(string) && field.GetValue(null) is string value)
|
||||
set.Add(value);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private sealed class DuplicateDhcpHostHandler : IStructuredOptionValueHandler<DhcpHostEntry>
|
||||
{
|
||||
public string OptionName => DnsmasqConfKeys.DhcpHost;
|
||||
public Type ValueType => typeof(DhcpHostEntry);
|
||||
public string SerializeLine(DhcpHostEntry value) => "";
|
||||
public string SerializeValue(DhcpHostEntry value) => "";
|
||||
public bool TryParseValue(string text, int lineNumber, out DhcpHostEntry? value)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InvalidServerHandler : IStructuredOptionValueHandler<string>
|
||||
{
|
||||
public string OptionName => DnsmasqConfKeys.Server;
|
||||
public Type ValueType => typeof(string);
|
||||
public string SerializeLine(string value) => "";
|
||||
public string SerializeValue(string value) => value;
|
||||
public bool TryParseValue(string text, int lineNumber, out string? value)
|
||||
{
|
||||
value = text;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.Dnsmasq;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for parsing "Compile time options: ..." from dnsmasq --version output.
|
||||
/// </summary>
|
||||
public class DnsmasqCompileOptionsParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_WhenNoCompileLine_ReturnsAllCapabilitiesFalse()
|
||||
{
|
||||
var result = DnsmasqCompileOptionsParser.Parse("Dnsmasq version 2.89\nCopyright ...", null);
|
||||
Assert.False(result.Dhcp);
|
||||
Assert.False(result.Tftp);
|
||||
Assert.False(result.Dnssec);
|
||||
Assert.False(result.Dbus);
|
||||
Assert.NotNull(result.RawTokens);
|
||||
Assert.Empty(result.RawTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WhenCompileLineHasDnssec_SetsDnssecTrue()
|
||||
{
|
||||
var stdout = "Dnsmasq version 2.89\nCompile time options: IPv6 GNU-getopt no-DBus no-Idn DHCP DHCPv6 TFTP DNSSEC auth";
|
||||
var result = DnsmasqCompileOptionsParser.Parse(stdout, null);
|
||||
Assert.True(result.Dnssec);
|
||||
Assert.True(result.Dhcp);
|
||||
Assert.True(result.Tftp);
|
||||
Assert.False(result.Dbus);
|
||||
Assert.Contains("DNSSEC", result.RawTokens, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WhenCompileLineHasDhcpOnly_SetsDhcpTrueOthersFalse()
|
||||
{
|
||||
var result = DnsmasqCompileOptionsParser.Parse(null, "Compile time options: DHCP no-DBus no-DNSSEC");
|
||||
Assert.True(result.Dhcp);
|
||||
Assert.False(result.Tftp);
|
||||
Assert.False(result.Dnssec);
|
||||
Assert.False(result.Dbus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WhenCompileLineHasDhcpv6_SetsDhcpTrue()
|
||||
{
|
||||
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: DHCPv6", null);
|
||||
Assert.True(result.Dhcp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WhenCompileLineHasTftp_SetsTftpTrue()
|
||||
{
|
||||
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: TFTP", null);
|
||||
Assert.True(result.Tftp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WhenCompileLineHasDbus_SetsDbusTrue()
|
||||
{
|
||||
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: DBus", null);
|
||||
Assert.True(result.Dbus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CombinesStdoutAndStderr()
|
||||
{
|
||||
var result = DnsmasqCompileOptionsParser.Parse("Version line\n", "Compile time options: DNSSEC");
|
||||
Assert.True(result.Dnssec);
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
using DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfDhcpHostLineParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore]
|
||||
/// Comma-separated; ## = deleted, # = comment; trailing # comment allowed.
|
||||
/// </summary>
|
||||
public class DnsmasqConfDhcpHostLineParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseLine_NotDhcpHost_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("domain=local", 1));
|
||||
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-range=192.168.1.1,192.168.1.100", 1));
|
||||
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("# comment", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_BasicMacIpNameLease_Parses()
|
||||
{
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.False(e!.IsComment);
|
||||
Assert.False(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal(mac, e.MacAddresses[0]);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Equal(lease, e.Lease);
|
||||
Assert.Empty(e.Extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_CommentedLine_ParsesWithIsComment()
|
||||
{
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
|
||||
var line = $"#dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.False(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DeletedLine_ParsesWithIsDeleted()
|
||||
{
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "oldpc";
|
||||
|
||||
var line = $"##dhcp-host={mac},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.True(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TrailingComment_ParsesComment()
|
||||
{
|
||||
string mac = "11:22:33:44:55:66";
|
||||
string address = "192.168.1.11";
|
||||
string name = "laptop";
|
||||
string lease = "infinite";
|
||||
string comment = "main laptop";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease} # {comment}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(comment, e!.Comment);
|
||||
Assert.Single(e.MacAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_MultipleMacs_SameIp_Parses()
|
||||
{
|
||||
string mac1 = "11:22:33:44:55:66";
|
||||
string mac2 = "12:34:56:78:90:12";
|
||||
string address = "192.168.0.2";
|
||||
|
||||
var line = $"dhcp-host={mac1},{mac2},{address}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(2, e!.MacAddresses.Count);
|
||||
Assert.Equal(mac1, e.MacAddresses[0]);
|
||||
Assert.Equal(mac2, e.MacAddresses[1]);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_WithSetTag_PutsInExtra()
|
||||
{
|
||||
string mac = "AA:BB:CC:DD:CC:BB";
|
||||
string name = "redhost1";
|
||||
string address = "192.168.1.41";
|
||||
string lease = "infinite";
|
||||
string extraTag = "set:red";
|
||||
|
||||
var line = $"dhcp-host={mac},{name},{address},{lease},{extraTag}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(address, e!.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Contains(e.Extra, x => x == extraTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_HostnameOnly_Parses()
|
||||
{
|
||||
string name = "lap";
|
||||
string address = "192.168.0.199";
|
||||
|
||||
var line = $"dhcp-host={name},{address}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(name, e!.Name);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Empty(e.MacAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Ignore_Parses()
|
||||
{
|
||||
string mac = "00:20:e0:3b:13:af";
|
||||
|
||||
var line = $"dhcp-host={mac},ignore";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.Ignore);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal(mac, e.MacAddresses[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_NumericLease_Parses()
|
||||
{
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "pc";
|
||||
string lease = "3600";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(lease, e!.Lease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_WithIdClient_PutsInExtra()
|
||||
{
|
||||
string idPart = "id:01:02:03:04";
|
||||
string address = "192.168.1.50";
|
||||
string name = "myhost";
|
||||
|
||||
var line = $"dhcp-host={idPart},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Roundtrip_Basic()
|
||||
{
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("dhcp-host=", back);
|
||||
Assert.Contains(mac, back);
|
||||
Assert.Contains(address, back);
|
||||
Assert.Contains(name, back);
|
||||
Assert.Contains(lease, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Roundtrip_WithComment()
|
||||
{
|
||||
string mac = "11:22:33:44:55:66";
|
||||
string address = "192.168.1.11";
|
||||
string name = "laptop";
|
||||
string lease = "infinite";
|
||||
string comment = "main laptop";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease} # {comment}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("dhcp-host=", back);
|
||||
Assert.Contains(comment, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_CommentedEntry_Prefix()
|
||||
{
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
|
||||
var line = $"#dhcp-host={mac},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("#dhcp-host=", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_DeletedEntry_DoubleHash()
|
||||
{
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "old";
|
||||
|
||||
var line = $"##dhcp-host={mac},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("##dhcp-host=", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_testdata_dhcp_conf_ParsesDhcpHostLines()
|
||||
{
|
||||
var lines = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf");
|
||||
var dhcpEntries = new List<DhcpHostEntry>();
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(lines[i], i + 1);
|
||||
if (e != null)
|
||||
dhcpEntries.Add(e);
|
||||
}
|
||||
Assert.Single(dhcpEntries);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpEntries[0].MacAddresses[0]);
|
||||
Assert.Equal("172.28.0.100", dhcpEntries[0].Address);
|
||||
Assert.Equal("testpc", dhcpEntries[0].Name);
|
||||
Assert.Equal("infinite", dhcpEntries[0].Lease);
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfDirectiveParser. Key/value parsing (TryParseKeyValue) and comment stripping (StripComment)
|
||||
/// used by DnsmasqConfIncludeParser to build effective config.
|
||||
/// </summary>
|
||||
public class DnsmasqConfDirectiveParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParseKeyValue_Empty_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue(""));
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_Comment_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("# comment"));
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("# addn-hosts=/etc/hosts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_KeyValue_ReturnsKeyAndValue()
|
||||
{
|
||||
var value = "53";
|
||||
var line = $"port={value}";
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
|
||||
Assert.Equal(value, kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_KeyOnly_ReturnsEmptyValue()
|
||||
{
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("domain-needed");
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.DomainNeeded, kv!.Value.key);
|
||||
Assert.Equal("", kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_CommentedLine_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("#addn-hosts=/etc/hosts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripComment_CommentAfterSpace_StripsToEnd()
|
||||
{
|
||||
var before = "port=53";
|
||||
var comment1 = " DNS port";
|
||||
var comment2 = " comment";
|
||||
Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment1));
|
||||
Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_LineWithComment_ValueExcludesComment()
|
||||
{
|
||||
var value = "53";
|
||||
var comment = " DNS port";
|
||||
var line = $"port={value} #{comment}";
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
|
||||
Assert.Equal(value, kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_ValueContainsEquals_ParsesFirstEqualsAsKeyValueSeparator()
|
||||
{
|
||||
var line = "server=/local/1.2.3.4";
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal("server", kv!.Value.key);
|
||||
Assert.Equal("/local/1.2.3.4", kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_ErrorOverload_InvalidLine_ReturnsFalseAndSetsError()
|
||||
{
|
||||
// KeyValueLine requires at least one key character before '='; "=no-key" has no key and fails.
|
||||
var ok = DnsmasqConfDirectiveParser.TryParseKeyValue("=no-key", out var kv, out var error, out var errorPosition);
|
||||
Assert.False(ok);
|
||||
Assert.NotNull(error);
|
||||
Assert.True(errorPosition.Absolute >= 0);
|
||||
}
|
||||
}
|
||||
+68
-35
@@ -1,8 +1,10 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
using DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfFileLineParser. Parses dnsmasq config file lines into Blank, Comment, AddnHosts, DhcpHost, Other.
|
||||
@@ -19,73 +21,89 @@ public class DnsmasqConfFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseFile_BlankLine_BlankKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([""]);
|
||||
var raw = "";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
|
||||
Assert.Single(lines);
|
||||
var blank = Assert.IsType<BlankLine>(lines[0]);
|
||||
Assert.Equal(1, blank.LineNumber);
|
||||
Assert.Equal("", blank.RawLine);
|
||||
Assert.Equal(raw, blank.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_BlankLineWithSpaces_BlankKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([" \t "]);
|
||||
var raw = " \t ";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
|
||||
Assert.Single(lines);
|
||||
var blank = Assert.IsType<BlankLine>(lines[0]);
|
||||
Assert.Equal(" \t ", blank.RawLine);
|
||||
Assert.Equal(raw, blank.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_CommentLine_CommentKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["# Not managed by this app."]);
|
||||
var commentLine = "# Not managed by this app.";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([commentLine]);
|
||||
Assert.Single(lines);
|
||||
var comment = Assert.IsType<CommentLine>(lines[0]);
|
||||
Assert.Equal(1, comment.LineNumber);
|
||||
Assert.Equal("# Not managed by this app.", comment.RawLine);
|
||||
Assert.Equal(commentLine, comment.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_OtherDirective_OtherKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["domain=local"]);
|
||||
var raw = "domain=local";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
|
||||
Assert.Single(lines);
|
||||
var other = Assert.IsType<OtherLine>(lines[0]);
|
||||
Assert.Equal("domain=local", other.RawLine);
|
||||
Assert.Equal(raw, other.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_AddnHostsLine_AddnHostsKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["addn-hosts=/var/lib/dnsmasq/hosts"]);
|
||||
var path = "/var/lib/dnsmasq/hosts";
|
||||
var line = $"addn-hosts={path}";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
|
||||
Assert.Single(lines);
|
||||
var addn = Assert.IsType<AddnHostsLine>(lines[0]);
|
||||
Assert.Equal("/var/lib/dnsmasq/hosts", addn.AddnHostsPath);
|
||||
Assert.Equal(path, addn.AddnHostsPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_AddnHosts_EmitsAddnHostsLine()
|
||||
{
|
||||
var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = "/etc/hosts" };
|
||||
Assert.Equal("addn-hosts=/etc/hosts", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var path = "/etc/hosts";
|
||||
var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = path };
|
||||
Assert.Equal($"addn-hosts={path}", DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_DhcpHostLine_DhcpHostKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"]);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
|
||||
Assert.Single(lines);
|
||||
var dhcpLine = Assert.IsType<DhcpHostLine>(lines[0]);
|
||||
Assert.Single(dhcpLine.DhcpHost.MacAddresses);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]);
|
||||
Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address);
|
||||
Assert.Equal(mac, dhcpLine.DhcpHost.MacAddresses[0]);
|
||||
Assert.Equal(address, dhcpLine.DhcpHost.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_CommentedDhcpHost_TreatedAsComment()
|
||||
{
|
||||
// Parser treats any line starting with # as Comment (does not parse dhcp-host content)
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc"]);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "oldpc";
|
||||
var line = $"#dhcp-host={mac},{address},{name}";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
|
||||
Assert.Single(lines);
|
||||
Assert.Equal(DnsmasqConfLineKind.Comment, lines[0].Kind);
|
||||
}
|
||||
@@ -93,13 +111,21 @@ public class DnsmasqConfFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseFile_MixedFile_ParsesAll()
|
||||
{
|
||||
var domainLine = "domain=local";
|
||||
string dhcpMac = "11:22:33:44:55:66";
|
||||
string dhcpAddress = "192.168.1.11";
|
||||
string dhcpName = "laptop";
|
||||
string dhcpLease = "infinite";
|
||||
var rangeLine = "dhcp-range=192.168.1.100,192.168.1.200";
|
||||
var dhcpLine = $"dhcp-host={dhcpMac},{dhcpAddress},{dhcpName},{dhcpLease}";
|
||||
|
||||
var input = new[]
|
||||
{
|
||||
"# Sample config",
|
||||
"",
|
||||
"domain=local",
|
||||
"dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite",
|
||||
"dhcp-range=192.168.1.100,192.168.1.200"
|
||||
domainLine,
|
||||
dhcpLine,
|
||||
rangeLine
|
||||
};
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(input);
|
||||
Assert.Equal(5, lines.Count);
|
||||
@@ -107,48 +133,55 @@ public class DnsmasqConfFileLineParserTests
|
||||
Assert.IsType<CommentLine>(lines[0]);
|
||||
Assert.IsType<BlankLine>(lines[1]);
|
||||
var other2 = Assert.IsType<OtherLine>(lines[2]);
|
||||
Assert.Equal("domain=local", other2.RawLine);
|
||||
Assert.Equal(domainLine, other2.RawLine);
|
||||
var dhcp3 = Assert.IsType<DhcpHostLine>(lines[3]);
|
||||
Assert.Equal("192.168.1.11", dhcp3.DhcpHost.Address);
|
||||
Assert.Equal(dhcpAddress, dhcp3.DhcpHost.Address);
|
||||
var other4 = Assert.IsType<OtherLine>(lines[4]);
|
||||
Assert.Equal("dhcp-range=192.168.1.100,192.168.1.200", other4.RawLine);
|
||||
Assert.Equal(rangeLine, other4.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Blank_PreservesRawLine()
|
||||
{
|
||||
var line = new BlankLine { LineNumber = 1, RawLine = " " };
|
||||
Assert.Equal(" ", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var raw = " ";
|
||||
var line = new BlankLine { LineNumber = 1, RawLine = raw };
|
||||
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Comment_PreservesRawLine()
|
||||
{
|
||||
var line = new CommentLine { LineNumber = 1, RawLine = "# comment" };
|
||||
Assert.Equal("# comment", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var raw = "# comment";
|
||||
var line = new CommentLine { LineNumber = 1, RawLine = raw };
|
||||
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Other_PreservesRawLine()
|
||||
{
|
||||
var line = new OtherLine { LineNumber = 1, RawLine = "domain=local" };
|
||||
Assert.Equal("domain=local", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var raw = "domain=local";
|
||||
var line = new OtherLine { LineNumber = 1, RawLine = raw };
|
||||
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_DhcpHost_SerializesViaDhcpHostLineParser()
|
||||
{
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
var dhcp = new DhcpHostEntry
|
||||
{
|
||||
LineNumber = 1,
|
||||
MacAddresses = ["aa:bb:cc:dd:ee:ff"],
|
||||
Address = "192.168.1.10",
|
||||
Name = "testpc",
|
||||
Lease = "infinite"
|
||||
MacAddresses = [mac],
|
||||
Address = address,
|
||||
Name = name,
|
||||
Lease = lease
|
||||
};
|
||||
var line = new DhcpHostLine { LineNumber = 1, DhcpHost = dhcp };
|
||||
var back = DnsmasqConfFileLineParser.ToLine(line);
|
||||
Assert.Equal("dhcp-host=aa:bb:cc:dd:ee:ff, testpc, 192.168.1.10, infinite", back);
|
||||
Assert.Equal($"dhcp-host={mac}, {name}, {address}, {lease}", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
|
||||
/// <summary>Parser semantics: flags (option present/absent/with value), last-wins, case-sensitive, path order.</summary>
|
||||
public class DnsmasqConfIncludeParserCoreBehaviourTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.True(result);
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "port=53\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var firstVal = "100";
|
||||
var secondVal = "200";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"cache-size={firstVal}\n");
|
||||
File.WriteAllText(f2, $"cache-size={secondVal}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal(secondVal, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var logPath = "/data/dnsmasq.log";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"log-facility={logPath}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal(logPath, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var firstLogPath = "/var/log/dnsmasq.log";
|
||||
var secondLogPath = "/data/dnsmasq.log";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"log-facility={firstLogPath}\n");
|
||||
File.WriteAllText(f2, $"log-facility={secondLogPath}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal(secondLogPath, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Relative_ResolvesAgainstDir()
|
||||
{
|
||||
var dir = Path.GetTempPath();
|
||||
var subDirName = "sub";
|
||||
var fileName = "pid.pid";
|
||||
var relativePath = $"{subDirName}/{fileName}";
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(relativePath, dir);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subDirName, fileName)), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Absolute_ReturnsAsIs()
|
||||
{
|
||||
var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid");
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir");
|
||||
Assert.Equal(Path.GetFullPath(absPath), result);
|
||||
}
|
||||
|
||||
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
|
||||
|
||||
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var invalidValue = "1";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"expand-hosts={invalidValue}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var wrongKey = "Port";
|
||||
var value = "53";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"{wrongKey}={value}\n");
|
||||
var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
|
||||
Assert.Null(parsedValue);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
var port1 = "53";
|
||||
var port2 = "5353";
|
||||
var port3 = "54";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n");
|
||||
File.WriteAllText(extra, $"port={port3}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.Equal(port3, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- pathToLines overloads: same result as file-based when content is pre-read ---
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var lines = new[] { "expand-hosts", "no-resolv" };
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, string.Join("\n", lines) + "\n");
|
||||
var paths = new[] { conf };
|
||||
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[Path.GetFullPath(conf)] = lines
|
||||
};
|
||||
var fromFile = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts);
|
||||
var fromDict = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.Equal(fromFile, fromDict);
|
||||
Assert.True(fromDict);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-last-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var value = "1000";
|
||||
var lines = new[] { $"cache-size={value}" };
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, lines[0] + "\n");
|
||||
var paths = new[] { conf };
|
||||
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[Path.GetFullPath(conf)] = lines
|
||||
};
|
||||
var (fromFileVal, fromFileDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
|
||||
var (fromDictVal, fromDictDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal(fromFileVal, fromDictVal);
|
||||
Assert.Equal(value, fromDictVal);
|
||||
Assert.Equal(fromFileDir, fromDictDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-multi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var lines = new[] { "server=1.1.1.1", "server=8.8.8.8" };
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, string.Join("\n", lines) + "\n");
|
||||
var paths = new[] { conf };
|
||||
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[Path.GetFullPath(conf)] = lines
|
||||
};
|
||||
var fromFile = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var fromDict = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Server);
|
||||
Assert.Equal(fromFile, fromDict);
|
||||
Assert.Equal(2, fromDict.Count);
|
||||
Assert.Equal("1.1.1.1", fromDict[0]);
|
||||
Assert.Equal("8.8.8.8", fromDict[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Empty path list ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EmptyPathList_ReturnsNullAndNull()
|
||||
{
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(Array.Empty<string>(), DnsmasqConfKeys.Port);
|
||||
Assert.Null(value);
|
||||
Assert.Null(configDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_EmptyPathList_ReturnsEmptyList()
|
||||
{
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(Array.Empty<string>(), DnsmasqConfKeys.Server);
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
// --- Non-existent file in path list: skipped without throw, last-wins from existing ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_NonExistentFileInList_SkipsMissingFileLastWinsFromExisting()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-missing-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var existing = Path.Combine(dir, "existing.conf");
|
||||
var missing = Path.Combine(dir, "missing.conf");
|
||||
var other = Path.Combine(dir, "other.conf");
|
||||
var valueInExisting = "100";
|
||||
var valueInOther = "200";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(existing, $"port={valueInExisting}\n");
|
||||
File.WriteAllText(other, $"port={valueInOther}\n");
|
||||
Assert.False(File.Exists(missing));
|
||||
var paths = new[] { existing, missing, other };
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.Equal(valueInOther, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
|
||||
/// <summary>DHCP advanced, TFTP/PXE, DNSSEC, enable-dbus/ubus, fast-dns-retry.</summary>
|
||||
public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
|
||||
{
|
||||
// --- One test per option: key wired, when set returns value(s) ---
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpMatch_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpmatch-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:bios,option:client-arch,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-match={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpMatch);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpBoot_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpboot-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "tag:bios-x86,firmware/ipxe.pxe,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-boot={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpBoot);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpIgnore_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpignore-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "tag:!known";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-ignore={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpIgnore);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpVendorclass_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpvc-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:pxe,option:vendor-class-identifier,PXEClient";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-vendorclass={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpVendorclass);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpUserclass_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpuc-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:ipxe,iPXE";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-userclass={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpUserclass);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_RaParam_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-raparam-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "eth0,high,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ra-param={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RaParam);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Slaac_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-slaac-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "eth0,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"slaac={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Slaac);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_EnableTftp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftp-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "enable-tftp\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableTftp);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_TftpRoot_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftproot-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/var/lib/tftpboot";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"tftp-root={path}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TftpRoot);
|
||||
Assert.Equal(path, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_TftpSecure_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftpsec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "tftp-secure\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TftpSecure);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_PxeService_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pxe-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "x86PC,pxelinux.0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"pxe-service={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PxeService);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_PxePrompt_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pxeprompt-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "timeout,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"pxe-prompt={line}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PxePrompt);
|
||||
Assert.Equal(line, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Dnssec_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnssec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dnssec\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Dnssec);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_TrustAnchor_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-trust-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = ". IN DS 19036 8 2 49AAC11D7B6F6446702E54A1607371607A1A41855200FD2CE1CDE32F024E8FC5";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"trust-anchor={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TrustAnchor);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dnssec-check-unsigned\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
Assert.NotNull(value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DnssecCheckUnsigned_WhenEqualsNo_ReturnsNo()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-no-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dnssec-check-unsigned=no\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
Assert.Equal("no", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EnableDbus_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dbus-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var svc = "org.freedesktop.NetworkManager";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"enable-dbus={svc}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableDbus);
|
||||
Assert.Equal(svc, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EnableUbus_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ubus-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var svc = "com.example.dnsmasq";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"enable-ubus={svc}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableUbus);
|
||||
Assert.Equal(svc, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_FastDnsRetry_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-fastretry-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "500,5000";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"fast-dns-retry={line}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FastDnsRetry);
|
||||
Assert.Equal(line, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
|
||||
/// <summary>Boolean flags: no-hosts, bogus-priv, strict-order, domain-needed, no-poll, bind-*, rebind-*, clear-on-reload, filter*, localise-queries, log-debug, dhcp-authoritative, leasefile-ro.</summary>
|
||||
public class DnsmasqConfIncludeParserFlagsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_BogusPriv_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-boguspriv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "bogus-priv\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BogusPriv));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_StrictOrder_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-strict-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "strict-order\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.StrictOrder));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DomainNeeded_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-domainneed-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "domain-needed\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DomainNeeded));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoPoll_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nopoll-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-poll\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoPoll));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_BindInterfaces_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-bindif-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "bind-interfaces\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BindInterfaces));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_BindDynamic_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-binddyn-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "bind-dynamic\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BindDynamic));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoNegcache_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noneg-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-negcache\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoNegcache));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DnsLoopDetect_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-loop-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dns-loop-detect\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnsLoopDetect));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_StopDnsRebind_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebind-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "stop-dns-rebind\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.StopDnsRebind));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_RebindLocalhostOk_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebindlo-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "rebind-localhost-ok\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RebindLocalhostOk));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ClearOnReload_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-clear-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "clear-on-reload\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ClearOnReload));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Filterwin2k_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filtw2k-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "filterwin2k\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Filterwin2k));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FilterA_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filta-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "filter-A\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterA));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FilterAaaa_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filtaaaa-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "filter-AAAA\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterAaaa));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LocaliseQueries_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localise-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "localise-queries\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocaliseQueries));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LogDebug_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logdbg-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "log-debug\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogDebug));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpAuthoritative_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpauth-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-authoritative\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpAuthoritative));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
using DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
|
||||
/// <summary>Hosts (addn-hosts, hostsdir) and DHCP lease file parsing.</summary>
|
||||
public class DnsmasqConfIncludeParserHostsAndLeaseTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
|
||||
var paths = new[] { mainPath };
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.GetFullPath("/data/leases"), result);
|
||||
}
|
||||
|
||||
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
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);
|
||||
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
|
||||
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
|
||||
Assert.Single(listenAddrs);
|
||||
Assert.Equal("172.28.0.2", listenAddrs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var firstLeasePath = "/var/first.leases";
|
||||
var secondLeasePath = "/var/second.leases";
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, $"dhcp-leasefile={firstLeasePath}\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(Path.GetFullPath(secondLeasePath), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var subdir = "subdir";
|
||||
var leasesFile = "leases";
|
||||
var relativePath = $"{subdir}/{leasesFile}";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"dhcp-leasefile={relativePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty()
|
||||
{
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty<string>());
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var path1 = "/etc/hosts.d/first";
|
||||
var path2 = "/data/hosts";
|
||||
var path3 = "/data/extra.hosts";
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, $"addn-hosts={path1}\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, $"addn-hosts={path2}\naddn-hosts={path3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath(path1), result[0]);
|
||||
Assert.Equal(Path.GetFullPath(path2), result[1]);
|
||||
Assert.Equal(Path.GetFullPath(path3), result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var pathPart1 = "hosts.d";
|
||||
var pathPart2 = "app.hosts";
|
||||
var addnRelativePath = $"{pathPart1}/{pathPart2}";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"addn-hosts={addnRelativePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
|
||||
Assert.Single(result);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, pathPart1, pathPart2)), result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var managedPath = Path.Combine(dir, "managed.conf");
|
||||
var path1 = "/etc/hosts.a";
|
||||
var path2 = "/etc/hosts.b";
|
||||
var path3 = "/etc/hosts.c";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"addn-hosts={path1}\n");
|
||||
File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath(path1), 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(path2), result[1].Path);
|
||||
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath(path3), result[2].Path);
|
||||
Assert.Equal(2, result[2].Source.LineNumber);
|
||||
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedPath = Path.Combine(dir, "zz-managed.conf");
|
||||
var readOnlyPath = "/other/hosts";
|
||||
var managedAddnPath = "/managed/hosts";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n");
|
||||
File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n");
|
||||
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
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
|
||||
/// <summary>Config discovery: conf-file=, conf-dir=, GetIncludedPaths, GetFirstConfDir, GetIncludedPathsWithSource, conf-dir suffix.</summary>
|
||||
public class DnsmasqConfIncludeParserIncludeTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath()
|
||||
{
|
||||
var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainOnly_ReturnsSinglePath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var includeFileName = "extra.conf";
|
||||
var includePath = Path.Combine(dir, includeFileName);
|
||||
File.WriteAllText(includePath, "# extra\n");
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-file={includeFileName}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(includePath), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDirName = "d";
|
||||
var subDir = Path.Combine(baseDir, confDirName);
|
||||
Directory.CreateDirectory(subDir);
|
||||
var file1 = "aa.conf";
|
||||
var file2 = "zz.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, file2), "");
|
||||
File.WriteAllText(Path.Combine(subDir, file1), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-dir={confDirName}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_NoConfDir_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\nconf-file=other.conf\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.Null(first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDirName = "dnsmasq.d";
|
||||
var subDir = Path.Combine(baseDir, confDirName);
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-dir={confDirName}\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(Path.GetFullPath(subDir), first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_CommentsAndBlanks_Ignored()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
File.WriteAllText(extra, "");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(extra), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDirMissing_ReturnsMainOnly()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-missing-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=nonexistent-subdir\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirrors harness layout: main + conf-dir with multiple files (including lan-dns.conf) + conf-file. Asserts all appear in load order with correct sources.</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPathsWithSource_ConfDirAndConfFile_ReturnsAllFilesInLoadOrderWithSources()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-harness-layout-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDir = Path.Combine(baseDir, "dnsmasq.d");
|
||||
Directory.CreateDirectory(confDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(confDir, "01-other.conf"), "# other\n");
|
||||
File.WriteAllText(Path.Combine(confDir, "02-servers.conf"), "# servers\n");
|
||||
File.WriteAllText(Path.Combine(confDir, "lan-dns.conf"), "# lan dns\n");
|
||||
File.WriteAllText(Path.Combine(baseDir, "zz-dnsmasq-webui.conf"), "# managed\n");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=dnsmasq.d\nconf-file=zz-dnsmasq-webui.conf\n");
|
||||
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(main);
|
||||
Assert.Equal(5, withSource.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), withSource[0].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.Main, withSource[0].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "01-other.conf")), withSource[1].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[1].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "02-servers.conf")), withSource[2].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[2].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "lan-dns.conf")), withSource[3].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[3].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "zz-dnsmasq-webui.conf")), withSource[4].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfFile, withSource[4].Source);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
var fileConf1 = "a.conf";
|
||||
var fileTxt = "b.txt";
|
||||
var fileConf2 = "c.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, fileConf1), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileTxt), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileConf2), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,*.conf\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal));
|
||||
Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
var fileIncluded = "a.conf";
|
||||
var fileExcluded = "b.conf.bak";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, fileIncluded), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileExcluded), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,.bak\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Direct test for EnumerateConfigLinesInDnsmasqOrder: main has line1, conf-file=extra, line2; extra has extra1. Asserts interleaving order.</summary>
|
||||
[Fact]
|
||||
public void EnumerateConfigLinesInDnsmasqOrder_ConfFileInMiddle_InterleavesMainThenIncludedThenMain()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-enum-order-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(main, "line1\nconf-file=extra.conf\nline2\n");
|
||||
File.WriteAllText(extra, "extra1\n");
|
||||
var mainFull = Path.GetFullPath(main);
|
||||
var extraFull = Path.GetFullPath(extra);
|
||||
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
|
||||
var seq = DnsmasqConfIncludeParser.EnumerateConfigLinesInDnsmasqOrder(mainFull, mainDir, DnsmasqConfFileSource.Main, null).ToList();
|
||||
Assert.Equal(5, seq.Count);
|
||||
Assert.Equal((mainFull, 0, "", DnsmasqConfFileSource.Main), seq[0]);
|
||||
Assert.Equal((mainFull, 1, "line1", DnsmasqConfFileSource.Main), seq[1]);
|
||||
Assert.Equal((extraFull, 0, "", DnsmasqConfFileSource.ConfFile), seq[2]);
|
||||
Assert.Equal((extraFull, 1, "extra1", DnsmasqConfFileSource.ConfFile), seq[3]);
|
||||
Assert.Equal((mainFull, 3, "line2", DnsmasqConfFileSource.Main), seq[4]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
|
||||
/// <summary>Interfaces, auth-server, DHCP/option/resolv multi-value, rebind/bogus/ignore/alias, filter-rr, cache-rr.</summary>
|
||||
public class DnsmasqConfIncludeParserInterfacesAndFilteringTests
|
||||
{
|
||||
// --- Additional tests: one per conf field so every option has a documented example test ---
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Interface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-if-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "interface=eth0\ninterface=wlan0\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Interface);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("eth0", result[0]);
|
||||
Assert.Equal("wlan0", result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ExceptInterface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-except-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "except-interface=eth1\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExceptInterface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth1", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NoDhcpInterface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcpi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-dhcp-interface=eth2\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpInterface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth2", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NoDhcpv4Interface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcp4-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-dhcpv4-interface=eth0\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpv4Interface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth0", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NoDhcpv6Interface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcp6-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-dhcpv6-interface=eth0\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpv6Interface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth0", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_AuthServer_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-auth-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "lan,eth0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"auth-server={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AuthServer);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpHost_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcphost-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "00:11:22:33:44:55,192.168.1.100,router,12h";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHost);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpOption_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpopt-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "option:router,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-option={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOption);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ResolvFile_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-resolv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/resolv.dnsmasq";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"resolv-file={path}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ResolvFile);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(path, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_RebindDomainOk_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebind-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "/local.lan/";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"rebind-domain-ok={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RebindDomainOk);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_BogusNxdomain_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-bogusnx-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "64.94.110.11";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"bogus-nxdomain={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BogusNxdomain);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_IgnoreAddress_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignoreaddr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "192.168.1.0/24";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ignore-address={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.IgnoreAddress);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Alias_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-alias-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "1.2.3.0,6.7.8.0,255.255.255.0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"alias={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Alias);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_FilterRr_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filterrr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "TXT,MX";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"filter-rr={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterRr);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_CacheRr_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cacherr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "TXT";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"cache-rr={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.CacheRr);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoHosts_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nohosts-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-hosts\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Positive parser tests for added options: key wired and "option present" behaviour.
|
||||
/// Ensures key names in DnsmasqConfKeys match what the parser expects (complement to OfficialExampleTests which assert "all commented → false/empty").
|
||||
/// Covers process flags (keep-in-foreground, no-daemon), DHCP include paths (dhcp-hostsfile, dhcp-optsfile, dhcp-hostsdir), proxy-dnssec, conntrack.
|
||||
/// </summary>
|
||||
public class DnsmasqConfIncludeParserNewOptionsTests
|
||||
{
|
||||
// --- Flags (8): key-only line → GetFlag returns true ---
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ReadEthers_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.ReadEthers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpRapidCommit_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpRapidCommit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_TftpNoFail_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.TftpNoFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_TftpNoBlocksize_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.TftpNoBlocksize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Localmx_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.Localmx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Selfmx_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.Selfmx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_EnableRa_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.EnableRa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LogDhcp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.LogDhcp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_KeepInForeground_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.KeepInForeground);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoDaemon_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoDaemon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ProxyDnssec_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.ProxyDnssec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_ConnmarkAllowlistEnable_WhenPresent_ReturnsValue()
|
||||
{
|
||||
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.ConnmarkAllowlistEnable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoRoundRobin_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoRoundRobin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DnssecNoTimecheck_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DnssecNoTimecheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DnssecDebug_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DnssecDebug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Leasequery_WhenPresent_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "leasequery\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Leasequery);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("", result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Leasequery_WithValue_IsParsed()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "leasequery=10.0.0.0/24\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Leasequery);
|
||||
Assert.Equal("10.0.0.0/24", Assert.Single(result));
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_QuietDhcp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietDhcp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_QuietTftp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietTftp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpGenerateNames_WhenPresent_ReturnsValue()
|
||||
{
|
||||
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.DhcpGenerateNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpBroadcast_WhenPresent_ReturnsValue()
|
||||
{
|
||||
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.DhcpBroadcast);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpSequentialIp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpSequentialIp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpIgnoreClid_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpIgnoreClid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_BootpDynamic_WhenPresent_ReturnsValue()
|
||||
{
|
||||
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.BootpDynamic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoPing_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoPing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ScriptArp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.ScriptArp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ScriptOnRenewal_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.ScriptOnRenewal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpNoOverride_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpNoOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_QuietDhcp6_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietDhcp6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_QuietRa_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietRa);
|
||||
}
|
||||
|
||||
// --- Multi-value (6): key=value line(s) → GetMultiValue returns value(s) ---
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpOptionForce_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "208,f1:00:74:7e";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-option-force={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOptionForce);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Ipset_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "/example.com/vpn,search";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ipset={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Ipset);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Nftset_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "/example.com/ip#test#vpn";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"nftset={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Nftset);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpMac_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:red,00:60:8C:*:*:*";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-mac={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpMac);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpNameMatch_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:wpad-ignore,wpad";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-name-match={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpNameMatch);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpIgnoreNames_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "tag:wpad-ignore";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-ignore-names={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpIgnoreNames);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpHostsfile_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/dnsmasq.d/hosts.d";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-hostsfile={path}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHostsfile);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(path, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpOptsfile_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/dnsmasq.d/opts.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-optsfile={path}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOptsfile);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(path, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpHostsdir_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/dnsmasq.d/hosts.d";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-hostsdir={path}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHostsdir);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(path, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
// --- Single-value (2): key=value → GetLastValue returns value ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MxTarget_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var value = "mail.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"mx-target={value}\n");
|
||||
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MxTarget);
|
||||
Assert.Equal(value, result);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpScript_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var value = "/usr/bin/dnsmasq-script";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-script={value}\n");
|
||||
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpScript);
|
||||
Assert.Equal(value, result);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Conntrack_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "conntrack\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack));
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Conntrack_WhenAbsent_ReturnsFalse()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "port=53\n");
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack));
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
private static void WriteFlagAndAssertTrue(string key)
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, key + "\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, key));
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
private static void WriteLastWinsKeyOnlyAndAssertPresent(string key)
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, key + "\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, key);
|
||||
Assert.NotNull(value);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
private static string CreateTempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-newopts-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
using DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the parser correctly handles the official dnsmasq example (testdata/dnsmasq.official.conf.example).
|
||||
/// All options in that file are commented out, so parsing it should yield no active values (single path, empty/false/null).
|
||||
/// </summary>
|
||||
public class DnsmasqConfIncludeParserOfficialExampleTests
|
||||
{
|
||||
private static string GetOfficialExamplePath()
|
||||
{
|
||||
var path = TestDataHelper.GetPath("dnsmasq.official.conf.example");
|
||||
Assert.True(File.Exists(path), "Testdata dnsmasq.official.conf.example required (download from https://github.com/imp/dnsmasq)");
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OfficialExample_ReturnsSinglePath()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(mainPath), paths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_OfficialExample_AllCommented_ReturnsNull()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
var (cacheVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
|
||||
var (mxTargetVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.MxTarget);
|
||||
var (dhcpScriptVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpScript);
|
||||
Assert.Null(portVal);
|
||||
Assert.Null(cacheVal);
|
||||
Assert.Null(mxTargetVal);
|
||||
Assert.Null(dhcpScriptVal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
|
||||
var dhcpOptionForce = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpOptionForce);
|
||||
var ipset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Ipset);
|
||||
var nftset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Nftset);
|
||||
var dhcpMac = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpMac);
|
||||
var dhcpNameMatch = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpNameMatch);
|
||||
var dhcpIgnoreNames = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpIgnoreNames);
|
||||
Assert.Empty(servers);
|
||||
Assert.Empty(locals);
|
||||
Assert.Empty(dhcpOptionForce);
|
||||
Assert.Empty(ipset);
|
||||
Assert.Empty(nftset);
|
||||
Assert.Empty(dhcpMac);
|
||||
Assert.Empty(dhcpNameMatch);
|
||||
Assert.Empty(dhcpIgnoreNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OfficialExample_AllCommented_ReturnsFalse()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ReadEthers));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.DhcpRapidCommit));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.TftpNoFail));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.TftpNoBlocksize));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.Localmx));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.Selfmx));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.EnableRa));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.LogDhcp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
Assert.NotNull(addnHosts);
|
||||
Assert.Empty(addnHosts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNoHostsFromConfigFiles_OfficialExample_AllCommented_ReturnsFalse()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
|
||||
Assert.False(noHosts);
|
||||
}
|
||||
|
||||
/// <summary>Official-like sample with commented and active lines: commented ignored, active parsed, state reflects active only.</summary>
|
||||
[Fact]
|
||||
public void OfficialLikeActive_CommentedLinesIgnored_ActiveLinesParsed()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("real-world/edge/dnsmasq-real-official-like-active.conf");
|
||||
Assert.True(File.Exists(mainPath));
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
|
||||
|
||||
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.NotNull(portVal);
|
||||
Assert.True(int.TryParse(portVal, out var port) && port == 5353);
|
||||
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
Assert.Single(servers);
|
||||
Assert.Equal("1.1.1.1", servers[0]);
|
||||
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
using DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures every option in the coverage list is parsed consistently: one config line per option,
|
||||
/// dispatch by parser behavior (Flag / LastWins / Multi), assert we get a value back.
|
||||
/// Add new options to <see cref="OptionCoverageData.GetParserCoverageEntries"/> when adding to the app.
|
||||
/// </summary>
|
||||
public class DnsmasqConfIncludeParserOptionCoverageTests
|
||||
{
|
||||
public static IEnumerable<object[]> GetCoverageEntries()
|
||||
{
|
||||
foreach (var (optionKey, configLine) in OptionCoverageData.GetParserCoverageEntries())
|
||||
yield return new object[] { optionKey, configLine };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetCoverageEntries))]
|
||||
public void EveryOption_ParseSingleLine_ReturnsExpectedResult(string optionKey, string configLine)
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-opt-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, configLine + "\n");
|
||||
var paths = new[] { conf };
|
||||
|
||||
if (optionKey == DnsmasqConfKeys.AddnHosts)
|
||||
{
|
||||
var addn = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
Assert.True(addn.Count >= 1, $"addn-hosts should parse from: {configLine}");
|
||||
return;
|
||||
}
|
||||
|
||||
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionKey);
|
||||
|
||||
switch (behavior)
|
||||
{
|
||||
case EffectiveConfigParserBehavior.Flag:
|
||||
{
|
||||
var flag = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, optionKey);
|
||||
Assert.True(flag, $"Flag option {optionKey} should be true when set.");
|
||||
break;
|
||||
}
|
||||
case EffectiveConfigParserBehavior.LastWins:
|
||||
{
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, optionKey);
|
||||
Assert.NotNull(value);
|
||||
Assert.True(value!.Length > 0, $"LastWins option {optionKey} should return non-empty value.");
|
||||
break;
|
||||
}
|
||||
case EffectiveConfigParserBehavior.Multi:
|
||||
{
|
||||
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, optionKey);
|
||||
Assert.True(list.Count >= 1, $"Multi option {optionKey} should return at least one value.");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
Assert.Fail($"Unknown behavior for {optionKey}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
using DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Corpus-driven parser tests using testdata/real-world. Asserts counts, booleans, and effective state only.
|
||||
/// </summary>
|
||||
public class DnsmasqConfIncludeParserRealWorldCorpusTests
|
||||
{
|
||||
public static IEnumerable<object[]> GetCases()
|
||||
{
|
||||
foreach (var c in RealWorldCasesHelper.LoadCases())
|
||||
yield return new object[] { c };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetCases))]
|
||||
public void GetIncludedPaths_RealWorldCorpus_ReturnsAtLeastMain(CorpusCase c)
|
||||
{
|
||||
var mainPath = RealWorldCasesHelper.Resolve(c);
|
||||
Assert.True(File.Exists(mainPath), $"Corpus file missing: {c.File}");
|
||||
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
|
||||
Assert.NotNull(paths);
|
||||
Assert.NotEmpty(paths);
|
||||
Assert.Contains(Path.GetFullPath(mainPath), paths);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetCases))]
|
||||
public void ParseEffectiveSignals_FromRealWorldCorpus(CorpusCase c)
|
||||
{
|
||||
var e = c.Expected;
|
||||
var hasAny = e.ServerCountMin is not null || e.NoHosts is not null || e.Do0x20State is not null ||
|
||||
e.Port is not null || e.ExpandHosts is not null || e.LocalCountMin is not null ||
|
||||
e.AddressCountMin is not null || e.CacheSize is not null || e.AddnHostsCountMin is not null ||
|
||||
e.DomainCountMin is not null || e.NoResolv is not null || e.BogusPriv is not null ||
|
||||
e.CnameCountMin is not null || e.TxtRecordCountMin is not null || e.SrvCountMin is not null ||
|
||||
e.PtrRecordCountMin is not null || e.MxHostCountMin is not null || e.AuthZoneCountMin is not null ||
|
||||
e.SynthDomainCountMin is not null || e.AuthServerCountMin is not null;
|
||||
if (!hasAny)
|
||||
return;
|
||||
|
||||
var mainPath = RealWorldCasesHelper.Resolve(c);
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
|
||||
|
||||
if (e.ServerCountMin is int minServers)
|
||||
{
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
Assert.True(servers.Count >= minServers, $"Expected at least {minServers} server(s) for {c.File}");
|
||||
}
|
||||
if (e.LocalCountMin is int minLocal)
|
||||
{
|
||||
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
|
||||
Assert.True(locals.Count >= minLocal, $"Expected at least {minLocal} local(s) for {c.File}");
|
||||
}
|
||||
if (e.AddressCountMin is int minAddr)
|
||||
{
|
||||
var addrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
|
||||
Assert.True(addrs.Count >= minAddr, $"Expected at least {minAddr} address(es) for {c.File}");
|
||||
}
|
||||
if (e.CacheSize is int cacheSize)
|
||||
{
|
||||
var (val, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
|
||||
Assert.NotNull(val);
|
||||
Assert.True(int.TryParse(val, out var n) && n == cacheSize);
|
||||
}
|
||||
if (e.AddnHostsCountMin is int minAddn)
|
||||
{
|
||||
var addn = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
Assert.True(addn.Count >= minAddn);
|
||||
}
|
||||
if (e.DomainCountMin is int minDomain)
|
||||
{
|
||||
var domains = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Domain);
|
||||
Assert.True(domains.Count >= minDomain);
|
||||
}
|
||||
if (e.NoHosts is bool noHostsExpected)
|
||||
{
|
||||
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
|
||||
Assert.Equal(noHostsExpected, noHosts);
|
||||
}
|
||||
if (e.NoResolv is true)
|
||||
{
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.NoResolv));
|
||||
}
|
||||
if (e.BogusPriv is true)
|
||||
{
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.BogusPriv));
|
||||
}
|
||||
if (e.Do0x20State == "Disabled")
|
||||
{
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.No0x20Encode));
|
||||
}
|
||||
if (e.Port is int portExpected)
|
||||
{
|
||||
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.NotNull(portVal);
|
||||
Assert.True(int.TryParse(portVal, out var port) && port == portExpected);
|
||||
}
|
||||
if (e.ExpandHosts is true)
|
||||
{
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
|
||||
}
|
||||
if (e.CnameCountMin is int minCname)
|
||||
{
|
||||
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Cname);
|
||||
Assert.True(list.Count >= minCname);
|
||||
}
|
||||
if (e.TxtRecordCountMin is int minTxt)
|
||||
{
|
||||
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.TxtRecord);
|
||||
Assert.True(list.Count >= minTxt);
|
||||
}
|
||||
if (e.SrvCountMin is int minSrv)
|
||||
{
|
||||
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Srv);
|
||||
Assert.True(list.Count >= minSrv);
|
||||
}
|
||||
if (e.PtrRecordCountMin is int minPtr)
|
||||
{
|
||||
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.PtrRecord);
|
||||
Assert.True(list.Count >= minPtr);
|
||||
}
|
||||
if (e.MxHostCountMin is int minMx)
|
||||
{
|
||||
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.MxHost);
|
||||
Assert.True(list.Count >= minMx);
|
||||
}
|
||||
if (e.AuthZoneCountMin is int minAz)
|
||||
{
|
||||
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.AuthZone);
|
||||
Assert.True(list.Count >= minAz);
|
||||
}
|
||||
if (e.SynthDomainCountMin is int minSd)
|
||||
{
|
||||
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.SynthDomain);
|
||||
Assert.True(list.Count >= minSd);
|
||||
}
|
||||
if (e.AuthServerCountMin is int minAs)
|
||||
{
|
||||
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.AuthServer);
|
||||
Assert.True(list.Count >= minAs);
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
|
||||
/// <summary>DNS records and local names: hostsdir, domain, cname, mx-host, srv, ptr/txt/naptr/host-record, dynamic-host, interface-name.</summary>
|
||||
public class DnsmasqConfIncludeParserRecordsTests
|
||||
{
|
||||
// --- One test per option: key wired, when set returns value(s) ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_Hostsdir_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hostsdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/dnsmasq.d/hosts";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"hostsdir={path}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Hostsdir);
|
||||
Assert.Equal(path, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Domain_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-domain-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "home,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"domain={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Domain);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Cname_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cname-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "mail.example.com,real.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"cname={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Cname);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_MxHost_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-mx-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "example.com,mail.example.com,50";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"mx-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MxHost);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Srv_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-srv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "_http._tcp.example.com,server.example.com,80";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"srv-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Srv);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_PtrRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "1.168.192.in-addr.arpa,host.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ptr-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PtrRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_TxtRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-txt-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "example.com,v=spf1 include:_spf.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"txt-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TxtRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NaptrRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-naptr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "example.com,100,10,u,sip+E2U,sips:.*@example.com,.";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"naptr-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NaptrRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_HostRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hostrecord-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "router.example.com,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"host-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.HostRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DynamicHost_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dynamichost-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "hostname,example.com,192.168.1.50";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dynamic-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DynamicHost);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_InterfaceName_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ifname-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "eth0,router.local";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"interface-name={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.InterfaceName);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
|
||||
/// <summary>Server/local, rev-server, dhcp-range, all-servers, log-queries.</summary>
|
||||
public class DnsmasqConfIncludeParserServerAndResolvTests
|
||||
{
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var v1 = "1.1.1.1";
|
||||
var v2 = "/local/";
|
||||
var v3 = "/example.com/192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
|
||||
File.WriteAllText(f2, $"server={v3}\n");
|
||||
var resultServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Server);
|
||||
var resultLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Local);
|
||||
Assert.Equal(2, resultServer.Count);
|
||||
Assert.Equal(v1, resultServer[0]);
|
||||
Assert.Equal(v3, resultServer[1]);
|
||||
Assert.Single(resultLocal);
|
||||
Assert.Equal(v2, resultLocal[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var range1 = "172.28.0.10,172.28.0.50,12h";
|
||||
var range2 = "192.168.1.10,192.168.1.100,255.255.255.0,24h";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-range={range1}\ndhcp-range={range2}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(range1, result[0]);
|
||||
Assert.Equal(range2, result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_AllServers_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-allsrv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "all-servers\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AllServers);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogQueries_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logq-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "log-queries=extra\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogQueries);
|
||||
Assert.Equal("extra", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_RevServer_ReturnsAllValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rev-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var v1 = "1.2.3.0/24,192.168.0.1";
|
||||
var v2 = "10.0.0.0/8,10.0.0.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"rev-server={v1}\nrev-server={v2}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RevServer);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(v1, result[0]);
|
||||
Assert.Equal(v2, result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
|
||||
/// <summary>Single-value options (last wins): local-ttl, pid-file, user, group, auth-ttl, edns-packet-max, query-port, port-limit, min/max-port, log-async, local-service, dhcp-lease-max, neg/max/min-cache-ttl, dhcp-ttl.</summary>
|
||||
public class DnsmasqConfIncludeParserSingleValueTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LeasefileRo_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-leasero-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "leasefile-ro\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LeasefileRo));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LocalTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "local-ttl=300\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocalTtl);
|
||||
Assert.Equal("300", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_PidFile_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pid-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/var/run/dnsmasq.pid";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"pid-file={path}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PidFile);
|
||||
Assert.Equal(path, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_User_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-user-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "user=nobody\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.User);
|
||||
Assert.Equal("nobody", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_Group_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-group-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "group=dip\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Group);
|
||||
Assert.Equal("dip", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_AuthTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-authttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "auth-ttl=60\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AuthTtl);
|
||||
Assert.Equal("60", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EdnsPacketMax_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-edns-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "edns-packet-max=1232\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EdnsPacketMax);
|
||||
Assert.Equal("1232", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_QueryPort_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-qport-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "query-port=0\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.QueryPort);
|
||||
Assert.Equal("0", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_PortLimit_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-portlimit-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "port-limit=3\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PortLimit);
|
||||
Assert.Equal("3", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MinPort_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-minport-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "min-port=1024\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MinPort);
|
||||
Assert.Equal("1024", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MaxPort_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxport-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "max-port=65535\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxPort);
|
||||
Assert.Equal("65535", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogAsync_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logasync-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "log-async=25\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogAsync);
|
||||
Assert.Equal("25", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LocalService_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localsvc-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "local-service=host\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocalService);
|
||||
Assert.Equal("host", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpLeaseMax_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-leasemax-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-lease-max=150\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpLeaseMax);
|
||||
Assert.Equal("150", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_NegTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-negttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "neg-ttl=60\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NegTtl);
|
||||
Assert.Equal("60", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MaxTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "max-ttl=3600\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxTtl);
|
||||
Assert.Equal("3600", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MaxCacheTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxcttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "max-cache-ttl=86400\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxCacheTtl);
|
||||
Assert.Equal("86400", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MinCacheTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-mincttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "min-cache-ttl=60\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MinCacheTtl);
|
||||
Assert.Equal("60", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-ttl=0\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpTtl);
|
||||
Assert.Equal("0", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
public class DnsmasqDhcpRangeValueParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParse_ValidRange_ReturnsStructuredTokens()
|
||||
{
|
||||
var ok = DnsmasqDhcpRangeValueParser.TryParse(
|
||||
"tag:guest,set:known,192.168.1.50,192.168.1.150,12h",
|
||||
out var parsed,
|
||||
out var error);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Null(error);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(["tag:guest", "set:known"], parsed!.Tags);
|
||||
Assert.Equal("192.168.1.50", parsed.StartToken);
|
||||
Assert.Equal("192.168.1.150", parsed.SecondToken);
|
||||
Assert.Equal(["12h"], parsed.RemainingTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_MalformedTrailingComma_ReturnsError()
|
||||
{
|
||||
var ok = DnsmasqDhcpRangeValueParser.TryParse("172.28.0.10,", out var parsed, out var error);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Null(parsed);
|
||||
Assert.Equal("dhcp-range contains an empty comma-separated segment.", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIPv4StartEnd_ReturnsParsedIpv4Range()
|
||||
{
|
||||
var result = DnsmasqDhcpRangeValueParser.GetIPv4StartEnd("tag:guest,192.168.1.50,192.168.1.150,12h");
|
||||
|
||||
Assert.Equal(("192.168.1.50", "192.168.1.150"), result);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
using DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqLeasesFileLineParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated
|
||||
/// fields — expiry (epoch), MAC, IP, hostname (or *), client-id (or *).
|
||||
/// </summary>
|
||||
public class DnsmasqLeasesFileLineParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseLine_Empty_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(""));
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ValidFiveFields_Parses()
|
||||
{
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(epoch, e!.Epoch);
|
||||
Assert.Equal(mac, e.Mac);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Equal(clientId, e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_UnknownHostname_Asterisk()
|
||||
{
|
||||
long epoch = 946689522;
|
||||
string mac = "00:00:00:00:00:04";
|
||||
string address = "192.168.1.237";
|
||||
string name = "*";
|
||||
string clientId = "01:00:00:00:00:00:04";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(name, e!.Name);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_UnknownClientId_Asterisk()
|
||||
{
|
||||
long epoch = 946689351;
|
||||
string mac = "00:0f:b0:3a:b5:0b";
|
||||
string address = "192.168.1.208";
|
||||
string name = "colinux";
|
||||
string clientId = "*";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(name, e!.Name);
|
||||
Assert.Equal(clientId, e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_MultipleSpacesBetweenFields_Parses()
|
||||
{
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $" {epoch} {mac} {address} {name} {clientId} ";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(epoch, e!.Epoch);
|
||||
Assert.Equal(mac, e.Mac);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Equal(clientId, e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TooFewFields_ReturnsNull()
|
||||
{
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine($"{epoch} {mac} {address}"));
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine($"{epoch} {mac}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TrailingJunk_ReturnsNull()
|
||||
{
|
||||
// Parser uses .End() so extra text after fifth field fails
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId} extra";
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_InvalidEpoch_ReturnsNull()
|
||||
{
|
||||
string invalidEpoch = "notanum";
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{invalidEpoch} {mac} {address} {name} {clientId}";
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(line));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When DHCPv6 is in use, dnsmasq writes a "duid ..." line; we must skip it (first field is not numeric).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseLine_DuidLine_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("duid 00:11:22:33:44:55"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timestamp_ConvertsEpochToDateTime()
|
||||
{
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(epoch, e!.Epoch);
|
||||
var dt = e.Timestamp;
|
||||
Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_testdata_leases_ParsesAllLines()
|
||||
{
|
||||
var lines = TestDataHelper.ReadAllLines("leases");
|
||||
var entries = new List<LeaseEntry>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
if (e != null)
|
||||
entries.Add(e);
|
||||
}
|
||||
Assert.Equal(2, entries.Count);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", entries[0].Mac);
|
||||
Assert.Equal("192.168.1.10", entries[0].Address);
|
||||
Assert.Equal("testpc", entries[0].Name);
|
||||
Assert.Equal("01:02:03:04:05:06", entries[0].ClientId);
|
||||
Assert.Equal("11:22:33:44:55:66", entries[1].Mac);
|
||||
Assert.Equal("192.168.1.11", entries[1].Address);
|
||||
Assert.Equal("*", entries[1].Name);
|
||||
Assert.Equal("*", entries[1].ClientId);
|
||||
}
|
||||
}
|
||||
+83
-34
@@ -1,7 +1,9 @@
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using DnsmasqWebUI.Parsers;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
using DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for HostsFileLineParser. Format: IP_address canonical_hostname [aliases...] (hosts(5), RFC 952).
|
||||
@@ -22,7 +24,8 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_WhitespaceOnly_ReturnsPassthrough()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine(" \t ", 2);
|
||||
var raw = " \t ";
|
||||
var e = HostsFileLineParser.ParseLine(raw, 2);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsPassthrough);
|
||||
}
|
||||
@@ -30,7 +33,8 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_CommentOnly_TreatsWholeLineAsComment()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("# This is a comment", 1);
|
||||
var commentLine = "# This is a comment";
|
||||
var e = HostsFileLineParser.ParseLine(commentLine, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.True(e.IsPassthrough);
|
||||
@@ -42,7 +46,10 @@ public class HostsFileLineParserTests
|
||||
public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress()
|
||||
{
|
||||
// Per hosts(5): text from # to EOL is comment - so "# 127.0.0.1 localhost" is entirely comment
|
||||
var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var line = $"# {address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.Equal("", e.Address);
|
||||
@@ -52,75 +59,110 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_IPv4Localhost_ParsesAddressAndName()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var line = $"{address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.False(e!.IsPassthrough);
|
||||
Assert.Equal("127.0.0.1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv4WithCanonicalAndAlias_ParsesAll()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("127.0.1.1 thishost.example.org thishost", 1);
|
||||
string address = "127.0.1.1";
|
||||
string canonical = "thishost.example.org";
|
||||
string alias = "thishost";
|
||||
var line = $"{address} {canonical} {alias}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("127.0.1.1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.True(e.Names.Count >= 1);
|
||||
Assert.Equal("thishost.example.org", e.Names[0]);
|
||||
Assert.Equal(canonical, e.Names[0]);
|
||||
if (e.Names.Count >= 2)
|
||||
Assert.Equal("thishost", e.Names[1]);
|
||||
Assert.Equal(alias, e.Names[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv4WithMultipleSpaces_ParsesCorrectly()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("192.168.1.10 foo.example.org foo", 1);
|
||||
string address = "192.168.1.10";
|
||||
string name1 = "foo.example.org";
|
||||
string name2 = "foo";
|
||||
var line = $"{address} {name1} {name2}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.True(e.Names.Count >= 1);
|
||||
Assert.Equal("foo.example.org", e.Names[0]);
|
||||
Assert.Equal(name1, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv6_ParsesAddressAndNames()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("::1 localhost ip6-localhost ip6-loopback", 1);
|
||||
string address = "::1";
|
||||
string name1 = "localhost";
|
||||
var line = $"{address} {name1} ip6-localhost ip6-loopback";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("::1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.True(e.Names.Count >= 1);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
Assert.Equal(name1, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv6Multicast_Parses()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("ff02::1 ip6-allnodes", 1);
|
||||
string address = "ff02::1";
|
||||
string name = "ip6-allnodes";
|
||||
var line = $"{address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("ff02::1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("ip6-allnodes", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_InlineComment_StopsAtHash()
|
||||
{
|
||||
// Token stops at #; only address and names before # are parsed
|
||||
var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost # loopback", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
string comment = "loopback";
|
||||
var line = $"{address} {name} # {comment}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("127.0.0.1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
Assert.Equal(comment, e.InlineComment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_WithInlineComment_AppendsHashAndRoundTrips()
|
||||
{
|
||||
var line = "192.168.0.5 router # edge gateway";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
var back = HostsFileLineParser.ToLine(e!);
|
||||
Assert.Equal(line, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_LeadingWhitespace_TrimmedAndParsed()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine(" 192.168.1.13 bar", 1);
|
||||
string address = "192.168.1.13";
|
||||
string name = "bar";
|
||||
var line = $" {address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.13", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("bar", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -136,7 +178,8 @@ public class HostsFileLineParserTests
|
||||
public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough()
|
||||
{
|
||||
// Content requires address + whitespace + at least one name
|
||||
var e = HostsFileLineParser.ParseLine("192.168.1.1", 1);
|
||||
string address = "192.168.1.1";
|
||||
var e = HostsFileLineParser.ParseLine(address, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsPassthrough);
|
||||
}
|
||||
@@ -144,7 +187,9 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ToLine_Entry_Roundtrips()
|
||||
{
|
||||
var line = "127.0.0.1 localhost";
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var line = $"{address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
var back = HostsFileLineParser.ToLine(e!);
|
||||
@@ -154,19 +199,23 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ToLine_CommentedEntry_PrefixPreserved()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var commentLine = $"# {address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(commentLine, 1);
|
||||
Assert.NotNull(e);
|
||||
// When we parse "# ..." we get isComment=true, address="", names=[]. ToLine for passthrough returns RawLine.
|
||||
e = new HostEntry { LineNumber = 1, Address = "127.0.0.1", Names = ["localhost"], IsComment = true };
|
||||
e = new HostEntry { LineNumber = 1, Address = address, Names = [name], IsComment = true };
|
||||
var back = HostsFileLineParser.ToLine(e);
|
||||
Assert.Equal("# 127.0.0.1 localhost", back);
|
||||
Assert.Equal(commentLine, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Passthrough_ReturnsRawLine()
|
||||
{
|
||||
var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true };
|
||||
Assert.Equal(" \t ", HostsFileLineParser.ToLine(e));
|
||||
var raw = " \t ";
|
||||
var e = new HostEntry { LineNumber = 1, RawLine = raw, IsPassthrough = true };
|
||||
Assert.Equal(raw, HostsFileLineParser.ToLine(e));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -0,0 +1,63 @@
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.EffectiveConfig;
|
||||
|
||||
public class DomainRuleFieldsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("home.arpa", "home.arpa")]
|
||||
[InlineData("#", "# (resolv.conf search)")]
|
||||
[InlineData("example.com,192.168.0.0/24", "example.com · 192.168.0.0/24")]
|
||||
[InlineData("example.com,192.168.0.0/24,local", "example.com · 192.168.0.0/24 · local DNS")]
|
||||
[InlineData("example.com,eth0", "example.com · interface eth0")]
|
||||
[InlineData("example.com,local", "example.com · local DNS")]
|
||||
public void FormatSummary_FormatsExpected(string line, string expectedContains)
|
||||
{
|
||||
var s = DomainRuleFields.FormatSummary(line);
|
||||
Assert.Equal(expectedContains, s);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RoundTrip_Unconditional()
|
||||
{
|
||||
var f = DomainRuleFields.Parse("my.domain");
|
||||
Assert.False(f.IsRaw);
|
||||
Assert.Equal("my.domain", f.ToConfigLine());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RoundTrip_RangeAndLocal()
|
||||
{
|
||||
var line = "my.domain,10.0.0.0/24,local";
|
||||
var f = DomainRuleFields.Parse(line);
|
||||
Assert.False(f.IsRaw);
|
||||
Assert.Equal(line, f.ToConfigLine());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RoundTrip_Interface()
|
||||
{
|
||||
var line = "my.domain,br0";
|
||||
var f = DomainRuleFields.Parse(line);
|
||||
Assert.False(f.IsRaw);
|
||||
Assert.Equal(DomainRuleScope.InterfaceName, f.Scope);
|
||||
Assert.Equal(line, f.ToConfigLine());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TooManyTokens_UsesRaw()
|
||||
{
|
||||
var line = "a,b,c,d";
|
||||
var f = DomainRuleFields.Parse(line);
|
||||
Assert.True(f.IsRaw);
|
||||
Assert.Equal(line, f.ToConfigLine());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromRawConfigLine_PreservesLine()
|
||||
{
|
||||
var f = DomainRuleFields.FromRawConfigLine(" weird,line,here ");
|
||||
Assert.True(f.IsRaw);
|
||||
Assert.Equal("weird,line,here", f.ToConfigLine());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters;
|
||||
using Xunit;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Serialization.Parsers.Filters;
|
||||
|
||||
public class FilterRuleFieldsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Address_AllDomains_NullSinkhole_SummaryAndRoundTrip()
|
||||
{
|
||||
var raw = "/#/#";
|
||||
var p = AddressRuleFields.Parse(raw);
|
||||
Assert.False(p.IsRaw);
|
||||
Assert.Equal(AddressMatchMode.AllDomains, p.MatchMode);
|
||||
Assert.Equal(AddressResponseMode.NullSinkhole, p.ResponseMode);
|
||||
Assert.Equal("all domains return null/sinkhole", p.ToSummary());
|
||||
Assert.Equal(raw, p.ToConfigLine());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Address_DomainPattern_Ip_Summary()
|
||||
{
|
||||
var raw = "/corp.local/10.0.0.2";
|
||||
var p = AddressRuleFields.Parse(raw);
|
||||
Assert.False(p.IsRaw);
|
||||
Assert.Equal(AddressMatchMode.DomainPattern, p.MatchMode);
|
||||
Assert.Equal("corp.local", p.DomainPath);
|
||||
Assert.Equal("queries for /corp.local/ return 10.0.0.2", p.ToSummary());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_Scoped_Summary()
|
||||
{
|
||||
var raw = "/corp.local/10.0.0.2";
|
||||
var p = ServerRuleFields.Parse(raw);
|
||||
Assert.False(p.IsRaw);
|
||||
Assert.True(p.IsScoped);
|
||||
Assert.Equal("queries for /corp.local/ go to 10.0.0.2", p.ToSummary());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevServer_Summary_MatchesExample()
|
||||
{
|
||||
var raw = "192.168.1.0/24,10.0.0.1";
|
||||
var p = RevServerRuleFields.Parse(raw);
|
||||
Assert.False(p.IsRaw);
|
||||
Assert.Equal("reverse lookups for 192.168.1.0/24 go to 10.0.0.1", p.ToSummary());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BogusNxdomain_Summary()
|
||||
{
|
||||
var p = BogusNxdomainRuleFields.Parse("64.94.110.11/24");
|
||||
Assert.False(p.IsRaw);
|
||||
Assert.Contains("64.94.110.11/24", p.ToSummary());
|
||||
Assert.Contains("bogus NXDOMAIN", p.ToSummary());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterRr_Summary()
|
||||
{
|
||||
var p = FilterRrRuleFields.Parse("0.0.0.0,A");
|
||||
Assert.False(p.IsRaw);
|
||||
Assert.Contains("A", p.ToSummary());
|
||||
Assert.Contains("0.0.0.0", p.ToSummary());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterRr_MatchMayContainCommas_SplitsOnLastComma_RoundTrips()
|
||||
{
|
||||
const string raw = "10.0.0.0,192.168.1.0,A";
|
||||
var p = FilterRrRuleFields.Parse(raw);
|
||||
Assert.False(p.IsRaw);
|
||||
Assert.Equal("10.0.0.0,192.168.1.0", p.MatchValue);
|
||||
Assert.Equal("A", p.RecordType);
|
||||
Assert.Equal(raw, p.ToConfigLine());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Local_UnqualifiedNames_Parse_ToConfigLine_RoundTrips()
|
||||
{
|
||||
const string raw = "//";
|
||||
var p = LocalRuleFields.Parse(raw);
|
||||
Assert.False(p.IsRaw);
|
||||
Assert.Equal("", p.DomainPath);
|
||||
Assert.Equal("unqualified names are answered locally only", p.ToSummary());
|
||||
Assert.Equal(raw, p.ToConfigLine());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Tests effective-config resolution in ConfigSetCache (BuildEffectiveConfig / BuildEffectiveConfigSources).
|
||||
/// Verifies no-0x20-encode precedence and source attribution per dnsmasq docs.
|
||||
/// </summary>
|
||||
public class ConfigSetCacheEffectiveConfigTests
|
||||
{
|
||||
private static (string Dir, string MainPath, string ManagedPath, ConfigSetCache Cache) CreateCacheWithManaged(string managedContent)
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cache-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
File.WriteAllText(managedPath, managedContent);
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
return (dir, mainPath, managedPath, cache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Do0x20_BothPresent_No0x20Wins()
|
||||
{
|
||||
var (dir, _, managedPath, cache) = CreateCacheWithManaged("do-0x20-encode\nno-0x20-encode\n");
|
||||
try
|
||||
{
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
|
||||
Assert.NotNull(snapshot.Sources.Do0x20Encode);
|
||||
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Dispose();
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Do0x20_OnlyDo_Enabled()
|
||||
{
|
||||
var (dir, _, _, cache) = CreateCacheWithManaged("do-0x20-encode\n");
|
||||
try
|
||||
{
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.Equal(ExplicitToggleState.Enabled, snapshot.Config.Do0x20EncodeState);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Dispose();
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Do0x20_OnlyNo_Disabled()
|
||||
{
|
||||
var (dir, _, managedPath, cache) = CreateCacheWithManaged("no-0x20-encode\n");
|
||||
try
|
||||
{
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
|
||||
Assert.NotNull(snapshot.Sources.Do0x20Encode);
|
||||
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Dispose();
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Do0x20_Neither_Default()
|
||||
{
|
||||
var (dir, _, _, cache) = CreateCacheWithManaged("port=53\n");
|
||||
try
|
||||
{
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.Equal(ExplicitToggleState.Default, snapshot.Config.Do0x20EncodeState);
|
||||
Assert.Null(snapshot.Sources.Do0x20Encode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Dispose();
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Do0x20_SourceAttribution_WhenBothPresent_IsNo0x20Line()
|
||||
{
|
||||
var (dir, _, managedPath, cache) = CreateCacheWithManaged("no-0x20-encode\ndo-0x20-encode\n");
|
||||
try
|
||||
{
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
|
||||
Assert.NotNull(snapshot.Sources.Do0x20Encode);
|
||||
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Dispose();
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StripFlags_ParseState_And_Source()
|
||||
{
|
||||
var (dir, _, managedPath, cache) = CreateCacheWithManaged("strip-mac\nstrip-subnet\n");
|
||||
try
|
||||
{
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.True(snapshot.Config.StripMac);
|
||||
Assert.True(snapshot.Config.StripSubnet);
|
||||
Assert.NotNull(snapshot.Sources.StripMac);
|
||||
Assert.NotNull(snapshot.Sources.StripSubnet);
|
||||
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.StripMac!.FilePath, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.StripSubnet!.FilePath, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Dispose();
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RealWorldCorpus_EdgeCommentedActiveMix_Do0x20StateAndSource()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("real-world/edge/dnsmasq-real-edge-commented-active-mix.conf");
|
||||
Assert.True(File.Exists(mainPath));
|
||||
var dir = Path.GetDirectoryName(mainPath)!;
|
||||
var managedName = "zz-managed.conf";
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
cache.Invalidate();
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
|
||||
Assert.NotNull(snapshot.Sources.Do0x20Encode);
|
||||
Assert.Single(snapshot.Config.ServerValues);
|
||||
Assert.Equal("1.1.1.1", snapshot.Config.ServerValues[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RealWorldCorpus_GoodHomeBasic_ServerCountAndSource()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("real-world/good/dnsmasq-real-home-basic.conf");
|
||||
Assert.True(File.Exists(mainPath));
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "zz-managed.conf" });
|
||||
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
cache.Invalidate();
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.True(snapshot.Config.ServerValues.Count >= 2);
|
||||
Assert.Equal(2, snapshot.Sources.ServerValues.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RealWorldCorpus_GoodResolverRich_LocalAddressCacheSizeFlags()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("real-world/good/dnsmasq-real-resolver-rich.conf");
|
||||
Assert.True(File.Exists(mainPath));
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "zz-managed.conf" });
|
||||
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
cache.Invalidate();
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.True(snapshot.Config.ServerValues.Count >= 2);
|
||||
Assert.True(snapshot.Config.LocalValues.Count >= 2);
|
||||
Assert.True(snapshot.Config.AddressValues.Count >= 2);
|
||||
Assert.Equal(1000, snapshot.Config.CacheSize);
|
||||
Assert.True(snapshot.Config.NoResolv);
|
||||
Assert.True(snapshot.Config.BogusPriv);
|
||||
Assert.True(snapshot.Config.ExpandHosts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSnapshot_WithManagedFilesDirectory_UsesOverridePaths()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cache-" + Guid.NewGuid().ToString("N"));
|
||||
var managedDir = Path.Combine(dir, "repo");
|
||||
Directory.CreateDirectory(dir);
|
||||
Directory.CreateDirectory(managedDir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedFileName = "zz-managed.conf";
|
||||
var managedHostsFileName = "zz-managed.hosts";
|
||||
var managedPath = Path.Combine(managedDir, managedFileName);
|
||||
File.WriteAllText(mainPath, $"conf-file={managedPath}\n");
|
||||
File.WriteAllText(managedPath, $"addn-hosts={Path.Combine(managedDir, managedHostsFileName)}\n");
|
||||
|
||||
using var cache = new ConfigSetCache(
|
||||
Options.Create(new DnsmasqOptions
|
||||
{
|
||||
MainConfigPath = mainPath,
|
||||
ManagedFilesDirectory = managedDir,
|
||||
ManagedFileName = managedFileName,
|
||||
ManagedHostsFileName = managedHostsFileName
|
||||
}),
|
||||
NullLogger<ConfigSetCache>.Instance);
|
||||
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
|
||||
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Set.ManagedFilePath);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(managedDir, managedHostsFileName)), snapshot.Set.ManagedHostsFilePath);
|
||||
Assert.Contains(snapshot.Set.Files, f => f.IsManaged && string.Equals(f.Path, Path.GetFullPath(managedPath), StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
+735
@@ -0,0 +1,735 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Save-path tests for DnsmasqConfigService.ApplyEffectiveConfigChangesAsync.
|
||||
/// Verifies that pending changes produce the correct managed config lines.
|
||||
/// </summary>
|
||||
public class DnsmasqConfigServiceApplyChangesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ApplyEffectiveConfigChangesAsync_ConntrackTrue_WritesBareFlagLine()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Conntrack, false, true, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
Assert.True(File.Exists(managedPath));
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
||||
Assert.Contains(lines, l => l == "conntrack");
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEffectiveConfigChangesAsync_ConntrackFalse_RemovesFlagLine()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
File.WriteAllText(managedPath, "conntrack\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
cache.Invalidate();
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Conntrack, true, false, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
||||
Assert.DoesNotContain(lines, l => l == "conntrack" || l.StartsWith("conntrack=", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEffectiveConfigChangesAsync_StripMacTrue_WritesBareFlagLine()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.StripMac, false, true, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
Assert.True(File.Exists(managedPath));
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
||||
Assert.Contains(lines, l => l == DnsmasqConfKeys.StripMac);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEffectiveConfigChangesAsync_StripSubnetFalse_RemovesFlagLine()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
File.WriteAllText(managedPath, "strip-subnet\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
cache.Invalidate();
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.StripSubnet, true, false, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
||||
Assert.DoesNotContain(lines, l => l == DnsmasqConfKeys.StripSubnet || l.StartsWith($"{DnsmasqConfKeys.StripSubnet}=", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEffectiveConfigChangesAsync_DnssecCheckUnsignedNo_WritesEqualsNo()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
Assert.True(File.Exists(managedPath));
|
||||
var text = await File.ReadAllTextAsync(managedPath);
|
||||
Assert.Contains("dnssec-check-unsigned=no", text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEffectiveConfigChangesAsync_SingleValueNull_RemovesExistingLine()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
File.WriteAllText(managedPath, "port=54\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
cache.Invalidate();
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Port, "54", null, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
||||
Assert.DoesNotContain(lines, l => l == DnsmasqConfKeys.Port || l.StartsWith($"{DnsmasqConfKeys.Port}=", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEffectiveConfigChangesAsync_KeyOnlyOrValue_KeyOnly_WritesBareKey()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "", null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
Assert.True(File.Exists(managedPath));
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
Assert.Contains("use-stale-cache", content);
|
||||
Assert.DoesNotContain("use-stale-cache=", content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEffectiveConfigChangesAsync_InversePair_Enabled_WritesDo0x20Encode()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Enabled, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
Assert.True(File.Exists(managedPath));
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
Assert.Contains("do-0x20-encode", content);
|
||||
Assert.DoesNotContain("no-0x20-encode", content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_Do0x20_Default_RemovesBothLines()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
File.WriteAllText(managedPath, "do-0x20-encode\nno-0x20-encode\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
cache.Invalidate();
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Disabled, ExplicitToggleState.Default, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
||||
Assert.DoesNotContain(lines, l => l == "do-0x20-encode" || l == "no-0x20-encode");
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_Do0x20Enabled_RoundTripsToEffectiveState()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Enabled, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
cache.Invalidate();
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.Equal(ExplicitToggleState.Enabled, snapshot.Config.Do0x20EncodeState);
|
||||
Assert.NotNull(snapshot.Sources.Do0x20Encode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_UseStaleCache_RoundTripsToEffectiveState()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "60", null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
cache.Invalidate();
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.Equal("60", snapshot.Config.UseStaleCache);
|
||||
Assert.NotNull(snapshot.Sources.UseStaleCache);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_Server_RoundTripsToEffectiveState()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server, null, new List<string> { "1.1.1.1", "8.8.8.8" }, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
cache.Invalidate();
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.Equal(2, snapshot.Config.ServerValues.Count);
|
||||
Assert.Contains("1.1.1.1", snapshot.Config.ServerValues);
|
||||
Assert.Contains("8.8.8.8", snapshot.Config.ServerValues);
|
||||
Assert.Equal(2, snapshot.Sources.ServerValues.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_Conntrack_RoundTripsToEffectiveState()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Conntrack, false, true, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
cache.Invalidate();
|
||||
var snapshot = await cache.GetSnapshotAsync();
|
||||
Assert.True(snapshot.Config.Conntrack);
|
||||
Assert.NotNull(snapshot.Sources.Conntrack);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_Do0x20_Disabled_WritesNo0x20Encode()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Disabled, null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
Assert.Contains("no-0x20-encode", content);
|
||||
Assert.DoesNotContain("do-0x20-encode", content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_UseStaleCache_Value_WritesKeyEqualsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "60", null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
Assert.Contains("use-stale-cache=60", content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_AddMac_KeyOnly_WritesBareKey()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddMac, null, "", null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
Assert.Contains("add-mac", content);
|
||||
Assert.DoesNotContain("add-mac=", content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_AddMac_ValueBase64_WritesKeyEqualsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddMac, null, "base64", null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
Assert.Contains("add-mac=base64", content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_AddSubnet_KeyOnly_WritesBareKey()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddSubnet, null, "", null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
Assert.Contains("add-subnet", content);
|
||||
Assert.DoesNotContain("add-subnet=", content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyChanges_Umbrella_KeyOnly_WritesBareKey()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.Umbrella, null, "", null)
|
||||
};
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var content = await File.ReadAllTextAsync(managedPath);
|
||||
Assert.Contains("umbrella", content);
|
||||
Assert.DoesNotContain("umbrella=", content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEffectiveConfigChangesAsync_Leasequery_MultiKeyOnlyOrValue_DoesNotDuplicateReadonlyValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var readonlyPath = Path.Combine(dir, "readonly.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
var managedPath = Path.Combine(dir, managedName);
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, $"port=53\nconf-file={readonlyPath}\n");
|
||||
File.WriteAllText(readonlyPath, "leasequery\nleasequery=10.0.0.0/24\n");
|
||||
File.WriteAllText(managedPath, "leasequery=172.16.0.0/16\n");
|
||||
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
cache.Invalidate();
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
// Simulate UI effective values (readonly + managed). Save should write only managed-only values.
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(
|
||||
EffectiveConfigSections.SectionDhcp,
|
||||
DnsmasqConfKeys.Leasequery,
|
||||
null,
|
||||
new List<string> { "", "10.0.0.0/24", "192.168.50.0/24" },
|
||||
null)
|
||||
};
|
||||
|
||||
await configService.ApplyEffectiveConfigChangesAsync(changes);
|
||||
|
||||
var lines = (await File.ReadAllLinesAsync(managedPath))
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.StartsWith("leasequery", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
Assert.Single(lines);
|
||||
Assert.Equal("leasequery=192.168.50.0/24", lines[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-11
@@ -1,10 +1,11 @@
|
||||
using DnsmasqWebUI.Configuration;
|
||||
using DnsmasqWebUI.Models.EffectiveConfig;
|
||||
using DnsmasqWebUI.Services;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfigSetService, including GetEffectiveConfigWithSources.
|
||||
@@ -24,7 +25,8 @@ public class DnsmasqConfigSetServiceTests
|
||||
Assert.Empty(config.AddnHostsPaths);
|
||||
Assert.NotNull(sources);
|
||||
Assert.Empty(sources.AddnHostsPaths);
|
||||
Assert.Empty(sources.ServerLocalValues);
|
||||
Assert.Empty(sources.ServerValues);
|
||||
Assert.Empty(sources.LocalValues);
|
||||
Assert.Empty(sources.DhcpRanges);
|
||||
Assert.Null(sources.NoHosts);
|
||||
Assert.Null(sources.Port);
|
||||
@@ -40,7 +42,10 @@ public class DnsmasqConfigSetServiceTests
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\ncache-size=500\naddn-hosts=/etc/hosts.extra\n");
|
||||
int port = 53;
|
||||
int cacheSize = 500;
|
||||
var addnHostsPath = "/etc/hosts.extra";
|
||||
File.WriteAllText(mainPath, $"port={port}\ncache-size={cacheSize}\naddn-hosts={addnHostsPath}\n");
|
||||
var options = Options.Create(new DnsmasqOptions
|
||||
{
|
||||
MainConfigPath = mainPath,
|
||||
@@ -51,11 +56,12 @@ public class DnsmasqConfigSetServiceTests
|
||||
var (config, sources) = service.GetEffectiveConfigWithSources();
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(53, config.Port);
|
||||
Assert.Equal(500, config.CacheSize);
|
||||
Assert.Equal(port, config.Port);
|
||||
Assert.Equal(cacheSize, config.CacheSize);
|
||||
Assert.Single(config.AddnHostsPaths);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), config.AddnHostsPaths[0]);
|
||||
Assert.Empty(config.ServerLocalValues);
|
||||
Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
|
||||
Assert.Empty(config.ServerValues);
|
||||
Assert.Empty(config.LocalValues);
|
||||
Assert.Empty(config.DhcpRanges);
|
||||
|
||||
Assert.NotNull(sources);
|
||||
@@ -63,7 +69,7 @@ public class DnsmasqConfigSetServiceTests
|
||||
Assert.Equal(Path.GetFileName(mainPath), sources.Port!.FileName);
|
||||
Assert.NotNull(sources.CacheSize);
|
||||
Assert.Single(sources.AddnHostsPaths);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), sources.AddnHostsPaths[0].Path);
|
||||
Assert.Equal(Path.GetFullPath(addnHostsPath), sources.AddnHostsPaths[0].Path);
|
||||
Assert.NotNull(sources.AddnHostsPaths[0].Source);
|
||||
Assert.Equal(Path.GetFileName(mainPath), sources.AddnHostsPaths[0].Source!.FileName);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.Dhcp.Ui;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Dhcp;
|
||||
|
||||
public class DhcpPageProjectionServiceTests
|
||||
{
|
||||
private static readonly DhcpPageProjectionService Sut = new(new EffectiveMultiValueProjectionService());
|
||||
private static readonly DhcpHostOptionValueHandler Handler = new();
|
||||
|
||||
private static DhcpHostPageRow SyntheticRow(
|
||||
int effectiveIndex,
|
||||
DhcpSourceKind kind,
|
||||
string sourcePath,
|
||||
bool editable,
|
||||
string mac,
|
||||
string? name = null,
|
||||
string? address = "192.168.1.10") =>
|
||||
new(
|
||||
EffectiveIndex: effectiveIndex,
|
||||
ValueString: mac,
|
||||
RowKey: $"t:{effectiveIndex}",
|
||||
OccurrenceId: $"t:{effectiveIndex}",
|
||||
SourceKind: kind,
|
||||
SourcePath: sourcePath,
|
||||
IsDraftOnly: false,
|
||||
IsEditable: editable,
|
||||
IsActive: true,
|
||||
Entry: new DhcpHostEntry
|
||||
{
|
||||
LineNumber = effectiveIndex + 1,
|
||||
MacAddresses = new List<string> { mac },
|
||||
Name = name,
|
||||
Address = address,
|
||||
Lease = "infinite",
|
||||
Extra = new List<string>()
|
||||
},
|
||||
Conflict: null,
|
||||
LinkedLease: null,
|
||||
MatchesManagedStatic: kind == DhcpSourceKind.Managed && editable);
|
||||
|
||||
[Fact]
|
||||
public void BuildHostRows_NullSources_TreatsLinesAsManagedEditable()
|
||||
{
|
||||
var status = MinimalStatus(null, "/managed.conf", "/etc/dnsmasq.conf");
|
||||
var values = new[] { "aa:bb:cc:dd:ee:ff,192.168.1.2" };
|
||||
|
||||
var rows = Sut.BuildHostRows(status, values, Handler);
|
||||
|
||||
Assert.Single(rows);
|
||||
Assert.True(rows[0].IsEditable);
|
||||
Assert.Equal(DhcpSourceKind.Managed, rows[0].SourceKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyHostConflictsAndLeases_DetectsDuplicateMac()
|
||||
{
|
||||
var entry1 = new DhcpHostEntry
|
||||
{
|
||||
LineNumber = 1,
|
||||
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
|
||||
Address = "192.168.1.2"
|
||||
};
|
||||
var entry2 = new DhcpHostEntry
|
||||
{
|
||||
LineNumber = 2,
|
||||
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
|
||||
Address = "192.168.1.3"
|
||||
};
|
||||
var rows = new List<DhcpHostPageRow>
|
||||
{
|
||||
new(0, "a", "k0", "k0", DhcpSourceKind.Managed, "/m", false, true, true, entry1, null, null, true),
|
||||
new(1, "b", "k1", "k1", DhcpSourceKind.Managed, "/m", false, true, true, entry2, null, null, true)
|
||||
};
|
||||
|
||||
DhcpPageProjectionService.ApplyHostConflictsAndLeases(rows, null, null);
|
||||
|
||||
Assert.NotNull(rows[0].Conflict);
|
||||
Assert.True(rows[0].Conflict!.DuplicateMac);
|
||||
Assert.NotNull(rows[1].Conflict);
|
||||
Assert.True(rows[1].Conflict!.DuplicateMac);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHostGroups_PrependsEmptyManagedGroupWhenOnlyExternalRows()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
SyntheticRow(0, DhcpSourceKind.MainConfig, "/etc/dnsmasq.conf", false, "aa:bb:cc:dd:ee:01")
|
||||
};
|
||||
var query = new DhcpPageQueryState("", null, DhcpHostSortMode.LineNumber, false);
|
||||
var groups = Sut.BuildHostGroups(rows, query, "/var/lib/dnsmasq/managed.conf");
|
||||
|
||||
Assert.True(groups.Count >= 2);
|
||||
Assert.Equal(DhcpSourceKind.Managed, groups[0].SourceKind);
|
||||
Assert.Empty(groups[0].Rows);
|
||||
Assert.True(groups[0].IsSourceEditable);
|
||||
Assert.Single(groups[1].Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHostGroups_OrdersManagedGroupBeforeMainConfig()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
SyntheticRow(1, DhcpSourceKind.MainConfig, "/etc/dnsmasq.conf", false, "aa:bb:cc:dd:ee:02"),
|
||||
SyntheticRow(0, DhcpSourceKind.Managed, "/m.conf", true, "aa:bb:cc:dd:ee:01")
|
||||
};
|
||||
var query = new DhcpPageQueryState("", null, DhcpHostSortMode.LineNumber, false);
|
||||
var groups = Sut.BuildHostGroups(rows, query, "/m.conf");
|
||||
|
||||
Assert.Equal(2, groups.Count);
|
||||
Assert.Equal(DhcpSourceKind.Managed, groups[0].SourceKind);
|
||||
Assert.Equal(DhcpSourceKind.MainConfig, groups[1].SourceKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHostGroups_SearchKeepsOnlyMatchingRows()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
SyntheticRow(0, DhcpSourceKind.Managed, "/m.conf", true, "aa:bb:cc:dd:ee:01", name: "printer-one"),
|
||||
SyntheticRow(1, DhcpSourceKind.Managed, "/m.conf", true, "aa:bb:cc:dd:ee:02", name: "router-fw")
|
||||
};
|
||||
var query = new DhcpPageQueryState("printer", null, DhcpHostSortMode.LineNumber, false);
|
||||
var groups = Sut.BuildHostGroups(rows, query, "/m.conf");
|
||||
|
||||
Assert.Single(groups);
|
||||
Assert.Single(groups[0].Rows);
|
||||
Assert.Contains("printer", groups[0].Rows[0].Entry.Name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLeaseRows_FlagsManagedStaticWhenMacMatchesEditableRow()
|
||||
{
|
||||
var status = MinimalStatus(null, "/m.conf", "/etc/dnsmasq.conf");
|
||||
var hostRows = new[]
|
||||
{
|
||||
SyntheticRow(0, DhcpSourceKind.Managed, "/m.conf", true, "11:22:33:44:55:66")
|
||||
};
|
||||
var leases = new List<LeaseEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Epoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
Mac = "11:22:33:44:55:66",
|
||||
Address = "192.168.1.50",
|
||||
Name = "x",
|
||||
ClientId = ""
|
||||
}
|
||||
};
|
||||
|
||||
var vms = Sut.BuildLeaseRows(status, leases, hostRows);
|
||||
|
||||
Assert.Single(vms);
|
||||
Assert.True(vms[0].HasManagedStaticByMac);
|
||||
Assert.True(vms[0].HasAnyStaticByMac);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyHostConflictsAndLeases_DetectsDuplicateAddress()
|
||||
{
|
||||
var entry1 = new DhcpHostEntry
|
||||
{
|
||||
LineNumber = 1,
|
||||
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:01" },
|
||||
Address = "192.168.1.5"
|
||||
};
|
||||
var entry2 = new DhcpHostEntry
|
||||
{
|
||||
LineNumber = 2,
|
||||
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:02" },
|
||||
Address = "192.168.1.5"
|
||||
};
|
||||
var rows = new List<DhcpHostPageRow>
|
||||
{
|
||||
new(0, "a", "k0", "k0", DhcpSourceKind.Managed, "/m", false, true, true, entry1, null, null, true),
|
||||
new(1, "b", "k1", "k1", DhcpSourceKind.Managed, "/m", false, true, true, entry2, null, null, true)
|
||||
};
|
||||
|
||||
DhcpPageProjectionService.ApplyHostConflictsAndLeases(rows, null, null);
|
||||
|
||||
Assert.NotNull(rows[0].Conflict);
|
||||
Assert.True(rows[0].Conflict!.DuplicateAddress);
|
||||
Assert.True(rows[1].Conflict!.DuplicateAddress);
|
||||
}
|
||||
|
||||
private static DnsmasqServiceStatus MinimalStatus(
|
||||
DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.EffectiveConfigSources? sources,
|
||||
string managedPath,
|
||||
string mainPath) =>
|
||||
new(
|
||||
SystemHostsPath: null, SystemHostsPathExists: false, ManagedHostsFilePath: null, ManagedHostsPathExists: false,
|
||||
NoHosts: false, AddnHostsPaths: [],
|
||||
EffectiveConfig: null,
|
||||
EffectiveConfigSources: sources,
|
||||
MainConfigPath: mainPath, ManagedFilePath: managedPath, LeasesPath: null,
|
||||
MainConfigPathExists: true, ManagedFilePathExists: true, LeasesPathConfigured: false, LeasesPathExists: false,
|
||||
ConfigFiles: null, ReloadCommandConfigured: false, StatusCommandConfigured: false, StatusShowConfigured: false,
|
||||
LogsConfigured: false, LogsPath: null, StatusShowCommand: null, LogsCommand: null,
|
||||
DnsmasqStatus: "active", StatusCommandExitCode: null, StatusCommandStdout: null, StatusCommandStderr: null,
|
||||
StatusShowOutput: null, LogsOutput: null, DhcpRangeStart: null, DhcpRangeEnd: null,
|
||||
DnsmasqVersion: null, MinimumSupportedDnsmasqVersion: "2.90", DnsmasqVersionSupported: true, DnsmasqVersionError: null,
|
||||
DnsmasqSupportsDhcp: true, DnsmasqSupportsTftp: true, DnsmasqSupportsDnssec: false, DnsmasqSupportsDbus: false);
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using static DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata.EffectiveConfigSections;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.DnsRecords;
|
||||
|
||||
public class DnsRecordDirectiveCodecProviderTests
|
||||
{
|
||||
private readonly IDnsRecordDirectiveCodecProvider _provider = new DnsRecordDirectiveCodecProvider();
|
||||
|
||||
[Fact]
|
||||
public void CnameCodec_RoundTrip_PreservesValue()
|
||||
{
|
||||
var codec = _provider.Get(DnsmasqConfKeys.Cname);
|
||||
const string raw = "alias1,alias2,target.example.com,3600";
|
||||
var row = codec.Parse(new ValueWithSource(raw, null), 0);
|
||||
Assert.Equal(raw, codec.Serialize(row));
|
||||
var p = Assert.IsType<CnamePayload>(row.Payload);
|
||||
Assert.Equal(new[] { "alias1", "alias2" }, p.Aliases);
|
||||
Assert.Equal("target.example.com", p.Target);
|
||||
Assert.Equal(3600, p.Ttl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthSoaCodec_RoundTrip_KeepsRefreshInCorrectSlotWhenHostmasterBlank()
|
||||
{
|
||||
var codec = _provider.Get(DnsmasqConfKeys.AuthSoa);
|
||||
const string raw = "42,,3600";
|
||||
var row = codec.Parse(new ValueWithSource(raw, null), 0);
|
||||
Assert.Equal(raw, codec.Serialize(row));
|
||||
var p = Assert.IsType<AuthSoaPayload>(row.Payload);
|
||||
Assert.Equal("42", p.Serial);
|
||||
Assert.Equal("3600", p.Refresh);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthSoaCodec_RoundTrip_FullLineWithHostmaster()
|
||||
{
|
||||
var codec = _provider.Get(DnsmasqConfKeys.AuthSoa);
|
||||
const string raw = "1,hostmaster.example.com,3600,600,86400";
|
||||
var row = codec.Parse(new ValueWithSource(raw, null), 0);
|
||||
Assert.Equal(raw, codec.Serialize(row));
|
||||
var p = Assert.IsType<AuthSoaPayload>(row.Payload);
|
||||
Assert.Equal("1", p.Serial);
|
||||
Assert.Equal("hostmaster.example.com", p.Hostmaster);
|
||||
Assert.Equal("3600", p.Refresh);
|
||||
Assert.Equal("600", p.Retry);
|
||||
Assert.Equal("86400", p.Expiry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthSoaCodec_RoundTrip_SerialOnly()
|
||||
{
|
||||
var codec = _provider.Get(DnsmasqConfKeys.AuthSoa);
|
||||
const string raw = "99";
|
||||
var row = codec.Parse(new ValueWithSource(raw, null), 0);
|
||||
Assert.Equal(raw, codec.Serialize(row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostRecordCodec_RoundTrip_OwnersIpv4AndTtl()
|
||||
{
|
||||
var codec = _provider.Get(DnsmasqConfKeys.HostRecord);
|
||||
const string raw = "host.example.com,www.host.example.com,192.168.1.10,120";
|
||||
var row = codec.Parse(new ValueWithSource(raw, null), 0);
|
||||
Assert.Equal(raw, codec.Serialize(row));
|
||||
var p = Assert.IsType<HostRecordPayload>(row.Payload);
|
||||
Assert.Equal(new[] { "host.example.com", "www.host.example.com" }, p.Owners);
|
||||
Assert.Equal("192.168.1.10", p.IPv4);
|
||||
Assert.Equal(120, p.Ttl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SrvCodec_RoundTrip_PreservesValue()
|
||||
{
|
||||
var codec = _provider.Get(DnsmasqConfKeys.Srv);
|
||||
const string raw = "_sip._tcp.example.com,host.example.com,5060,10,5";
|
||||
var row = codec.Parse(new ValueWithSource(raw, null), 0);
|
||||
Assert.Equal(raw, codec.Serialize(row));
|
||||
var p = Assert.IsType<SrvPayload>(row.Payload);
|
||||
Assert.Equal("_sip._tcp.example.com", p.ServiceName);
|
||||
Assert.Equal("host.example.com", p.Target);
|
||||
Assert.Equal(5060, p.Port);
|
||||
Assert.Equal(10, p.Priority);
|
||||
Assert.Equal(5, p.Weight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SrvCodec_RoundTrip_ServiceAndTargetOnly_NoTrailingZeros()
|
||||
{
|
||||
var codec = _provider.Get(DnsmasqConfKeys.Srv);
|
||||
const string raw = "_sip._tcp.example.com,host.example.com";
|
||||
var row = codec.Parse(new ValueWithSource(raw, null), 0);
|
||||
Assert.Equal(raw, codec.Serialize(row));
|
||||
var p = Assert.IsType<SrvPayload>(row.Payload);
|
||||
Assert.Null(p.Port);
|
||||
Assert.Null(p.Priority);
|
||||
Assert.Null(p.Weight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SrvCodec_RoundTrip_ServiceTargetPort_OmitsPriorityAndWeight()
|
||||
{
|
||||
var codec = _provider.Get(DnsmasqConfKeys.Srv);
|
||||
const string raw = "_http._tcp.example.com,host.example.com,80";
|
||||
var row = codec.Parse(new ValueWithSource(raw, null), 0);
|
||||
Assert.Equal(raw, codec.Serialize(row));
|
||||
var p = Assert.IsType<SrvPayload>(row.Payload);
|
||||
Assert.Equal(80, p.Port);
|
||||
Assert.Null(p.Priority);
|
||||
Assert.Null(p.Weight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_ListsEveryDnsRecordsSectionOptionExceptAdjunctFlags()
|
||||
{
|
||||
var sectionOptions = Sections
|
||||
.First(s => s.SectionId == SectionDnsRecords)
|
||||
.OptionNames;
|
||||
|
||||
foreach (var name in sectionOptions)
|
||||
{
|
||||
if (name is DnsmasqConfKeys.MxTarget or DnsmasqConfKeys.Localmx or DnsmasqConfKeys.Selfmx)
|
||||
continue;
|
||||
Assert.True(_provider.TryGet(name, out var c) && c != null, $"Missing codec for {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.DnsRecords;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.DnsRecords;
|
||||
|
||||
public class DnsRecordsPageProjectionServiceTests
|
||||
{
|
||||
private readonly DnsRecordsPageProjectionService _sut = new(
|
||||
new DnsRecordDirectiveCodecProvider(),
|
||||
new NoopOptionSemanticValidator(),
|
||||
new EffectiveMultiValueProjectionService());
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_ReturnsEmpty_WhenEffectiveConfigNull()
|
||||
{
|
||||
var status = MinimalStatus() with { EffectiveConfig = null };
|
||||
Assert.Empty(_sut.BuildRows(status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterRows_HidesReadOnly_WhenShowReadOnlyFalse()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("a", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", editable: true),
|
||||
Row("b", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "p,q", editable: false),
|
||||
};
|
||||
var q = new DnsRecordsQueryState("", DnsRecordsUiFamily.All, null, ShowReadOnly: false, OnlyWithIssues: false);
|
||||
var filtered = _sut.FilterRows(rows, q);
|
||||
Assert.Single(filtered);
|
||||
Assert.Equal("a", filtered[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterRows_SourcePathFilter_MatchesFilePath()
|
||||
{
|
||||
var src = new ConfigValueSource("/etc/extra.conf", "extra.conf", IsManaged: false);
|
||||
var rows = new[]
|
||||
{
|
||||
Row("a", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", editable: true, source: src),
|
||||
Row("b", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "p,q", editable: true, source: null),
|
||||
};
|
||||
var q = new DnsRecordsQueryState("", DnsRecordsUiFamily.All, "/etc/extra.conf", true, false);
|
||||
var filtered = _sut.FilterRows(rows, q);
|
||||
Assert.Single(filtered);
|
||||
Assert.Equal("a", filtered[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterRows_Search_MatchesSummaryCaseInsensitive()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("a", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", editable: true, summary: "Alpha summary"),
|
||||
Row("b", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "p,q", editable: true, summary: "Beta"),
|
||||
};
|
||||
var q = new DnsRecordsQueryState("alpha", DnsRecordsUiFamily.All, null, true, false);
|
||||
var filtered = _sut.FilterRows(rows, q);
|
||||
Assert.Single(filtered);
|
||||
Assert.Equal("a", filtered[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterRows_UiFamily_Advanced_KeepsOnlyAdvancedFamilies()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("c", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", true),
|
||||
Row("n", DnsmasqConfKeys.NaptrRecord, DnsRecordFamily.Naptr, "1", true, payload: new NaptrPayload("e.com", "1", "2", "s", "SIP", "!^.*$!", "_sip._udp.example.com")),
|
||||
};
|
||||
var q = new DnsRecordsQueryState("", DnsRecordsUiFamily.Advanced, null, true, false);
|
||||
var filtered = _sut.FilterRows(rows, q);
|
||||
Assert.Single(filtered);
|
||||
Assert.Equal("n", filtered[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_CurrentValuesAccessor_UsesDraftValuesAndManagedSourcePathForUnmatchedRows()
|
||||
{
|
||||
var status = MinimalStatus() with
|
||||
{
|
||||
ManagedFilePath = "/managed.conf",
|
||||
EffectiveConfig = MinimalConfig() with { CnameValues = ["disk.example,target.example"] },
|
||||
EffectiveConfigSources = MinimalSources() with
|
||||
{
|
||||
CnameValues = [new ValueWithSource("disk.example,target.example", new ConfigValueSource("/etc/dnsmasq.d/base.conf", "base.conf", false, 12))]
|
||||
}
|
||||
};
|
||||
|
||||
var rows = _sut.BuildRows(status, optionName => optionName == DnsmasqConfKeys.Cname
|
||||
? ["draft.example,target.example"]
|
||||
: []);
|
||||
|
||||
var row = Assert.Single(rows);
|
||||
Assert.Equal("draft.example,target.example", row.RawValue);
|
||||
Assert.True(row.IsDraftOnly);
|
||||
Assert.True(row.IsEditable);
|
||||
Assert.Equal("/managed.conf", row.SourcePath);
|
||||
Assert.Equal("managed.conf", row.SourceLabel);
|
||||
}
|
||||
|
||||
private static DnsRecordRow Row(
|
||||
string id,
|
||||
string optionName,
|
||||
DnsRecordFamily family,
|
||||
string rawValue,
|
||||
bool editable,
|
||||
ConfigValueSource? source = null,
|
||||
DnsRecordPayload? payload = null,
|
||||
string? summary = null,
|
||||
IReadOnlyList<DnsRecordIssue>? issues = null) =>
|
||||
new(
|
||||
id,
|
||||
id,
|
||||
optionName,
|
||||
family,
|
||||
IndexInOption: 0,
|
||||
rawValue,
|
||||
source,
|
||||
source?.FilePath,
|
||||
source?.FileName,
|
||||
false,
|
||||
editable,
|
||||
payload ?? new CnamePayload(["x"], "y", null),
|
||||
issues ?? [],
|
||||
summary ?? rawValue);
|
||||
|
||||
private static EffectiveDnsmasqConfig MinimalConfig() =>
|
||||
new(
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: [],
|
||||
HostsdirPath: null,
|
||||
ServerValues: [],
|
||||
LocalValues: [],
|
||||
RevServerValues: [],
|
||||
AddressValues: [],
|
||||
Interfaces: [],
|
||||
ListenAddresses: [],
|
||||
ExceptInterfaces: [],
|
||||
DhcpRanges: [],
|
||||
DhcpHostLines: [],
|
||||
DhcpOptionLines: [],
|
||||
DhcpMatchValues: [],
|
||||
DhcpBootValues: [],
|
||||
DhcpIgnoreValues: [],
|
||||
DhcpVendorclassValues: [],
|
||||
DhcpUserclassValues: [],
|
||||
RaParamValues: [],
|
||||
SlaacValues: [],
|
||||
PxeServiceValues: [],
|
||||
TrustAnchorValues: [],
|
||||
ResolvFiles: [],
|
||||
RebindDomainOkValues: [],
|
||||
BogusNxdomainValues: [],
|
||||
IgnoreAddressValues: [],
|
||||
AliasValues: [],
|
||||
FilterRrValues: [],
|
||||
CacheRrValues: [],
|
||||
AuthServerValues: [],
|
||||
NoDhcpInterfaceValues: [],
|
||||
NoDhcpv4InterfaceValues: [],
|
||||
NoDhcpv6InterfaceValues: [],
|
||||
DomainValues: [],
|
||||
CnameValues: [],
|
||||
MxHostValues: [],
|
||||
SrvValues: [],
|
||||
PtrRecordValues: [],
|
||||
TxtRecordValues: [],
|
||||
NaptrRecordValues: [],
|
||||
HostRecordValues: [],
|
||||
DynamicHostValues: [],
|
||||
InterfaceNameValues: [],
|
||||
DhcpOptionForceLines: [],
|
||||
IpsetValues: [],
|
||||
NftsetValues: [],
|
||||
DhcpMacValues: [],
|
||||
DhcpNameMatchValues: [],
|
||||
DhcpIgnoreNamesValues: [],
|
||||
DhcpHostsfilePaths: [],
|
||||
DhcpOptsfilePaths: [],
|
||||
DhcpHostsdirPaths: [],
|
||||
DhcpOptsdirPaths: [],
|
||||
ConnmarkAllowlistValues: [],
|
||||
CaaRecordValues: [],
|
||||
DnsRrValues: [],
|
||||
SynthDomainValues: [],
|
||||
AuthZoneValues: [],
|
||||
AuthSoaValues: [],
|
||||
AuthSecServersValues: [],
|
||||
AuthPeerValues: [],
|
||||
DhcpRelayValues: [],
|
||||
DhcpCircuitidValues: [],
|
||||
DhcpRemoteidValues: [],
|
||||
DhcpSubscridValues: [],
|
||||
DhcpProxyValues: [],
|
||||
TagIfValues: [],
|
||||
BridgeInterfaceValues: [],
|
||||
SharedNetworkValues: [],
|
||||
DhcpOptionPxeValues: [],
|
||||
ExpandHosts: false,
|
||||
BogusPriv: false,
|
||||
StrictOrder: false,
|
||||
AllServers: false,
|
||||
NoResolv: false,
|
||||
DomainNeeded: false,
|
||||
NoPoll: false,
|
||||
BindInterfaces: false,
|
||||
BindDynamic: false,
|
||||
NoNegcache: false,
|
||||
DnsLoopDetect: false,
|
||||
StopDnsRebind: false,
|
||||
RebindLocalhostOk: false,
|
||||
ClearOnReload: false,
|
||||
Filterwin2k: false,
|
||||
FilterA: false,
|
||||
FilterAaaa: false,
|
||||
LocaliseQueries: false,
|
||||
LogDebug: false,
|
||||
DhcpAuthoritative: false,
|
||||
LeasefileRo: false,
|
||||
EnableTftp: false,
|
||||
TftpSecure: false,
|
||||
TftpNoFail: false,
|
||||
TftpNoBlocksize: false,
|
||||
Dnssec: false,
|
||||
DnssecCheckUnsigned: null,
|
||||
ReadEthers: false,
|
||||
DhcpRapidCommit: false,
|
||||
Localmx: false,
|
||||
Selfmx: false,
|
||||
EnableRa: false,
|
||||
LogDhcp: false,
|
||||
KeepInForeground: false,
|
||||
NoDaemon: false,
|
||||
ProxyDnssec: false,
|
||||
ConnmarkAllowlistEnable: null,
|
||||
NoRoundRobin: false,
|
||||
DnssecNoTimecheck: false,
|
||||
DnssecDebug: false,
|
||||
LeasequeryValues: [],
|
||||
DhcpGenerateNames: null,
|
||||
DhcpBroadcast: null,
|
||||
DhcpSequentialIp: false,
|
||||
DhcpIgnoreClid: false,
|
||||
BootpDynamic: null,
|
||||
NoPing: false,
|
||||
ScriptArp: false,
|
||||
ScriptOnRenewal: false,
|
||||
DhcpNoOverride: false,
|
||||
QuietDhcp: false,
|
||||
QuietDhcp6: false,
|
||||
QuietRa: false,
|
||||
QuietTftp: false,
|
||||
DhcpLeaseFilePath: null,
|
||||
CacheSize: null,
|
||||
Port: null,
|
||||
LocalTtl: null,
|
||||
PidFilePath: null,
|
||||
User: null,
|
||||
Group: null,
|
||||
LogFacility: null,
|
||||
LogQueries: null,
|
||||
AuthTtl: null,
|
||||
EdnsPacketMax: null,
|
||||
QueryPort: null,
|
||||
PortLimit: null,
|
||||
MinPort: null,
|
||||
MaxPort: null,
|
||||
LogAsync: null,
|
||||
LocalService: null,
|
||||
DhcpLeaseMax: null,
|
||||
NegTtl: null,
|
||||
MaxTtl: null,
|
||||
MaxCacheTtl: null,
|
||||
MinCacheTtl: null,
|
||||
DhcpTtl: null,
|
||||
TftpRootPath: null,
|
||||
PxePrompt: null,
|
||||
EnableDbus: null,
|
||||
EnableUbus: null,
|
||||
FastDnsRetry: null,
|
||||
DhcpScriptPath: null,
|
||||
MxTarget: null,
|
||||
DnsForwardMax: null,
|
||||
DumpfilePath: null,
|
||||
Dumpmask: null,
|
||||
AddCpeId: null,
|
||||
DnssecTimestamp: null,
|
||||
DnssecLimits: null,
|
||||
DhcpAlternatePort: null,
|
||||
DhcpDuid: null,
|
||||
DhcpLuascriptPath: null,
|
||||
DhcpScriptuser: null,
|
||||
DhcpPxeVendor: null,
|
||||
UseStaleCache: null,
|
||||
AddMac: null,
|
||||
StripMac: false,
|
||||
AddSubnet: null,
|
||||
StripSubnet: false,
|
||||
Umbrella: null,
|
||||
Do0x20EncodeState: ExplicitToggleState.Default,
|
||||
Conntrack: false);
|
||||
|
||||
private static EffectiveConfigSources MinimalSources() =>
|
||||
new(
|
||||
NoHosts: null,
|
||||
AddnHostsPaths: [],
|
||||
HostsdirPath: null,
|
||||
ServerValues: [],
|
||||
LocalValues: [],
|
||||
RevServerValues: [],
|
||||
AddressValues: [],
|
||||
Interfaces: [],
|
||||
ListenAddresses: [],
|
||||
ExceptInterfaces: [],
|
||||
DhcpRanges: [],
|
||||
DhcpHostLines: [],
|
||||
DhcpOptionLines: [],
|
||||
DhcpMatchValues: [],
|
||||
DhcpBootValues: [],
|
||||
DhcpIgnoreValues: [],
|
||||
DhcpVendorclassValues: [],
|
||||
DhcpUserclassValues: [],
|
||||
RaParamValues: [],
|
||||
SlaacValues: [],
|
||||
PxeServiceValues: [],
|
||||
TrustAnchorValues: [],
|
||||
ResolvFiles: [],
|
||||
RebindDomainOkValues: [],
|
||||
BogusNxdomainValues: [],
|
||||
IgnoreAddressValues: [],
|
||||
AliasValues: [],
|
||||
FilterRrValues: [],
|
||||
CacheRrValues: [],
|
||||
AuthServerValues: [],
|
||||
NoDhcpInterfaceValues: [],
|
||||
NoDhcpv4InterfaceValues: [],
|
||||
NoDhcpv6InterfaceValues: [],
|
||||
DomainValues: [],
|
||||
CnameValues: [],
|
||||
MxHostValues: [],
|
||||
SrvValues: [],
|
||||
PtrRecordValues: [],
|
||||
TxtRecordValues: [],
|
||||
NaptrRecordValues: [],
|
||||
HostRecordValues: [],
|
||||
DynamicHostValues: [],
|
||||
InterfaceNameValues: [],
|
||||
DhcpOptionForceLines: [],
|
||||
IpsetValues: [],
|
||||
NftsetValues: [],
|
||||
DhcpMacValues: [],
|
||||
DhcpNameMatchValues: [],
|
||||
DhcpIgnoreNamesValues: [],
|
||||
DhcpHostsfilePaths: [],
|
||||
DhcpOptsfilePaths: [],
|
||||
DhcpHostsdirPaths: [],
|
||||
DhcpOptsdirPaths: [],
|
||||
ConnmarkAllowlistValues: [],
|
||||
CaaRecordValues: [],
|
||||
DnsRrValues: [],
|
||||
SynthDomainValues: [],
|
||||
AuthZoneValues: [],
|
||||
AuthSoaValues: [],
|
||||
AuthSecServersValues: [],
|
||||
AuthPeerValues: [],
|
||||
DhcpRelayValues: [],
|
||||
DhcpCircuitidValues: [],
|
||||
DhcpRemoteidValues: [],
|
||||
DhcpSubscridValues: [],
|
||||
DhcpProxyValues: [],
|
||||
TagIfValues: [],
|
||||
BridgeInterfaceValues: [],
|
||||
SharedNetworkValues: [],
|
||||
DhcpOptionPxeValues: [],
|
||||
ExpandHosts: null,
|
||||
BogusPriv: null,
|
||||
StrictOrder: null,
|
||||
AllServers: null,
|
||||
NoResolv: null,
|
||||
DomainNeeded: null,
|
||||
NoPoll: null,
|
||||
BindInterfaces: null,
|
||||
BindDynamic: null,
|
||||
NoNegcache: null,
|
||||
DnsLoopDetect: null,
|
||||
StopDnsRebind: null,
|
||||
RebindLocalhostOk: null,
|
||||
ClearOnReload: null,
|
||||
Filterwin2k: null,
|
||||
FilterA: null,
|
||||
FilterAaaa: null,
|
||||
LocaliseQueries: null,
|
||||
LogDebug: null,
|
||||
DhcpAuthoritative: null,
|
||||
LeasefileRo: null,
|
||||
EnableTftp: null,
|
||||
TftpSecure: null,
|
||||
TftpNoFail: null,
|
||||
TftpNoBlocksize: null,
|
||||
Dnssec: null,
|
||||
DnssecCheckUnsigned: null,
|
||||
ReadEthers: null,
|
||||
DhcpRapidCommit: null,
|
||||
Localmx: null,
|
||||
Selfmx: null,
|
||||
EnableRa: null,
|
||||
LogDhcp: null,
|
||||
KeepInForeground: null,
|
||||
NoDaemon: null,
|
||||
ProxyDnssec: null,
|
||||
ConnmarkAllowlistEnable: null,
|
||||
NoRoundRobin: null,
|
||||
DnssecNoTimecheck: null,
|
||||
DnssecDebug: null,
|
||||
LeasequeryValues: [],
|
||||
DhcpGenerateNames: null,
|
||||
DhcpBroadcast: null,
|
||||
DhcpSequentialIp: null,
|
||||
DhcpIgnoreClid: null,
|
||||
BootpDynamic: null,
|
||||
NoPing: null,
|
||||
ScriptArp: null,
|
||||
ScriptOnRenewal: null,
|
||||
DhcpNoOverride: null,
|
||||
QuietDhcp: null,
|
||||
QuietDhcp6: null,
|
||||
QuietRa: null,
|
||||
QuietTftp: null,
|
||||
DhcpLeaseFilePath: null,
|
||||
CacheSize: null,
|
||||
Port: null,
|
||||
LocalTtl: null,
|
||||
PidFilePath: null,
|
||||
User: null,
|
||||
Group: null,
|
||||
LogFacility: null,
|
||||
LogQueries: null,
|
||||
AuthTtl: null,
|
||||
EdnsPacketMax: null,
|
||||
QueryPort: null,
|
||||
PortLimit: null,
|
||||
MinPort: null,
|
||||
MaxPort: null,
|
||||
LogAsync: null,
|
||||
LocalService: null,
|
||||
DhcpLeaseMax: null,
|
||||
NegTtl: null,
|
||||
MaxTtl: null,
|
||||
MaxCacheTtl: null,
|
||||
MinCacheTtl: null,
|
||||
DhcpTtl: null,
|
||||
TftpRootPath: null,
|
||||
PxePrompt: null,
|
||||
EnableDbus: null,
|
||||
EnableUbus: null,
|
||||
FastDnsRetry: null,
|
||||
DhcpScriptPath: null,
|
||||
MxTarget: null,
|
||||
DnsForwardMax: null,
|
||||
DumpfilePath: null,
|
||||
Dumpmask: null,
|
||||
AddCpeId: null,
|
||||
DnssecTimestamp: null,
|
||||
DnssecLimits: null,
|
||||
DhcpAlternatePort: null,
|
||||
DhcpDuid: null,
|
||||
DhcpLuascriptPath: null,
|
||||
DhcpScriptuser: null,
|
||||
DhcpPxeVendor: null,
|
||||
UseStaleCache: null,
|
||||
AddMac: null,
|
||||
StripMac: null,
|
||||
AddSubnet: null,
|
||||
StripSubnet: null,
|
||||
Umbrella: null,
|
||||
Do0x20Encode: null,
|
||||
Conntrack: null);
|
||||
|
||||
private static DnsmasqServiceStatus MinimalStatus() =>
|
||||
new(
|
||||
SystemHostsPath: null,
|
||||
SystemHostsPathExists: false,
|
||||
ManagedHostsFilePath: null,
|
||||
ManagedHostsPathExists: false,
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: [],
|
||||
EffectiveConfig: null,
|
||||
EffectiveConfigSources: null,
|
||||
MainConfigPath: null,
|
||||
ManagedFilePath: null,
|
||||
LeasesPath: null,
|
||||
MainConfigPathExists: false,
|
||||
ManagedFilePathExists: false,
|
||||
LeasesPathConfigured: false,
|
||||
LeasesPathExists: false,
|
||||
ConfigFiles: null,
|
||||
ReloadCommandConfigured: false,
|
||||
StatusCommandConfigured: false,
|
||||
StatusShowConfigured: false,
|
||||
LogsConfigured: false,
|
||||
LogsPath: null,
|
||||
StatusShowCommand: null,
|
||||
LogsCommand: null,
|
||||
DnsmasqStatus: "active",
|
||||
StatusCommandExitCode: null,
|
||||
StatusCommandStdout: null,
|
||||
StatusCommandStderr: null,
|
||||
StatusShowOutput: null,
|
||||
LogsOutput: null,
|
||||
DhcpRangeStart: null,
|
||||
DhcpRangeEnd: null,
|
||||
DnsmasqVersion: null,
|
||||
MinimumSupportedDnsmasqVersion: "2.91",
|
||||
DnsmasqVersionSupported: true,
|
||||
DnsmasqVersionError: null,
|
||||
DnsmasqSupportsDhcp: true,
|
||||
DnsmasqSupportsTftp: true,
|
||||
DnsmasqSupportsDnssec: true,
|
||||
DnsmasqSupportsDbus: false);
|
||||
|
||||
private sealed class NoopOptionSemanticValidator : IOptionSemanticValidator
|
||||
{
|
||||
public string? ValidateSingle(string optionName, object? value, OptionValidationSemantics semantics) => null;
|
||||
|
||||
public string? ValidateMultiItem(string optionName, string? value, OptionValidationSemantics semantics) => null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Rules;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
|
||||
|
||||
public class CrossOptionRulesTests
|
||||
{
|
||||
private static EffectiveConfigCrossOptionContext Ctx(EffectiveDnsmasqConfig cfg) =>
|
||||
new(CrossOptionTestHelpers.Status(cfg), []);
|
||||
|
||||
[Fact]
|
||||
public void NoResolvWithoutUpstreamsRule_Warns_when_no_server_and_no_resolv_file()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { NoResolv = true };
|
||||
var rule = new NoResolvWithoutUpstreamsRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
var key = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server);
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(key, issues[0].FieldKey);
|
||||
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoResolvWithoutUpstreamsRule_No_issue_when_resolv_file_configured()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
NoResolv = true,
|
||||
ResolvFiles = ["/run/resolv.conf"]
|
||||
};
|
||||
var rule = new NoResolvWithoutUpstreamsRule();
|
||||
Assert.Empty(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConntrackWithQueryPortRule_Error_when_both_set()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Conntrack = true, QueryPort = 5353 };
|
||||
var rule = new ConntrackWithQueryPortRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(FieldIssueSeverity.Error, issues[0].Severity);
|
||||
Assert.Contains("query-port", issues[0].Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebindExceptionsRequireStopDnsRebindRule_Warns_on_localhost_ok_without_stop()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { RebindLocalhostOk = true };
|
||||
var rule = new RebindExceptionsRequireStopDnsRebindRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_private_in_addr_server()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
BogusPriv = true,
|
||||
ServerValues = ["/0.168.192.in-addr.arpa/10.0.0.1"]
|
||||
};
|
||||
var rule = new BogusPrivBlocksPrivateReverseServerRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
|
||||
Assert.Equal(
|
||||
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server),
|
||||
issues[0].FieldKey);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/10.in-addr.arpa/8.8.8.8")]
|
||||
[InlineData("/31.172.in-addr.arpa/8.8.8.8")]
|
||||
public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_rfc1918_zone_roots(string serverValue)
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
BogusPriv = true,
|
||||
ServerValues = [serverValue]
|
||||
};
|
||||
var rule = new BogusPrivBlocksPrivateReverseServerRule();
|
||||
Assert.Single(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/210.in-addr.arpa/8.8.8.8")]
|
||||
[InlineData("/110.in-addr.arpa/8.8.8.8")]
|
||||
[InlineData("/1.0.0.192.in-addr.arpa/8.8.8.8")]
|
||||
[InlineData("/15.172.in-addr.arpa/8.8.8.8")]
|
||||
[InlineData("/32.172.in-addr.arpa/8.8.8.8")]
|
||||
[InlineData("/10.0.0.192.in-addr.arpa/8.8.8.8")]
|
||||
public void BogusPrivBlocksPrivateReverseServerRule_No_issue_for_non_private_in_addr_substrings(string serverValue)
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
BogusPriv = true,
|
||||
ServerValues = [serverValue]
|
||||
};
|
||||
var rule = new BogusPrivBlocksPrivateReverseServerRule();
|
||||
Assert.Empty(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("192.168.1.0/24,8.8.8.8")]
|
||||
[InlineData("10.0.0.0/8,8.8.8.8")]
|
||||
[InlineData("172.16.0.0/12,8.8.8.8")]
|
||||
public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_rfc1918_rev_server(string revServerValue)
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
BogusPriv = true,
|
||||
RevServerValues = [revServerValue]
|
||||
};
|
||||
var rule = new BogusPrivBlocksPrivateReverseServerRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(
|
||||
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RevServer),
|
||||
issues[0].FieldKey);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("8.8.8.0/24,1.1.1.1")]
|
||||
[InlineData("1.0.0.0/8,1.1.1.1")]
|
||||
public void BogusPrivBlocksPrivateReverseServerRule_No_issue_for_public_rev_server(string revServerValue)
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
BogusPriv = true,
|
||||
RevServerValues = [revServerValue]
|
||||
};
|
||||
var rule = new BogusPrivBlocksPrivateReverseServerRule();
|
||||
Assert.Empty(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BogusPrivBlocksPrivateReverseServerRule_Emits_separate_issues_for_server_and_rev_server()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
BogusPriv = true,
|
||||
ServerValues = ["/0.168.192.in-addr.arpa/10.0.0.1"],
|
||||
RevServerValues = ["192.168.2.0/24,8.8.8.8"]
|
||||
};
|
||||
var rule = new BogusPrivBlocksPrivateReverseServerRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Equal(2, issues.Count);
|
||||
Assert.Contains(
|
||||
issues,
|
||||
i => i.FieldKey == EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server));
|
||||
Assert.Contains(
|
||||
issues,
|
||||
i => i.FieldKey == EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RevServer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BogusPrivBlocksPrivateReverseServerRule_Pending_rev_server_overrides_disk()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
BogusPriv = true,
|
||||
RevServerValues = ["8.8.8.0/24,1.1.1.1"]
|
||||
};
|
||||
var pending = new[]
|
||||
{
|
||||
new PendingOptionChange(
|
||||
"resolver",
|
||||
DnsmasqConfKeys.RevServer,
|
||||
Array.Empty<string>(),
|
||||
new[] { "192.168.1.0/24,8.8.8.8" },
|
||||
null)
|
||||
};
|
||||
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
|
||||
var rule = new BogusPrivBlocksPrivateReverseServerRule();
|
||||
var issues = rule.Evaluate(ctx);
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(
|
||||
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RevServer),
|
||||
issues[0].FieldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoPingWithDhcpSequentialIpRule_Warns_when_both_set()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
NoPing = true,
|
||||
DhcpSequentialIp = true
|
||||
};
|
||||
var rule = new NoPingWithDhcpSequentialIpRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
|
||||
Assert.Equal(
|
||||
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.DhcpSequentialIp),
|
||||
issues[0].FieldKey);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public void NoPingWithDhcpSequentialIpRule_No_issue_unless_both(bool noPing, bool sequential)
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
NoPing = noPing,
|
||||
DhcpSequentialIp = sequential
|
||||
};
|
||||
var rule = new NoPingWithDhcpSequentialIpRule();
|
||||
Assert.Empty(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DnsPortZeroWithTftpOrPxeRule_Warns_when_port_zero_and_enable_tftp()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
Port = 0,
|
||||
EnableTftp = true
|
||||
};
|
||||
var rule = new DnsPortZeroWithTftpOrPxeRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(
|
||||
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Port),
|
||||
issues[0].FieldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DnsPortZeroWithTftpOrPxeRule_Warns_when_port_zero_and_pxe_service()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
Port = 0,
|
||||
PxeServiceValues = ["x86PC,Boot from network,pxelinux"]
|
||||
};
|
||||
var rule = new DnsPortZeroWithTftpOrPxeRule();
|
||||
Assert.Single(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DnsPortZeroWithTftpOrPxeRule_No_issue_when_port_nonzero_even_with_netboot()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
Port = 53,
|
||||
EnableTftp = true,
|
||||
PxeServiceValues = ["x86PC,Boot from network,pxelinux"]
|
||||
};
|
||||
var rule = new DnsPortZeroWithTftpOrPxeRule();
|
||||
Assert.Empty(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DnsPortZeroWithTftpOrPxeRule_No_issue_when_port_zero_without_tftp_or_pxe()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Port = 0 };
|
||||
var rule = new DnsPortZeroWithTftpOrPxeRule();
|
||||
Assert.Empty(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DnssecPrerequisitesRule_Error_when_build_lacks_dnssec()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true };
|
||||
var status = CrossOptionTestHelpers.Status(cfg, dnsmasqSupportsDnssec: false);
|
||||
var ctx = new EffectiveConfigCrossOptionContext(status, []);
|
||||
var rule = new DnssecPrerequisitesRule();
|
||||
var issues = rule.Evaluate(ctx);
|
||||
var dnssecKey = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Dnssec);
|
||||
Assert.Contains(issues, i =>
|
||||
i.Severity == FieldIssueSeverity.Error && i.FieldKey == dnssecKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DnssecPrerequisitesRule_Warns_when_no_trust_anchors()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true };
|
||||
var rule = new DnssecPrerequisitesRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
var trustAnchorKey = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.TrustAnchor);
|
||||
Assert.Contains(issues, i =>
|
||||
i.Severity == FieldIssueSeverity.Warning &&
|
||||
i.FieldKey == trustAnchorKey &&
|
||||
i.Message.Contains("trust-anchor", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProxyDnssecCacheWarningRule_Warns_when_cache_not_zero()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
ProxyDnssec = true,
|
||||
CacheSize = 150
|
||||
};
|
||||
var rule = new ProxyDnssecCacheWarningRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
|
||||
Assert.Equal(
|
||||
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.CacheSize),
|
||||
issues[0].FieldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnmarkAllowlistEnableRequiresAllowlistRule_Error_when_enable_key_only()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ConnmarkAllowlistEnable = "" };
|
||||
var rule = new ConnmarkAllowlistEnableRequiresAllowlistRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(FieldIssueSeverity.Error, issues[0].Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnmarkAllowlistEnableRequiresAllowlistRule_No_issue_when_allowlists_present()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
ConnmarkAllowlistEnable = "0xff",
|
||||
ConnmarkAllowlistValues = ["0x1,*.example.com"]
|
||||
};
|
||||
var rule = new ConnmarkAllowlistEnableRequiresAllowlistRule();
|
||||
Assert.Empty(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryPortIgnoredForSourceBoundServerRule_Warns_when_server_has_at()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
QueryPort = 12345,
|
||||
ServerValues = ["10.0.0.1@192.168.1.1"]
|
||||
};
|
||||
var rule = new QueryPortIgnoredForSourceBoundServerRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSubnetCacheBehaviorRule_Warns_when_add_subnet_set()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { AddSubnet = "24,96" };
|
||||
var rule = new AddSubnetCacheBehaviorRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(
|
||||
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.AddSubnet),
|
||||
issues[0].FieldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filterwin2kSrvWarningRule_Warns_when_enabled()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Filterwin2k = true };
|
||||
var rule = new Filterwin2kSrvWarningRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Contains("SRV", issues[0].Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressLocalDnsmasq286CompatibilityRule_Warns_for_domain_address()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
AddressValues = ["/example.com/192.0.2.1"]
|
||||
};
|
||||
var rule = new AddressLocalDnsmasq286CompatibilityRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressLocalDnsmasq286CompatibilityRule_No_issue_when_matching_local_exists()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
AddressValues = ["/example.com/192.0.2.1"],
|
||||
LocalValues = ["/example.com/"]
|
||||
};
|
||||
var rule = new AddressLocalDnsmasq286CompatibilityRule();
|
||||
Assert.Empty(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressLocalDnsmasq286CompatibilityRule_No_issue_for_nxdomain_form_without_ip()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
AddressValues = ["/ads.example/"]
|
||||
};
|
||||
var rule = new AddressLocalDnsmasq286CompatibilityRule();
|
||||
Assert.Empty(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressLocalDnsmasq286CompatibilityRule_Warns_for_hash_target_without_local()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
AddressValues = ["/blocked.example/#"]
|
||||
};
|
||||
var rule = new AddressLocalDnsmasq286CompatibilityRule();
|
||||
Assert.Single(rule.Evaluate(Ctx(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DnssecCheckUnsignedRequiresDnssecRule_Warns_when_dnssec_check_unsigned_set_without_dnssec()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
Dnssec = false,
|
||||
DnssecCheckUnsigned = ""
|
||||
};
|
||||
var rule = new DnssecCheckUnsignedRequiresDnssecRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
|
||||
Assert.Equal(
|
||||
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.DnssecCheckUnsigned),
|
||||
issues[0].FieldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalServiceIgnoredByBindSettingsRule_Warns_when_local_service_with_interface_filters()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
LocalService = "net",
|
||||
Interfaces = ["eth0"]
|
||||
};
|
||||
var rule = new LocalServiceIgnoredByBindSettingsRule();
|
||||
var issues = rule.Evaluate(Ctx(cfg));
|
||||
Assert.Single(issues);
|
||||
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
|
||||
Assert.Equal(
|
||||
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.LocalService),
|
||||
issues[0].FieldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pending_overlay_overrides_config_for_cross_option_rules()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { NoResolv = false };
|
||||
var pending = new[]
|
||||
{
|
||||
new PendingOptionChange("resolver", DnsmasqConfKeys.NoResolv, false, true, null)
|
||||
};
|
||||
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
|
||||
var rule = new NoResolvWithoutUpstreamsRule();
|
||||
var issues = rule.Evaluate(ctx);
|
||||
Assert.Single(issues);
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
|
||||
|
||||
internal static class CrossOptionTestHelpers
|
||||
{
|
||||
/// <summary>Minimal baseline effective config; override with record <c>with</c> in tests.</summary>
|
||||
public static EffectiveDnsmasqConfig BaselineConfig() =>
|
||||
new(
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: Array.Empty<string>(),
|
||||
HostsdirPath: null,
|
||||
ServerValues: Array.Empty<string>(),
|
||||
LocalValues: Array.Empty<string>(),
|
||||
RevServerValues: Array.Empty<string>(),
|
||||
AddressValues: Array.Empty<string>(),
|
||||
Interfaces: Array.Empty<string>(),
|
||||
ListenAddresses: Array.Empty<string>(),
|
||||
ExceptInterfaces: Array.Empty<string>(),
|
||||
DhcpRanges: Array.Empty<string>(),
|
||||
DhcpHostLines: Array.Empty<string>(),
|
||||
DhcpOptionLines: Array.Empty<string>(),
|
||||
DhcpMatchValues: Array.Empty<string>(),
|
||||
DhcpBootValues: Array.Empty<string>(),
|
||||
DhcpIgnoreValues: Array.Empty<string>(),
|
||||
DhcpVendorclassValues: Array.Empty<string>(),
|
||||
DhcpUserclassValues: Array.Empty<string>(),
|
||||
RaParamValues: Array.Empty<string>(),
|
||||
SlaacValues: Array.Empty<string>(),
|
||||
PxeServiceValues: Array.Empty<string>(),
|
||||
TrustAnchorValues: Array.Empty<string>(),
|
||||
ResolvFiles: Array.Empty<string>(),
|
||||
RebindDomainOkValues: Array.Empty<string>(),
|
||||
BogusNxdomainValues: Array.Empty<string>(),
|
||||
IgnoreAddressValues: Array.Empty<string>(),
|
||||
AliasValues: Array.Empty<string>(),
|
||||
FilterRrValues: Array.Empty<string>(),
|
||||
CacheRrValues: Array.Empty<string>(),
|
||||
AuthServerValues: Array.Empty<string>(),
|
||||
NoDhcpInterfaceValues: Array.Empty<string>(),
|
||||
NoDhcpv4InterfaceValues: Array.Empty<string>(),
|
||||
NoDhcpv6InterfaceValues: Array.Empty<string>(),
|
||||
DomainValues: Array.Empty<string>(),
|
||||
CnameValues: Array.Empty<string>(),
|
||||
MxHostValues: Array.Empty<string>(),
|
||||
SrvValues: Array.Empty<string>(),
|
||||
PtrRecordValues: Array.Empty<string>(),
|
||||
TxtRecordValues: Array.Empty<string>(),
|
||||
NaptrRecordValues: Array.Empty<string>(),
|
||||
HostRecordValues: Array.Empty<string>(),
|
||||
DynamicHostValues: Array.Empty<string>(),
|
||||
InterfaceNameValues: Array.Empty<string>(),
|
||||
DhcpOptionForceLines: Array.Empty<string>(),
|
||||
IpsetValues: Array.Empty<string>(),
|
||||
NftsetValues: Array.Empty<string>(),
|
||||
DhcpMacValues: Array.Empty<string>(),
|
||||
DhcpNameMatchValues: Array.Empty<string>(),
|
||||
DhcpIgnoreNamesValues: Array.Empty<string>(),
|
||||
DhcpHostsfilePaths: Array.Empty<string>(),
|
||||
DhcpOptsfilePaths: Array.Empty<string>(),
|
||||
DhcpHostsdirPaths: Array.Empty<string>(),
|
||||
DhcpOptsdirPaths: Array.Empty<string>(),
|
||||
ConnmarkAllowlistValues: Array.Empty<string>(),
|
||||
CaaRecordValues: Array.Empty<string>(),
|
||||
DnsRrValues: Array.Empty<string>(),
|
||||
SynthDomainValues: Array.Empty<string>(),
|
||||
AuthZoneValues: Array.Empty<string>(),
|
||||
AuthSoaValues: Array.Empty<string>(),
|
||||
AuthSecServersValues: Array.Empty<string>(),
|
||||
AuthPeerValues: Array.Empty<string>(),
|
||||
DhcpRelayValues: Array.Empty<string>(),
|
||||
DhcpCircuitidValues: Array.Empty<string>(),
|
||||
DhcpRemoteidValues: Array.Empty<string>(),
|
||||
DhcpSubscridValues: Array.Empty<string>(),
|
||||
DhcpProxyValues: Array.Empty<string>(),
|
||||
TagIfValues: Array.Empty<string>(),
|
||||
BridgeInterfaceValues: Array.Empty<string>(),
|
||||
SharedNetworkValues: Array.Empty<string>(),
|
||||
DhcpOptionPxeValues: Array.Empty<string>(),
|
||||
ExpandHosts: false,
|
||||
BogusPriv: false,
|
||||
StrictOrder: false,
|
||||
AllServers: false,
|
||||
NoResolv: false,
|
||||
DomainNeeded: false,
|
||||
NoPoll: false,
|
||||
BindInterfaces: false,
|
||||
BindDynamic: false,
|
||||
NoNegcache: false,
|
||||
DnsLoopDetect: false,
|
||||
StopDnsRebind: false,
|
||||
RebindLocalhostOk: false,
|
||||
ClearOnReload: false,
|
||||
Filterwin2k: false,
|
||||
FilterA: false,
|
||||
FilterAaaa: false,
|
||||
LocaliseQueries: false,
|
||||
LogDebug: false,
|
||||
DhcpAuthoritative: false,
|
||||
LeasefileRo: false,
|
||||
EnableTftp: false,
|
||||
TftpSecure: false,
|
||||
TftpNoFail: false,
|
||||
TftpNoBlocksize: false,
|
||||
Dnssec: false,
|
||||
DnssecCheckUnsigned: null,
|
||||
ReadEthers: false,
|
||||
DhcpRapidCommit: false,
|
||||
Localmx: false,
|
||||
Selfmx: false,
|
||||
EnableRa: false,
|
||||
LogDhcp: false,
|
||||
KeepInForeground: false,
|
||||
NoDaemon: false,
|
||||
ProxyDnssec: false,
|
||||
ConnmarkAllowlistEnable: null,
|
||||
NoRoundRobin: false,
|
||||
DnssecNoTimecheck: false,
|
||||
DnssecDebug: false,
|
||||
LeasequeryValues: Array.Empty<string>(),
|
||||
DhcpGenerateNames: null,
|
||||
DhcpBroadcast: null,
|
||||
DhcpSequentialIp: false,
|
||||
DhcpIgnoreClid: false,
|
||||
BootpDynamic: null,
|
||||
NoPing: false,
|
||||
ScriptArp: false,
|
||||
ScriptOnRenewal: false,
|
||||
DhcpNoOverride: false,
|
||||
QuietDhcp: false,
|
||||
QuietDhcp6: false,
|
||||
QuietRa: false,
|
||||
QuietTftp: false,
|
||||
DhcpLeaseFilePath: null,
|
||||
CacheSize: null,
|
||||
Port: null,
|
||||
LocalTtl: null,
|
||||
PidFilePath: null,
|
||||
User: null,
|
||||
Group: null,
|
||||
LogFacility: null,
|
||||
LogQueries: null,
|
||||
AuthTtl: null,
|
||||
EdnsPacketMax: null,
|
||||
QueryPort: null,
|
||||
PortLimit: null,
|
||||
MinPort: null,
|
||||
MaxPort: null,
|
||||
LogAsync: null,
|
||||
LocalService: null,
|
||||
DhcpLeaseMax: null,
|
||||
NegTtl: null,
|
||||
MaxTtl: null,
|
||||
MaxCacheTtl: null,
|
||||
MinCacheTtl: null,
|
||||
DhcpTtl: null,
|
||||
TftpRootPath: null,
|
||||
PxePrompt: null,
|
||||
EnableDbus: null,
|
||||
EnableUbus: null,
|
||||
FastDnsRetry: null,
|
||||
DhcpScriptPath: null,
|
||||
MxTarget: null,
|
||||
DnsForwardMax: null,
|
||||
DumpfilePath: null,
|
||||
Dumpmask: null,
|
||||
AddCpeId: null,
|
||||
DnssecTimestamp: null,
|
||||
DnssecLimits: null,
|
||||
DhcpAlternatePort: null,
|
||||
DhcpDuid: null,
|
||||
DhcpLuascriptPath: null,
|
||||
DhcpScriptuser: null,
|
||||
DhcpPxeVendor: null,
|
||||
UseStaleCache: null,
|
||||
AddMac: null,
|
||||
StripMac: false,
|
||||
AddSubnet: null,
|
||||
StripSubnet: false,
|
||||
Umbrella: null,
|
||||
Do0x20EncodeState: ExplicitToggleState.Default,
|
||||
Conntrack: false);
|
||||
|
||||
public static DnsmasqServiceStatus Status(EffectiveDnsmasqConfig config, bool dnsmasqSupportsDnssec = true) =>
|
||||
new(
|
||||
SystemHostsPath: null,
|
||||
SystemHostsPathExists: false,
|
||||
ManagedHostsFilePath: null,
|
||||
ManagedHostsPathExists: false,
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: Array.Empty<string>(),
|
||||
EffectiveConfig: config,
|
||||
EffectiveConfigSources: null,
|
||||
MainConfigPath: null,
|
||||
ManagedFilePath: null,
|
||||
LeasesPath: null,
|
||||
MainConfigPathExists: false,
|
||||
ManagedFilePathExists: false,
|
||||
LeasesPathConfigured: false,
|
||||
LeasesPathExists: false,
|
||||
ConfigFiles: null,
|
||||
ReloadCommandConfigured: false,
|
||||
StatusCommandConfigured: false,
|
||||
StatusShowConfigured: false,
|
||||
LogsConfigured: false,
|
||||
LogsPath: null,
|
||||
StatusShowCommand: null,
|
||||
LogsCommand: null,
|
||||
DnsmasqStatus: "active",
|
||||
StatusCommandExitCode: null,
|
||||
StatusCommandStdout: null,
|
||||
StatusCommandStderr: null,
|
||||
StatusShowOutput: null,
|
||||
LogsOutput: null,
|
||||
DhcpRangeStart: null,
|
||||
DhcpRangeEnd: null,
|
||||
DnsmasqVersion: "2.91",
|
||||
MinimumSupportedDnsmasqVersion: "2.91",
|
||||
DnsmasqVersionSupported: true,
|
||||
DnsmasqVersionError: null,
|
||||
DnsmasqSupportsDhcp: true,
|
||||
DnsmasqSupportsTftp: true,
|
||||
DnsmasqSupportsDnssec: dnsmasqSupportsDnssec,
|
||||
DnsmasqSupportsDbus: false);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
|
||||
|
||||
public class EffectiveConfigCrossOptionContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetMulti_WhenPendingNewValueIsNull_ReturnsEmpty_NotDiskValues()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["1.1.1.1", "8.8.8.8"] };
|
||||
var pending = new[]
|
||||
{
|
||||
new PendingOptionChange("resolver", DnsmasqConfKeys.Server, new List<string> { "1.1.1.1" }, null, null)
|
||||
};
|
||||
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
|
||||
Assert.Empty(ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMulti_WhenPendingNewValueIsList_ReturnsPending_NotDisk()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["9.9.9.9"] };
|
||||
var pending = new[]
|
||||
{
|
||||
new PendingOptionChange("resolver", DnsmasqConfKeys.Server, null, new List<string> { "1.1.1.1" }, null)
|
||||
};
|
||||
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
|
||||
var v = ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues);
|
||||
Assert.Single(v);
|
||||
Assert.Equal("1.1.1.1", v[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMulti_WhenPendingNewValueIsWrongType_ReturnsEmpty_NotDiskValues()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["1.1.1.1"] };
|
||||
var pending = new[]
|
||||
{
|
||||
new PendingOptionChange("resolver", DnsmasqConfKeys.Server, null, "not-a-list", null)
|
||||
};
|
||||
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
|
||||
Assert.Empty(ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues));
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Rules;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
|
||||
|
||||
public class EffectiveConfigCrossOptionValidationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_aggregates_issues_from_all_rules()
|
||||
{
|
||||
var cfg = CrossOptionTestHelpers.BaselineConfig() with
|
||||
{
|
||||
Conntrack = true,
|
||||
QueryPort = 1,
|
||||
Filterwin2k = true
|
||||
};
|
||||
var status = CrossOptionTestHelpers.Status(cfg);
|
||||
IReadOnlyList<IEffectiveConfigCrossOptionRule> rules =
|
||||
[
|
||||
new ConntrackWithQueryPortRule(),
|
||||
new Filterwin2kSrvWarningRule()
|
||||
];
|
||||
var service = new EffectiveConfigCrossOptionValidationService(rules);
|
||||
|
||||
var issues = service.Validate(status, []);
|
||||
|
||||
Assert.Equal(2, issues.Count);
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using System.Linq;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
public class EffectiveConfigEditSessionChangedEventTests
|
||||
{
|
||||
[Fact]
|
||||
public void TrackCommit_RaisesChanged()
|
||||
{
|
||||
var saveService = new StubSaveService();
|
||||
var session = new EffectiveConfigEditSession(saveService);
|
||||
session.Ui.EnterEditMode();
|
||||
var raised = false;
|
||||
session.Changed += () => raised = true;
|
||||
session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
|
||||
Assert.True(raised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnterEditMode_RaisesChanged()
|
||||
{
|
||||
var saveService = new StubSaveService();
|
||||
var session = new EffectiveConfigEditSession(saveService);
|
||||
var raised = false;
|
||||
session.Changed += () => raised = true;
|
||||
session.Ui.EnterEditMode();
|
||||
Assert.True(raised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevertChange_RaisesChanged()
|
||||
{
|
||||
var saveService = new StubSaveService();
|
||||
var session = new EffectiveConfigEditSession(saveService);
|
||||
session.Ui.EnterEditMode();
|
||||
session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
|
||||
var raised = false;
|
||||
session.Changed += () => raised = true;
|
||||
session.Draft.RevertChange("resolver", "port");
|
||||
Assert.True(raised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ui_EnterEditMode_DoesNotClearDraft()
|
||||
{
|
||||
var saveService = new StubSaveService();
|
||||
var session = new EffectiveConfigEditSession(saveService);
|
||||
var oldEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a" } } };
|
||||
var newEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "b" } } };
|
||||
session.Draft.SetManagedHostsDraft(oldEntries, newEntries, "/tmp/managed.hosts");
|
||||
Assert.Single(session.Draft.PendingChanges);
|
||||
session.Ui.EnterEditMode();
|
||||
Assert.Single(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetManagedHostsDraft_StoresChange_AndRevertManagedHostsDraft_RemovesIt()
|
||||
{
|
||||
var saveService = new StubSaveService();
|
||||
var session = new EffectiveConfigEditSession(saveService);
|
||||
session.Ui.EnterEditMode();
|
||||
var oldEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a" } } };
|
||||
var newEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a", "b" } } };
|
||||
session.Draft.SetManagedHostsDraft(oldEntries, newEntries, "/etc/hosts");
|
||||
Assert.Single(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
|
||||
session.Draft.RevertManagedHostsDraft();
|
||||
Assert.Empty(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
|
||||
}
|
||||
|
||||
private sealed class StubSaveService : IEffectiveConfigSaveService
|
||||
{
|
||||
public Task<EffectiveConfigSaveResult> SaveAsync(IReadOnlyList<PendingDnsmasqChange> changes, CancellationToken ct = default) =>
|
||||
Task.FromResult(EffectiveConfigSaveResult.NoChanges());
|
||||
public Task<EffectiveConfigRestoreResult> RestoreAsync(IReadOnlyList<DnsmasqManagedBackup> backups, CancellationToken ct = default) =>
|
||||
Task.FromResult(new EffectiveConfigRestoreResult(false, false, -1, null, "Stub"));
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for option -> feature mapping and capability checks.
|
||||
/// </summary>
|
||||
public class EffectiveConfigFeatureRequirementsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DnsmasqConfKeys.Dnssec, DnsmasqFeature.Dnssec)]
|
||||
[InlineData(DnsmasqConfKeys.DnssecCheckUnsigned, DnsmasqFeature.Dnssec)]
|
||||
[InlineData(DnsmasqConfKeys.TrustAnchor, DnsmasqFeature.Dnssec)]
|
||||
[InlineData(DnsmasqConfKeys.DhcpRange, DnsmasqFeature.Dhcp)]
|
||||
[InlineData(DnsmasqConfKeys.EnableTftp, DnsmasqFeature.Tftp)]
|
||||
[InlineData(DnsmasqConfKeys.EnableDbus, DnsmasqFeature.Dbus)]
|
||||
public void GetRequiredFeature_ReturnsExpectedFeature(string optionName, DnsmasqFeature expected)
|
||||
{
|
||||
var feature = EffectiveConfigFeatureRequirements.GetRequiredFeature(optionName);
|
||||
Assert.NotNull(feature);
|
||||
Assert.Equal(expected, feature.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRequiredFeature_ForUnknownOption_ReturnsNull()
|
||||
{
|
||||
var feature = EffectiveConfigFeatureRequirements.GetRequiredFeature("no-such-option");
|
||||
Assert.Null(feature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSupportedByCapabilities_WhenCapabilityPresent_ReturnsTrue()
|
||||
{
|
||||
var caps = new DnsmasqCompileCapabilities(true, true, true, false,
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP", "DNSSEC" });
|
||||
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, caps));
|
||||
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DhcpRange, caps));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSupportedByCapabilities_WhenDnssecMissing_ReturnsFalseForDnssecOptions()
|
||||
{
|
||||
var caps = new DnsmasqCompileCapabilities(true, true, false, false,
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.Dnssec, caps));
|
||||
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, caps));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSupportedByCapabilities_ForOptionWithNoRequiredFeature_ReturnsTrue()
|
||||
{
|
||||
var caps = new DnsmasqCompileCapabilities(false, false, false, false,
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.Port, caps));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCapabilityDisabled_WhenStatusSupportsFeature_ReturnsNotDisabled()
|
||||
{
|
||||
var status = CreateStatusWithCapabilities(dnssec: true);
|
||||
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, status);
|
||||
Assert.False(isDisabled);
|
||||
Assert.Null(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCapabilityDisabled_WhenStatusMissingDnssec_ReturnsDisabledWithReason()
|
||||
{
|
||||
var status = CreateStatusWithCapabilities(dnssec: false);
|
||||
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, status);
|
||||
Assert.True(isDisabled);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCapabilityDisabled_WhenStatusNull_ReturnsNotDisabled()
|
||||
{
|
||||
var (isDisabled, _) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, null);
|
||||
Assert.False(isDisabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save path uses IsSupportedByCapabilities(optionName, version.Capabilities) to reject changes before write.
|
||||
/// This test asserts that logic: a build without DNSSEC would reject dnssec-check-unsigned.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SaveGuard_WhenBuildHasNoDnssec_DnssecOptionChangesWouldBeRejected()
|
||||
{
|
||||
var noDnssecCaps = new DnsmasqCompileCapabilities(true, true, false, false,
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP" });
|
||||
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, noDnssecCaps));
|
||||
Assert.NotNull(EffectiveConfigFeatureRequirements.GetUnsupportedReason(DnsmasqFeature.Dnssec));
|
||||
}
|
||||
|
||||
private static DnsmasqServiceStatus CreateStatusWithCapabilities(bool dhcp = true, bool tftp = true, bool dnssec = true, bool dbus = false)
|
||||
{
|
||||
return new DnsmasqServiceStatus(
|
||||
SystemHostsPath: null,
|
||||
SystemHostsPathExists: false,
|
||||
ManagedHostsFilePath: null,
|
||||
ManagedHostsPathExists: false,
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: Array.Empty<string>(),
|
||||
EffectiveConfig: null,
|
||||
EffectiveConfigSources: null,
|
||||
MainConfigPath: null,
|
||||
ManagedFilePath: null,
|
||||
LeasesPath: null,
|
||||
MainConfigPathExists: false,
|
||||
ManagedFilePathExists: false,
|
||||
LeasesPathConfigured: false,
|
||||
LeasesPathExists: false,
|
||||
ConfigFiles: null,
|
||||
ReloadCommandConfigured: false,
|
||||
StatusCommandConfigured: false,
|
||||
StatusShowConfigured: false,
|
||||
LogsConfigured: false,
|
||||
LogsPath: null,
|
||||
StatusShowCommand: null,
|
||||
LogsCommand: null,
|
||||
DnsmasqStatus: "active",
|
||||
StatusCommandExitCode: null,
|
||||
StatusCommandStdout: null,
|
||||
StatusCommandStderr: null,
|
||||
StatusShowOutput: null,
|
||||
LogsOutput: null,
|
||||
DhcpRangeStart: null,
|
||||
DhcpRangeEnd: null,
|
||||
DnsmasqVersion: "2.91",
|
||||
MinimumSupportedDnsmasqVersion: "2.91",
|
||||
DnsmasqVersionSupported: true,
|
||||
DnsmasqVersionError: null,
|
||||
DnsmasqSupportsDhcp: dhcp,
|
||||
DnsmasqSupportsTftp: tftp,
|
||||
DnsmasqSupportsDnssec: dnssec,
|
||||
DnsmasqSupportsDbus: dbus);
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that field descriptors get IsCapabilityDisabled set when the status indicates the option is not supported by the dnsmasq build.
|
||||
/// </summary>
|
||||
public class EffectiveConfigFieldBuilderCapabilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetSingleDescriptor_WhenDnssecOptionAndStatusHasNoDnssec_ReturnsDescriptorWithCapabilityDisabled()
|
||||
{
|
||||
var status = CreateStatusWithCapabilities(dnssec: false);
|
||||
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
|
||||
null,
|
||||
DnsmasqConfKeys.DnssecCheckUnsigned,
|
||||
status,
|
||||
_ => null,
|
||||
_ => null,
|
||||
null);
|
||||
|
||||
Assert.True(descriptor.IsCapabilityDisabled);
|
||||
Assert.NotNull(descriptor.CapabilityDisabledReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSingleDescriptor_WhenDnssecOptionAndStatusHasDnssec_ReturnsDescriptorWithCapabilityNotDisabled()
|
||||
{
|
||||
var status = CreateStatusWithCapabilities(dnssec: true);
|
||||
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
|
||||
null,
|
||||
DnsmasqConfKeys.DnssecCheckUnsigned,
|
||||
status,
|
||||
_ => null,
|
||||
_ => null,
|
||||
null);
|
||||
|
||||
Assert.False(descriptor.IsCapabilityDisabled);
|
||||
Assert.Null(descriptor.CapabilityDisabledReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSingleDescriptor_WhenOptionWithNoFeatureRequirement_ReturnsDescriptorWithCapabilityNotDisabled()
|
||||
{
|
||||
var status = CreateStatusWithCapabilities(dnssec: false);
|
||||
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
|
||||
null,
|
||||
DnsmasqConfKeys.Port,
|
||||
status,
|
||||
_ => null,
|
||||
_ => null,
|
||||
null);
|
||||
|
||||
Assert.False(descriptor.IsCapabilityDisabled);
|
||||
Assert.Null(descriptor.CapabilityDisabledReason);
|
||||
}
|
||||
|
||||
private static DnsmasqServiceStatus CreateStatusWithCapabilities(bool dhcp = true, bool tftp = true, bool dnssec = true, bool dbus = false)
|
||||
{
|
||||
return new DnsmasqServiceStatus(
|
||||
SystemHostsPath: null,
|
||||
SystemHostsPathExists: false,
|
||||
ManagedHostsFilePath: null,
|
||||
ManagedHostsPathExists: false,
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: Array.Empty<string>(),
|
||||
EffectiveConfig: null,
|
||||
EffectiveConfigSources: null,
|
||||
MainConfigPath: null,
|
||||
ManagedFilePath: null,
|
||||
LeasesPath: null,
|
||||
MainConfigPathExists: false,
|
||||
ManagedFilePathExists: false,
|
||||
LeasesPathConfigured: false,
|
||||
LeasesPathExists: false,
|
||||
ConfigFiles: null,
|
||||
ReloadCommandConfigured: false,
|
||||
StatusCommandConfigured: false,
|
||||
StatusShowConfigured: false,
|
||||
LogsConfigured: false,
|
||||
LogsPath: null,
|
||||
StatusShowCommand: null,
|
||||
LogsCommand: null,
|
||||
DnsmasqStatus: "active",
|
||||
StatusCommandExitCode: null,
|
||||
StatusCommandStdout: null,
|
||||
StatusCommandStderr: null,
|
||||
StatusShowOutput: null,
|
||||
LogsOutput: null,
|
||||
DhcpRangeStart: null,
|
||||
DhcpRangeEnd: null,
|
||||
DnsmasqVersion: "2.91",
|
||||
MinimumSupportedDnsmasqVersion: "2.91",
|
||||
DnsmasqVersionSupported: true,
|
||||
DnsmasqVersionError: null,
|
||||
DnsmasqSupportsDhcp: dhcp,
|
||||
DnsmasqSupportsTftp: tftp,
|
||||
DnsmasqSupportsDnssec: dnssec,
|
||||
DnsmasqSupportsDbus: dbus);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using DnsmasqWebUI.Components.EffectiveConfig;
|
||||
using DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
public class EffectiveConfigRenderFragmentRegistryMultiTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildMultiFieldComponentFragment_Domain_ReturnsFragment()
|
||||
{
|
||||
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DomainSemanticHandler()]));
|
||||
var fragment = registry.BuildMultiFieldComponentFragment(
|
||||
EffectiveConfigSections.SectionHosts,
|
||||
DnsmasqConfKeys.Domain,
|
||||
EventCallback<IReadOnlyList<string>>.Empty);
|
||||
|
||||
Assert.NotNull(fragment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DomainMultiValueDisplay_InheritsMultiBase()
|
||||
{
|
||||
Assert.Equal(typeof(EffectiveConfigMultiValueDisplayBase), typeof(DomainMultiValueDisplay).BaseType);
|
||||
}
|
||||
|
||||
/// <summary>Custom domain UI must still use RegisterSemanticMultiDescriptor wiring so per-item validation is not dropped.</summary>
|
||||
[Fact]
|
||||
public void GetMultiDescriptorFactory_Domain_WiresSemanticValidator()
|
||||
{
|
||||
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DomainSemanticHandler()]));
|
||||
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.Domain);
|
||||
Assert.NotNull(factory);
|
||||
|
||||
var descriptor = factory!(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.Domain, status: null, getItems: _ => null);
|
||||
Assert.NotNull(descriptor.Validator);
|
||||
Assert.Null(descriptor.Validator!.ValidateItem("example.com", Array.Empty<string>(), null));
|
||||
Assert.NotNull(descriptor.Validator.ValidateItem("not a valid domain name!", Array.Empty<string>(), null));
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Save path capability guard: unsupported-option changes return UnsupportedCapabilities
|
||||
/// before validation or reload can run.
|
||||
/// </summary>
|
||||
public class EffectiveConfigSaveServiceCapabilityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_WhenCapabilitiesMissingDnssec_ReturnsUnsupportedCapabilitiesErrorCode()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cap-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedName = "zz-managed.conf";
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var versionService = new StubVersionService(CreateSupportedVersionInfo(dnssec: false));
|
||||
var semanticValidationService = new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>()));
|
||||
|
||||
var saveService = new EffectiveConfigSaveService(
|
||||
setService,
|
||||
configService,
|
||||
cache,
|
||||
new StubHostsFileService(),
|
||||
new UnexpectedValidationService(),
|
||||
semanticValidationService,
|
||||
new UnexpectedReloadService(),
|
||||
versionService,
|
||||
NullLogger<EffectiveConfigSaveService>.Instance);
|
||||
|
||||
var changes = new List<PendingDnsmasqChange>
|
||||
{
|
||||
new PendingOptionChange(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null)
|
||||
};
|
||||
|
||||
var result = await saveService.SaveAsync(changes);
|
||||
|
||||
Assert.Equal(EffectiveConfigSaveResult.ErrorCodes.UnsupportedCapabilities, result.ErrorCode);
|
||||
Assert.False(File.Exists(Path.Combine(dir, managedName)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubVersionService : IDnsmasqVersionService
|
||||
{
|
||||
private readonly DnsmasqVersionInfo _info;
|
||||
|
||||
public StubVersionService(DnsmasqVersionInfo info) => _info = info;
|
||||
|
||||
public Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(_info);
|
||||
}
|
||||
|
||||
private static DnsmasqVersionInfo CreateSupportedVersionInfo(bool dnssec)
|
||||
{
|
||||
var capabilities = new DnsmasqCompileCapabilities(
|
||||
Dhcp: true,
|
||||
Tftp: true,
|
||||
Dnssec: dnssec,
|
||||
Dbus: false,
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP" });
|
||||
|
||||
return new DnsmasqVersionInfo(
|
||||
new Version(2, 91),
|
||||
new Version(2, 91),
|
||||
ProbeSucceeded: true,
|
||||
IsSupported: true,
|
||||
"dnsmasq --version",
|
||||
null,
|
||||
capabilities);
|
||||
}
|
||||
|
||||
private sealed class UnexpectedValidationService : IConfigValidationService
|
||||
{
|
||||
public Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("ValidateAsync should not be called when capabilities are unsupported.");
|
||||
}
|
||||
|
||||
private sealed class UnexpectedReloadService : IReloadService
|
||||
{
|
||||
public Task<ReloadResult> ReloadAsync(CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("ReloadAsync should not be called when capabilities are unsupported.");
|
||||
}
|
||||
|
||||
private sealed class StubHostsFileService : IHostsFileService
|
||||
{
|
||||
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) => Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
|
||||
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for multi-file backup/restore and managed-hosts-only save.
|
||||
/// </summary>
|
||||
public class EffectiveConfigSaveServiceMultiBackupTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_WhenOnlyManagedHostsChange_BacksUpManagedHostsFile_WritesHosts_ValidatesAndReloads()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hosts-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var options = Options.Create(new DnsmasqOptions
|
||||
{
|
||||
MainConfigPath = mainPath,
|
||||
ManagedFileName = "zz-managed.conf",
|
||||
ManagedHostsFileName = "zz-dnsmasq-webui.hosts"
|
||||
});
|
||||
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
|
||||
|
||||
var set = await setService.GetConfigSetAsync();
|
||||
var managedHostsPath = set.ManagedHostsFilePath!;
|
||||
File.WriteAllText(managedHostsPath, "192.168.1.1 host1\n");
|
||||
|
||||
var writtenEntries = new List<IReadOnlyList<HostEntry>>();
|
||||
var capturingHostsService = new CapturingHostsFileService(writtenEntries);
|
||||
|
||||
var versionService = new StubVersionService(CreateSupportedVersionInfo());
|
||||
var semanticValidationService = new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>()));
|
||||
var saveService = new EffectiveConfigSaveService(
|
||||
setService,
|
||||
configService,
|
||||
cache,
|
||||
capturingHostsService,
|
||||
new StubValidationService(success: true),
|
||||
semanticValidationService,
|
||||
new StubReloadService(success: true),
|
||||
versionService,
|
||||
NullLogger<EffectiveConfigSaveService>.Instance);
|
||||
|
||||
var oldEntries = new List<HostEntry> { new() { Address = "192.168.1.1", Names = new List<string> { "host1" }, Id = "192.168.1.1|host1" } };
|
||||
var newEntries = new List<HostEntry>
|
||||
{
|
||||
new() { Address = "192.168.1.1", Names = new List<string> { "host1" }, Id = "192.168.1.1|host1" },
|
||||
new() { Address = "192.168.1.2", Names = new List<string> { "host2" }, Id = "192.168.1.2|host2" }
|
||||
};
|
||||
var changes = new List<PendingDnsmasqChange>
|
||||
{
|
||||
new PendingManagedHostsChange(oldEntries, newEntries, managedHostsPath)
|
||||
};
|
||||
|
||||
var result = await saveService.SaveAsync(changes);
|
||||
|
||||
Assert.True(result.Saved);
|
||||
Assert.True(result.Validated);
|
||||
Assert.True(result.Restarted);
|
||||
Assert.Null(result.ErrorCode);
|
||||
Assert.Single(result.Backups);
|
||||
Assert.Equal(DnsmasqManagedTargetKind.ManagedHosts, result.Backups[0].Kind);
|
||||
Assert.Equal(managedHostsPath, result.Backups[0].TargetPath);
|
||||
Assert.True(File.Exists(result.Backups[0].BackupPath));
|
||||
Assert.Single(writtenEntries);
|
||||
Assert.Equal(2, writtenEntries[0].Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache?.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_WhenBackupsContainTwoTargets_RestoresBothThenReloads()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-restore-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(mainPath, "port=53\n");
|
||||
var target1 = Path.Combine(dir, "target1.conf");
|
||||
var target2 = Path.Combine(dir, "target2.hosts");
|
||||
var backup1 = Path.Combine(dir, "backup1.bak");
|
||||
var backup2 = Path.Combine(dir, "backup2.bak");
|
||||
File.WriteAllText(target1, "original1");
|
||||
File.WriteAllText(target2, "original2");
|
||||
File.WriteAllText(backup1, "restored1");
|
||||
File.WriteAllText(backup2, "restored2");
|
||||
|
||||
var backups = new List<DnsmasqManagedBackup>
|
||||
{
|
||||
new(DnsmasqManagedTargetKind.ManagedConfig, target1, backup1),
|
||||
new(DnsmasqManagedTargetKind.ManagedHosts, target2, backup2)
|
||||
};
|
||||
|
||||
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "x.conf" });
|
||||
var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
|
||||
var setService = new DnsmasqConfigSetService(cache);
|
||||
try
|
||||
{
|
||||
var saveService = new EffectiveConfigSaveService(
|
||||
setService,
|
||||
new StubConfigService(),
|
||||
cache,
|
||||
new StubHostsFileService(),
|
||||
new StubValidationService(success: true),
|
||||
new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>())),
|
||||
new StubReloadService(success: true),
|
||||
new StubVersionService(CreateSupportedVersionInfo()),
|
||||
NullLogger<EffectiveConfigSaveService>.Instance);
|
||||
|
||||
var result = await saveService.RestoreAsync(backups);
|
||||
|
||||
Assert.True(result.Restored);
|
||||
Assert.True(result.Restarted);
|
||||
Assert.Equal("restored1", File.ReadAllText(target1));
|
||||
Assert.Equal("restored2", File.ReadAllText(target2));
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache.Dispose();
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static DnsmasqVersionInfo CreateSupportedVersionInfo()
|
||||
{
|
||||
var capabilities = new DnsmasqCompileCapabilities(
|
||||
Dhcp: true, Tftp: true, Dnssec: true, Dbus: false,
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP", "DNSSEC" });
|
||||
return new DnsmasqVersionInfo(
|
||||
new Version(2, 91), new Version(2, 91),
|
||||
ProbeSucceeded: true, IsSupported: true, "dnsmasq --version", null, capabilities);
|
||||
}
|
||||
|
||||
private sealed class CapturingHostsFileService : IHostsFileService
|
||||
{
|
||||
private readonly List<IReadOnlyList<HostEntry>> _written;
|
||||
|
||||
public CapturingHostsFileService(List<IReadOnlyList<HostEntry>> written) => _written = written;
|
||||
|
||||
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
|
||||
|
||||
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
|
||||
{
|
||||
_written.Add(entries);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubVersionService : IDnsmasqVersionService
|
||||
{
|
||||
private readonly DnsmasqVersionInfo _info;
|
||||
public StubVersionService(DnsmasqVersionInfo info) => _info = info;
|
||||
public Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default) => Task.FromResult(_info);
|
||||
}
|
||||
|
||||
private sealed class StubValidationService : IConfigValidationService
|
||||
{
|
||||
private readonly bool _success;
|
||||
public StubValidationService(bool success) => _success = success;
|
||||
public Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new ConfigValidationResult(_success, true, _success ? 0 : 1, null, null, null));
|
||||
}
|
||||
|
||||
private sealed class StubReloadService : IReloadService
|
||||
{
|
||||
private readonly bool _success;
|
||||
public StubReloadService(bool success) => _success = success;
|
||||
public Task<ReloadResult> ReloadAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new ReloadResult(_success, _success ? 0 : 1, null, null));
|
||||
}
|
||||
|
||||
private sealed class StubHostsFileService : IHostsFileService
|
||||
{
|
||||
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
|
||||
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class StubConfigService : IDnsmasqConfigService
|
||||
{
|
||||
public Task ApplyEffectiveConfigChangesAsync(IReadOnlyList<PendingEffectiveConfigChange> changes, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default) => Task.FromResult<IReadOnlyList<DhcpHostEntry>>(Array.Empty<DhcpHostEntry>());
|
||||
public Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<ManagedConfigContent> ReadManagedConfigAsync(CancellationToken ct = default) => Task.FromResult(new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), ""));
|
||||
public Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Wiring tests that ensure special-option semantics actually feed parser and registry behavior.
|
||||
/// </summary>
|
||||
public class EffectiveConfigSemanticsWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public void Registry_WiresSemanticValidator_ForDnsRr()
|
||||
{
|
||||
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DnsRrSemanticHandler()]));
|
||||
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.DnsRr);
|
||||
Assert.NotNull(factory);
|
||||
var descriptor = factory!(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.DnsRr, status: null, getItems: _ => null);
|
||||
Assert.NotNull(descriptor.Validator);
|
||||
Assert.Null(descriptor.Validator!.ValidateItem("example.com,16,01:02", Array.Empty<string>()));
|
||||
Assert.NotNull(descriptor.Validator.ValidateItem("example.com,not-a-number", Array.Empty<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics()
|
||||
{
|
||||
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new LeasequerySemanticHandler()]));
|
||||
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery);
|
||||
Assert.NotNull(factory);
|
||||
|
||||
var descriptor = factory!(
|
||||
EffectiveConfigSections.SectionDhcp,
|
||||
DnsmasqConfKeys.Leasequery,
|
||||
status: null,
|
||||
getItems: _ => null);
|
||||
|
||||
Assert.NotNull(descriptor.Validator);
|
||||
Assert.NotNull(descriptor.Validator!.ValidateItem("not-an-ip", Array.Empty<string>()));
|
||||
Assert.Null(descriptor.Validator.ValidateItem("10.0.0.0/24", Array.Empty<string>()));
|
||||
}
|
||||
|
||||
/// <summary>Pre-save semantic blocking: invalid leasequery value produces errors; SaveService uses this and returns SemanticValidationFailed before write.</summary>
|
||||
[Fact]
|
||||
public void SemanticValidationService_InvalidLeasequery_ReturnsError()
|
||||
{
|
||||
var validator = new OptionSemanticValidator([new LeasequerySemanticHandler()]);
|
||||
var service = new EffectiveConfigSemanticValidationService(validator);
|
||||
var changes = new List<PendingEffectiveConfigChange>
|
||||
{
|
||||
new(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery, null,
|
||||
new List<string> { "not-an-ip" }, null)
|
||||
};
|
||||
var issues = service.Validate(changes);
|
||||
Assert.NotEmpty(issues);
|
||||
Assert.Contains(issues, i => i.Severity == FieldIssueSeverity.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for EffectiveDnsmasqConfig, including GetLogsPath (log-facility as file path).
|
||||
/// </summary>
|
||||
public class EffectiveDnsmasqConfigTests
|
||||
{
|
||||
private static EffectiveDnsmasqConfig CreateConfig(string? logFacility = null) =>
|
||||
new(
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: Array.Empty<string>(),
|
||||
HostsdirPath: null,
|
||||
ServerValues: Array.Empty<string>(),
|
||||
LocalValues: Array.Empty<string>(),
|
||||
RevServerValues: Array.Empty<string>(),
|
||||
AddressValues: Array.Empty<string>(),
|
||||
Interfaces: Array.Empty<string>(),
|
||||
ListenAddresses: Array.Empty<string>(),
|
||||
ExceptInterfaces: Array.Empty<string>(),
|
||||
DhcpRanges: Array.Empty<string>(),
|
||||
DhcpHostLines: Array.Empty<string>(),
|
||||
DhcpOptionLines: Array.Empty<string>(),
|
||||
DhcpMatchValues: Array.Empty<string>(),
|
||||
DhcpBootValues: Array.Empty<string>(),
|
||||
DhcpIgnoreValues: Array.Empty<string>(),
|
||||
DhcpVendorclassValues: Array.Empty<string>(),
|
||||
DhcpUserclassValues: Array.Empty<string>(),
|
||||
RaParamValues: Array.Empty<string>(),
|
||||
SlaacValues: Array.Empty<string>(),
|
||||
PxeServiceValues: Array.Empty<string>(),
|
||||
TrustAnchorValues: Array.Empty<string>(),
|
||||
ResolvFiles: Array.Empty<string>(),
|
||||
RebindDomainOkValues: Array.Empty<string>(),
|
||||
BogusNxdomainValues: Array.Empty<string>(),
|
||||
IgnoreAddressValues: Array.Empty<string>(),
|
||||
AliasValues: Array.Empty<string>(),
|
||||
FilterRrValues: Array.Empty<string>(),
|
||||
CacheRrValues: Array.Empty<string>(),
|
||||
AuthServerValues: Array.Empty<string>(),
|
||||
NoDhcpInterfaceValues: Array.Empty<string>(),
|
||||
NoDhcpv4InterfaceValues: Array.Empty<string>(),
|
||||
NoDhcpv6InterfaceValues: Array.Empty<string>(),
|
||||
DomainValues: Array.Empty<string>(), CnameValues: Array.Empty<string>(), MxHostValues: Array.Empty<string>(), SrvValues: Array.Empty<string>(),
|
||||
PtrRecordValues: Array.Empty<string>(), TxtRecordValues: Array.Empty<string>(), NaptrRecordValues: Array.Empty<string>(),
|
||||
HostRecordValues: Array.Empty<string>(), DynamicHostValues: Array.Empty<string>(), InterfaceNameValues: Array.Empty<string>(),
|
||||
DhcpOptionForceLines: Array.Empty<string>(),
|
||||
IpsetValues: Array.Empty<string>(),
|
||||
NftsetValues: Array.Empty<string>(),
|
||||
DhcpMacValues: Array.Empty<string>(),
|
||||
DhcpNameMatchValues: Array.Empty<string>(),
|
||||
DhcpIgnoreNamesValues: Array.Empty<string>(),
|
||||
DhcpHostsfilePaths: Array.Empty<string>(),
|
||||
DhcpOptsfilePaths: Array.Empty<string>(),
|
||||
DhcpHostsdirPaths: Array.Empty<string>(),
|
||||
DhcpOptsdirPaths: Array.Empty<string>(),
|
||||
ConnmarkAllowlistValues: Array.Empty<string>(),
|
||||
CaaRecordValues: Array.Empty<string>(),
|
||||
DnsRrValues: Array.Empty<string>(),
|
||||
SynthDomainValues: Array.Empty<string>(),
|
||||
AuthZoneValues: Array.Empty<string>(),
|
||||
AuthSoaValues: Array.Empty<string>(),
|
||||
AuthSecServersValues: Array.Empty<string>(),
|
||||
AuthPeerValues: Array.Empty<string>(),
|
||||
DhcpRelayValues: Array.Empty<string>(),
|
||||
DhcpCircuitidValues: Array.Empty<string>(),
|
||||
DhcpRemoteidValues: Array.Empty<string>(),
|
||||
DhcpSubscridValues: Array.Empty<string>(),
|
||||
DhcpProxyValues: Array.Empty<string>(),
|
||||
TagIfValues: Array.Empty<string>(),
|
||||
BridgeInterfaceValues: Array.Empty<string>(),
|
||||
SharedNetworkValues: Array.Empty<string>(),
|
||||
DhcpOptionPxeValues: Array.Empty<string>(),
|
||||
ExpandHosts: false,
|
||||
BogusPriv: false,
|
||||
StrictOrder: false,
|
||||
AllServers: false,
|
||||
NoResolv: false,
|
||||
DomainNeeded: false,
|
||||
NoPoll: false,
|
||||
BindInterfaces: false,
|
||||
BindDynamic: false,
|
||||
NoNegcache: false,
|
||||
DnsLoopDetect: false,
|
||||
StopDnsRebind: false,
|
||||
RebindLocalhostOk: false,
|
||||
ClearOnReload: false,
|
||||
Filterwin2k: false,
|
||||
FilterA: false,
|
||||
FilterAaaa: false,
|
||||
LocaliseQueries: false,
|
||||
LogDebug: false,
|
||||
DhcpAuthoritative: false,
|
||||
LeasefileRo: false,
|
||||
EnableTftp: false,
|
||||
TftpSecure: false,
|
||||
TftpNoFail: false,
|
||||
TftpNoBlocksize: false,
|
||||
Dnssec: false,
|
||||
DnssecCheckUnsigned: null,
|
||||
ReadEthers: false,
|
||||
DhcpRapidCommit: false,
|
||||
Localmx: false,
|
||||
Selfmx: false,
|
||||
EnableRa: false,
|
||||
LogDhcp: false,
|
||||
KeepInForeground: false,
|
||||
NoDaemon: false,
|
||||
ProxyDnssec: false,
|
||||
ConnmarkAllowlistEnable: null,
|
||||
NoRoundRobin: false,
|
||||
DnssecNoTimecheck: false,
|
||||
DnssecDebug: false,
|
||||
LeasequeryValues: Array.Empty<string>(),
|
||||
DhcpGenerateNames: null,
|
||||
DhcpBroadcast: null,
|
||||
DhcpSequentialIp: false,
|
||||
DhcpIgnoreClid: false,
|
||||
BootpDynamic: null,
|
||||
NoPing: false,
|
||||
ScriptArp: false,
|
||||
ScriptOnRenewal: false,
|
||||
DhcpNoOverride: false,
|
||||
QuietDhcp: false,
|
||||
QuietDhcp6: false,
|
||||
QuietRa: false,
|
||||
QuietTftp: false,
|
||||
DhcpLeaseFilePath: null,
|
||||
CacheSize: null,
|
||||
Port: null,
|
||||
LocalTtl: null,
|
||||
PidFilePath: null,
|
||||
User: null,
|
||||
Group: null,
|
||||
LogFacility: logFacility,
|
||||
LogQueries: null,
|
||||
AuthTtl: null,
|
||||
EdnsPacketMax: null,
|
||||
QueryPort: null,
|
||||
PortLimit: null,
|
||||
MinPort: null,
|
||||
MaxPort: null,
|
||||
LogAsync: null,
|
||||
LocalService: null,
|
||||
DhcpLeaseMax: null,
|
||||
NegTtl: null,
|
||||
MaxTtl: null,
|
||||
MaxCacheTtl: null,
|
||||
MinCacheTtl: null,
|
||||
DhcpTtl: null,
|
||||
TftpRootPath: null,
|
||||
PxePrompt: null,
|
||||
EnableDbus: null,
|
||||
EnableUbus: null,
|
||||
FastDnsRetry: null,
|
||||
DhcpScriptPath: null,
|
||||
MxTarget: null,
|
||||
DnsForwardMax: null,
|
||||
DumpfilePath: null,
|
||||
Dumpmask: null,
|
||||
AddCpeId: null,
|
||||
DnssecTimestamp: null,
|
||||
DnssecLimits: null,
|
||||
DhcpAlternatePort: null,
|
||||
DhcpDuid: null,
|
||||
DhcpLuascriptPath: null,
|
||||
DhcpScriptuser: null,
|
||||
DhcpPxeVendor: null,
|
||||
UseStaleCache: null,
|
||||
AddMac: null,
|
||||
StripMac: false,
|
||||
AddSubnet: null,
|
||||
StripSubnet: false,
|
||||
Umbrella: null,
|
||||
Do0x20EncodeState: ExplicitToggleState.Default,
|
||||
Conntrack: false
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_NullConfig_ReturnsNull()
|
||||
{
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_NullLogFacility_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: null);
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_EmptyLogFacility_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "");
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_WhitespaceLogFacility_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: " ");
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_AbsolutePath_ReturnsPath()
|
||||
{
|
||||
var path = "/data/dnsmasq.log";
|
||||
var config = CreateConfig(logFacility: path);
|
||||
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_AbsolutePathWithTrim_ReturnsPath()
|
||||
{
|
||||
var path = "/var/log/dnsmasq.log";
|
||||
var pathWithSpaces = $" {path} ";
|
||||
var config = CreateConfig(logFacility: pathWithSpaces);
|
||||
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_RelativePathWithSlash_ReturnsPath()
|
||||
{
|
||||
var path = "logs/dnsmasq.log";
|
||||
var config = CreateConfig(logFacility: path);
|
||||
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_SyslogFacilityName_ReturnsNull()
|
||||
{
|
||||
var facility = "local0";
|
||||
var config = CreateConfig(logFacility: facility);
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_Stderr_ReturnsNull()
|
||||
{
|
||||
var facility = "-";
|
||||
var config = CreateConfig(logFacility: facility);
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
public class EffectiveMultiValueProjectionServiceTests
|
||||
{
|
||||
private readonly EffectiveMultiValueProjectionService _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void Project_ReusesBaselineSources_ByOccurrenceOrder()
|
||||
{
|
||||
var baseline = new[]
|
||||
{
|
||||
new ValueWithSource("same", new ConfigValueSource("/a.conf", "a.conf", true, 10)),
|
||||
new ValueWithSource("same", new ConfigValueSource("/b.conf", "b.conf", false, 20))
|
||||
};
|
||||
|
||||
var projected = _sut.Project(["same", "same"], baseline, "/managed.conf");
|
||||
|
||||
Assert.Equal("/a.conf", projected[0].Source?.FilePath);
|
||||
Assert.Equal("/b.conf", projected[1].Source?.FilePath);
|
||||
Assert.False(projected[0].IsDraftOnly);
|
||||
Assert.False(projected[1].IsDraftOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_UnmatchedRows_AreDraftEditableAndPointAtManagedPath()
|
||||
{
|
||||
var projected = _sut.Project(["draft-value"], [], "/managed.conf");
|
||||
|
||||
var row = Assert.Single(projected);
|
||||
Assert.True(row.IsDraftOnly);
|
||||
Assert.True(row.IsEditable);
|
||||
Assert.Null(row.Source);
|
||||
Assert.Equal("/managed.conf", row.DisplaySourcePath);
|
||||
Assert.Equal("managed.conf", row.DisplaySourceLabel);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
|
||||
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Tests.Helpers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
/// <summary>Corpus-driven semantic validation: bad samples produce errors for expected option keys only.</summary>
|
||||
public class OptionSemanticValidatorRealWorldCorpusTests
|
||||
{
|
||||
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
|
||||
new DomainSemanticHandler(),
|
||||
new DnsRrSemanticHandler(),
|
||||
new SynthDomainSemanticHandler(),
|
||||
new AuthServerSemanticHandler(),
|
||||
new SrvSemanticHandler(),
|
||||
]);
|
||||
|
||||
public static IEnumerable<object[]> GetCasesWithInvalidOptions()
|
||||
{
|
||||
foreach (var c in RealWorldCasesHelper.LoadCases())
|
||||
{
|
||||
if (c.SemanticInvalidOptions.Count > 0)
|
||||
yield return new object[] { c };
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetCasesWithInvalidOptions))]
|
||||
public void SemanticInvalidOptions_FromCorpus_AreRejected(CorpusCase c)
|
||||
{
|
||||
var mainPath = RealWorldCasesHelper.Resolve(c);
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
|
||||
foreach (var option in c.SemanticInvalidOptions)
|
||||
{
|
||||
var values = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, option);
|
||||
Assert.NotEmpty(values);
|
||||
|
||||
var anyInvalid = values.Any(v =>
|
||||
_validator.ValidateMultiItem(option, v, semantics) is not null);
|
||||
|
||||
Assert.True(anyInvalid, $"Expected at least one invalid value for option '{option}' in {c.File}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures every specialized option (one with a semantic handler) has at least one valid and one invalid
|
||||
/// test case; asserts only Null/NotNull, not error message text.
|
||||
/// </summary>
|
||||
public class OptionSemanticValidatorSpecializedTests
|
||||
{
|
||||
private static readonly OptionValidationSemantics ComplexAllowEmpty = new(OptionValidationKind.Complex, allowEmpty: true);
|
||||
|
||||
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
|
||||
new LeasequerySemanticHandler(),
|
||||
new ServerSemanticHandler(),
|
||||
new LocalSemanticHandler(),
|
||||
new RevServerSemanticHandler(),
|
||||
new AddressSemanticHandler(),
|
||||
new TrustAnchorSemanticHandler(),
|
||||
new AliasSemanticHandler(),
|
||||
new IpsetSemanticHandler(),
|
||||
new NftsetSemanticHandler(),
|
||||
new IgnoreAddressSemanticHandler(),
|
||||
new ConnmarkAllowlistSemanticHandler(),
|
||||
new DhcpRangeSemanticHandler(),
|
||||
new DhcpHostSemanticHandler(),
|
||||
new DhcpOptionSemanticHandler(),
|
||||
new DhcpMatchSemanticHandler(),
|
||||
new DhcpMacSemanticHandler(),
|
||||
new DhcpRelaySemanticHandler(),
|
||||
new DhcpProxySemanticHandler(),
|
||||
new RaParamSemanticHandler(),
|
||||
new DhcpNameMatchSemanticHandler(),
|
||||
new DhcpIgnoreSemanticHandler(),
|
||||
new DhcpVendorclassSemanticHandler(),
|
||||
new DhcpUserclassSemanticHandler(),
|
||||
new TagIfSemanticHandler(),
|
||||
new BridgeInterfaceSemanticHandler(),
|
||||
new SharedNetworkSemanticHandler(),
|
||||
new DhcpOptionPxeSemanticHandler(),
|
||||
new RebindDomainOkSemanticHandler(),
|
||||
new BogusNxdomainSemanticHandler(),
|
||||
new DhcpIgnoreNamesSemanticHandler(),
|
||||
new DhcpBootSemanticHandler(),
|
||||
new SlaacSemanticHandler(),
|
||||
new PxeServiceSemanticHandler(),
|
||||
new DhcpCircuitidSemanticHandler(),
|
||||
new DhcpRemoteidSemanticHandler(),
|
||||
new DhcpSubscrIdSemanticHandler(),
|
||||
new FilterRrSemanticHandler(),
|
||||
new CacheRrSemanticHandler(),
|
||||
new InterfaceNameSemanticHandler(),
|
||||
new AuthServerSemanticHandler(),
|
||||
new CnameSemanticHandler(),
|
||||
new MxHostSemanticHandler(),
|
||||
new PtrRecordSemanticHandler(),
|
||||
new InterfaceNameRecordSemanticHandler(),
|
||||
new CaaRecordSemanticHandler(),
|
||||
new SrvSemanticHandler(),
|
||||
new NaptrRecordSemanticHandler(),
|
||||
new DnsRrSemanticHandler(),
|
||||
new DynamicHostSemanticHandler(),
|
||||
new AuthSoaSemanticHandler(),
|
||||
new AuthSecServersSemanticHandler(),
|
||||
new AuthPeerSemanticHandler(),
|
||||
new HostRecordSemanticHandler(),
|
||||
new TxtRecordSemanticHandler(),
|
||||
new DomainSemanticHandler(),
|
||||
new SynthDomainSemanticHandler(),
|
||||
new AuthZoneSemanticHandler(),
|
||||
]);
|
||||
|
||||
/// <summary>Option key, one valid value (validator returns null), one invalid value (validator returns non-null).</summary>
|
||||
public static IEnumerable<object[]> GetSpecializedValidInvalidPairs()
|
||||
{
|
||||
yield return new object[] { DnsmasqConfKeys.Leasequery, "10.0.0.0/24", "not-an-ip" };
|
||||
yield return new object[] { DnsmasqConfKeys.Server, "1.1.1.1", "" };
|
||||
yield return new object[] { DnsmasqConfKeys.Local, "/lan/", "" };
|
||||
yield return new object[] { DnsmasqConfKeys.Address, "/example.com/127.0.0.1", "/bad..name/1.2.3.4" };
|
||||
yield return new object[] { DnsmasqConfKeys.Domain, "home.lan,192.168.1.0/24", "example.com,192.168.1.0/24,local,extra" };
|
||||
yield return new object[] { DnsmasqConfKeys.AuthServer, "zone.example.com,eth0", ",eth0" };
|
||||
yield return new object[] { DnsmasqConfKeys.SynthDomain, "dynamic.example.com,192.168.2.1,192.168.2.100", "example.com," };
|
||||
yield return new object[] { DnsmasqConfKeys.DnsRr, "example.com,16,01:02", "example.com,not-a-number" };
|
||||
yield return new object[] { DnsmasqConfKeys.Srv, "_http._tcp.example.com,host.example.com,80", "_sip._tcp,target,99999" };
|
||||
yield return new object[] { DnsmasqConfKeys.NaptrRecord, "example.com,0,0,a,s,r", "example.com,not-num,0,a,s,r" };
|
||||
yield return new object[] { DnsmasqConfKeys.TxtRecord, "example.com,text", "" };
|
||||
yield return new object[] { DnsmasqConfKeys.Cname, "www.example.com,example.com", "bad..name,example.com" };
|
||||
yield return new object[] { DnsmasqConfKeys.MxHost, "example.com,mail.example.com,10", "bad..name,mail.example.com,10" };
|
||||
yield return new object[] { DnsmasqConfKeys.PtrRecord, "1.168.192.in-addr.arpa,router.lan", "bad..arpa,host" };
|
||||
yield return new object[] { DnsmasqConfKeys.RebindDomainOk, "example.com", "bad..name" };
|
||||
yield return new object[] { DnsmasqConfKeys.BogusNxdomain, "192.168.1.1", "not-an-ip" };
|
||||
yield return new object[] { DnsmasqConfKeys.ConnmarkAllowlist, "0xff,example.com", "not-a-mark,example.com" };
|
||||
yield return new object[] { DnsmasqConfKeys.DhcpRange, "192.168.1.50,192.168.1.150,12h", "not-an-ip,192.168.1.150" };
|
||||
yield return new object[] { DnsmasqConfKeys.DhcpHost, "aa:bb:cc:dd:ee:ff,192.168.1.10", "ignore" };
|
||||
yield return new object[] { DnsmasqConfKeys.DhcpOption, "3,192.168.1.1", "option:," };
|
||||
yield return new object[] { DnsmasqConfKeys.DhcpMatch, "set:efi,option:client-arch,6", "option:client-arch,6" };
|
||||
yield return new object[] { DnsmasqConfKeys.DhcpMac, "set:vendor,aa:bb:cc:dd:ee:ff", "aa:bb:cc:dd:ee:ff" };
|
||||
yield return new object[] { DnsmasqConfKeys.DhcpNameMatch, "set:tag,hostname*", "hostname*" };
|
||||
yield return new object[] { DnsmasqConfKeys.DhcpIgnoreNames, "tag:guest", "guest" };
|
||||
yield return new object[] { DnsmasqConfKeys.AuthZone, "example.com", "bad..name" };
|
||||
yield return new object[] { DnsmasqConfKeys.FilterRr, "A,AAAA", "" };
|
||||
yield return new object[] { DnsmasqConfKeys.CacheRr, "A,TXT", "" };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetSpecializedValidInvalidPairs))]
|
||||
public void SpecializedOption_ValidAccepted_InvalidRejected(string optionKey, string validValue, string invalidValue)
|
||||
{
|
||||
var validErr = _validator.ValidateMultiItem(optionKey, validValue, ComplexAllowEmpty);
|
||||
Assert.Null(validErr);
|
||||
|
||||
var invalidErr = _validator.ValidateMultiItem(optionKey, invalidValue, ComplexAllowEmpty);
|
||||
Assert.NotNull(invalidErr);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,474 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Filters;
|
||||
using Xunit;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.Filters;
|
||||
|
||||
public class FiltersPageProjectionServiceTests
|
||||
{
|
||||
private static DnsmasqServiceStatus MinimalStatus(
|
||||
IReadOnlyList<string> addresses,
|
||||
IReadOnlyList<ValueWithSource>? addressSources = null)
|
||||
{
|
||||
var cfg = TestConfig(addresses);
|
||||
var sources = addressSources != null && addressSources.Count == addresses.Count
|
||||
? MinimalSources(addressSources)
|
||||
: null;
|
||||
return EmptyStatus() with { EffectiveConfig = cfg, EffectiveConfigSources = sources };
|
||||
}
|
||||
|
||||
private static EffectiveConfigSources MinimalSources(IReadOnlyList<ValueWithSource> addressValues) =>
|
||||
new(
|
||||
NoHosts: null,
|
||||
AddnHostsPaths: [],
|
||||
HostsdirPath: null,
|
||||
ServerValues: [],
|
||||
LocalValues: [],
|
||||
RevServerValues: [],
|
||||
AddressValues: addressValues,
|
||||
Interfaces: [],
|
||||
ListenAddresses: [],
|
||||
ExceptInterfaces: [],
|
||||
DhcpRanges: [],
|
||||
DhcpHostLines: [],
|
||||
DhcpOptionLines: [],
|
||||
DhcpMatchValues: [],
|
||||
DhcpBootValues: [],
|
||||
DhcpIgnoreValues: [],
|
||||
DhcpVendorclassValues: [],
|
||||
DhcpUserclassValues: [],
|
||||
RaParamValues: [],
|
||||
SlaacValues: [],
|
||||
PxeServiceValues: [],
|
||||
TrustAnchorValues: [],
|
||||
ResolvFiles: [],
|
||||
RebindDomainOkValues: [],
|
||||
BogusNxdomainValues: [],
|
||||
IgnoreAddressValues: [],
|
||||
AliasValues: [],
|
||||
FilterRrValues: [],
|
||||
CacheRrValues: [],
|
||||
AuthServerValues: [],
|
||||
NoDhcpInterfaceValues: [],
|
||||
NoDhcpv4InterfaceValues: [],
|
||||
NoDhcpv6InterfaceValues: [],
|
||||
DomainValues: [],
|
||||
CnameValues: [],
|
||||
MxHostValues: [],
|
||||
SrvValues: [],
|
||||
PtrRecordValues: [],
|
||||
TxtRecordValues: [],
|
||||
NaptrRecordValues: [],
|
||||
HostRecordValues: [],
|
||||
DynamicHostValues: [],
|
||||
InterfaceNameValues: [],
|
||||
DhcpOptionForceLines: [],
|
||||
IpsetValues: [],
|
||||
NftsetValues: [],
|
||||
DhcpMacValues: [],
|
||||
DhcpNameMatchValues: [],
|
||||
DhcpIgnoreNamesValues: [],
|
||||
DhcpHostsfilePaths: [],
|
||||
DhcpOptsfilePaths: [],
|
||||
DhcpHostsdirPaths: [],
|
||||
DhcpOptsdirPaths: [],
|
||||
ConnmarkAllowlistValues: [],
|
||||
CaaRecordValues: [],
|
||||
DnsRrValues: [],
|
||||
SynthDomainValues: [],
|
||||
AuthZoneValues: [],
|
||||
AuthSoaValues: [],
|
||||
AuthSecServersValues: [],
|
||||
AuthPeerValues: [],
|
||||
DhcpRelayValues: [],
|
||||
DhcpCircuitidValues: [],
|
||||
DhcpRemoteidValues: [],
|
||||
DhcpSubscridValues: [],
|
||||
DhcpProxyValues: [],
|
||||
TagIfValues: [],
|
||||
BridgeInterfaceValues: [],
|
||||
SharedNetworkValues: [],
|
||||
DhcpOptionPxeValues: [],
|
||||
ExpandHosts: null,
|
||||
BogusPriv: null,
|
||||
StrictOrder: null,
|
||||
AllServers: null,
|
||||
NoResolv: null,
|
||||
DomainNeeded: null,
|
||||
NoPoll: null,
|
||||
BindInterfaces: null,
|
||||
BindDynamic: null,
|
||||
NoNegcache: null,
|
||||
DnsLoopDetect: null,
|
||||
StopDnsRebind: null,
|
||||
RebindLocalhostOk: null,
|
||||
ClearOnReload: null,
|
||||
Filterwin2k: null,
|
||||
FilterA: null,
|
||||
FilterAaaa: null,
|
||||
LocaliseQueries: null,
|
||||
LogDebug: null,
|
||||
DhcpAuthoritative: null,
|
||||
LeasefileRo: null,
|
||||
EnableTftp: null,
|
||||
TftpSecure: null,
|
||||
TftpNoFail: null,
|
||||
TftpNoBlocksize: null,
|
||||
Dnssec: null,
|
||||
DnssecCheckUnsigned: null,
|
||||
ReadEthers: null,
|
||||
DhcpRapidCommit: null,
|
||||
Localmx: null,
|
||||
Selfmx: null,
|
||||
EnableRa: null,
|
||||
LogDhcp: null,
|
||||
KeepInForeground: null,
|
||||
NoDaemon: null,
|
||||
ProxyDnssec: null,
|
||||
ConnmarkAllowlistEnable: null,
|
||||
NoRoundRobin: null,
|
||||
DnssecNoTimecheck: null,
|
||||
DnssecDebug: null,
|
||||
LeasequeryValues: [],
|
||||
DhcpGenerateNames: null,
|
||||
DhcpBroadcast: null,
|
||||
DhcpSequentialIp: null,
|
||||
DhcpIgnoreClid: null,
|
||||
BootpDynamic: null,
|
||||
NoPing: null,
|
||||
ScriptArp: null,
|
||||
ScriptOnRenewal: null,
|
||||
DhcpNoOverride: null,
|
||||
QuietDhcp: null,
|
||||
QuietDhcp6: null,
|
||||
QuietRa: null,
|
||||
QuietTftp: null,
|
||||
DhcpLeaseFilePath: null,
|
||||
CacheSize: null,
|
||||
Port: null,
|
||||
LocalTtl: null,
|
||||
PidFilePath: null,
|
||||
User: null,
|
||||
Group: null,
|
||||
LogFacility: null,
|
||||
LogQueries: null,
|
||||
AuthTtl: null,
|
||||
EdnsPacketMax: null,
|
||||
QueryPort: null,
|
||||
PortLimit: null,
|
||||
MinPort: null,
|
||||
MaxPort: null,
|
||||
LogAsync: null,
|
||||
LocalService: null,
|
||||
DhcpLeaseMax: null,
|
||||
NegTtl: null,
|
||||
MaxTtl: null,
|
||||
MaxCacheTtl: null,
|
||||
MinCacheTtl: null,
|
||||
DhcpTtl: null,
|
||||
TftpRootPath: null,
|
||||
PxePrompt: null,
|
||||
EnableDbus: null,
|
||||
EnableUbus: null,
|
||||
FastDnsRetry: null,
|
||||
DhcpScriptPath: null,
|
||||
MxTarget: null,
|
||||
DnsForwardMax: null,
|
||||
DumpfilePath: null,
|
||||
Dumpmask: null,
|
||||
AddCpeId: null,
|
||||
DnssecTimestamp: null,
|
||||
DnssecLimits: null,
|
||||
DhcpAlternatePort: null,
|
||||
DhcpDuid: null,
|
||||
DhcpLuascriptPath: null,
|
||||
DhcpScriptuser: null,
|
||||
DhcpPxeVendor: null,
|
||||
UseStaleCache: null,
|
||||
AddMac: null,
|
||||
StripMac: null,
|
||||
AddSubnet: null,
|
||||
StripSubnet: null,
|
||||
Umbrella: null,
|
||||
Do0x20Encode: null,
|
||||
Conntrack: null);
|
||||
|
||||
private static EffectiveDnsmasqConfig TestConfig(IReadOnlyList<string> addresses) =>
|
||||
EmptyConfig() with { AddressValues = addresses };
|
||||
|
||||
private static EffectiveDnsmasqConfig EmptyConfig()
|
||||
{
|
||||
// Minimal non-null lists / defaults — only AddressValues overridden in tests
|
||||
return new EffectiveDnsmasqConfig(
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: [],
|
||||
HostsdirPath: null,
|
||||
ServerValues: [],
|
||||
LocalValues: [],
|
||||
RevServerValues: [],
|
||||
AddressValues: [],
|
||||
Interfaces: [],
|
||||
ListenAddresses: [],
|
||||
ExceptInterfaces: [],
|
||||
DhcpRanges: [],
|
||||
DhcpHostLines: [],
|
||||
DhcpOptionLines: [],
|
||||
DhcpMatchValues: [],
|
||||
DhcpBootValues: [],
|
||||
DhcpIgnoreValues: [],
|
||||
DhcpVendorclassValues: [],
|
||||
DhcpUserclassValues: [],
|
||||
RaParamValues: [],
|
||||
SlaacValues: [],
|
||||
PxeServiceValues: [],
|
||||
TrustAnchorValues: [],
|
||||
ResolvFiles: [],
|
||||
RebindDomainOkValues: [],
|
||||
BogusNxdomainValues: [],
|
||||
IgnoreAddressValues: [],
|
||||
AliasValues: [],
|
||||
FilterRrValues: [],
|
||||
CacheRrValues: [],
|
||||
AuthServerValues: [],
|
||||
NoDhcpInterfaceValues: [],
|
||||
NoDhcpv4InterfaceValues: [],
|
||||
NoDhcpv6InterfaceValues: [],
|
||||
DomainValues: [],
|
||||
CnameValues: [],
|
||||
MxHostValues: [],
|
||||
SrvValues: [],
|
||||
PtrRecordValues: [],
|
||||
TxtRecordValues: [],
|
||||
NaptrRecordValues: [],
|
||||
HostRecordValues: [],
|
||||
DynamicHostValues: [],
|
||||
InterfaceNameValues: [],
|
||||
DhcpOptionForceLines: [],
|
||||
IpsetValues: [],
|
||||
NftsetValues: [],
|
||||
DhcpMacValues: [],
|
||||
DhcpNameMatchValues: [],
|
||||
DhcpIgnoreNamesValues: [],
|
||||
DhcpHostsfilePaths: [],
|
||||
DhcpOptsfilePaths: [],
|
||||
DhcpHostsdirPaths: [],
|
||||
DhcpOptsdirPaths: [],
|
||||
ConnmarkAllowlistValues: [],
|
||||
CaaRecordValues: [],
|
||||
DnsRrValues: [],
|
||||
SynthDomainValues: [],
|
||||
AuthZoneValues: [],
|
||||
AuthSoaValues: [],
|
||||
AuthSecServersValues: [],
|
||||
AuthPeerValues: [],
|
||||
DhcpRelayValues: [],
|
||||
DhcpCircuitidValues: [],
|
||||
DhcpRemoteidValues: [],
|
||||
DhcpSubscridValues: [],
|
||||
DhcpProxyValues: [],
|
||||
TagIfValues: [],
|
||||
BridgeInterfaceValues: [],
|
||||
SharedNetworkValues: [],
|
||||
DhcpOptionPxeValues: [],
|
||||
ExpandHosts: false,
|
||||
BogusPriv: false,
|
||||
StrictOrder: false,
|
||||
AllServers: false,
|
||||
NoResolv: false,
|
||||
DomainNeeded: false,
|
||||
NoPoll: false,
|
||||
BindInterfaces: false,
|
||||
BindDynamic: false,
|
||||
NoNegcache: false,
|
||||
DnsLoopDetect: false,
|
||||
StopDnsRebind: false,
|
||||
RebindLocalhostOk: false,
|
||||
ClearOnReload: false,
|
||||
Filterwin2k: false,
|
||||
FilterA: false,
|
||||
FilterAaaa: false,
|
||||
LocaliseQueries: false,
|
||||
LogDebug: false,
|
||||
DhcpAuthoritative: false,
|
||||
LeasefileRo: false,
|
||||
EnableTftp: false,
|
||||
TftpSecure: false,
|
||||
TftpNoFail: false,
|
||||
TftpNoBlocksize: false,
|
||||
Dnssec: false,
|
||||
DnssecCheckUnsigned: null,
|
||||
ReadEthers: false,
|
||||
DhcpRapidCommit: false,
|
||||
Localmx: false,
|
||||
Selfmx: false,
|
||||
EnableRa: false,
|
||||
LogDhcp: false,
|
||||
KeepInForeground: false,
|
||||
NoDaemon: false,
|
||||
ProxyDnssec: false,
|
||||
ConnmarkAllowlistEnable: null,
|
||||
NoRoundRobin: false,
|
||||
DnssecNoTimecheck: false,
|
||||
DnssecDebug: false,
|
||||
LeasequeryValues: [],
|
||||
DhcpGenerateNames: null,
|
||||
DhcpBroadcast: null,
|
||||
DhcpSequentialIp: false,
|
||||
DhcpIgnoreClid: false,
|
||||
BootpDynamic: null,
|
||||
NoPing: false,
|
||||
ScriptArp: false,
|
||||
ScriptOnRenewal: false,
|
||||
DhcpNoOverride: false,
|
||||
QuietDhcp: false,
|
||||
QuietDhcp6: false,
|
||||
QuietRa: false,
|
||||
QuietTftp: false,
|
||||
DhcpLeaseFilePath: null,
|
||||
CacheSize: null,
|
||||
Port: null,
|
||||
LocalTtl: null,
|
||||
PidFilePath: null,
|
||||
User: null,
|
||||
Group: null,
|
||||
LogFacility: null,
|
||||
LogQueries: null,
|
||||
AuthTtl: null,
|
||||
EdnsPacketMax: null,
|
||||
QueryPort: null,
|
||||
PortLimit: null,
|
||||
MinPort: null,
|
||||
MaxPort: null,
|
||||
LogAsync: null,
|
||||
LocalService: null,
|
||||
DhcpLeaseMax: null,
|
||||
NegTtl: null,
|
||||
MaxTtl: null,
|
||||
MaxCacheTtl: null,
|
||||
MinCacheTtl: null,
|
||||
DhcpTtl: null,
|
||||
TftpRootPath: null,
|
||||
PxePrompt: null,
|
||||
EnableDbus: null,
|
||||
EnableUbus: null,
|
||||
FastDnsRetry: null,
|
||||
DhcpScriptPath: null,
|
||||
MxTarget: null,
|
||||
DnsForwardMax: null,
|
||||
DumpfilePath: null,
|
||||
Dumpmask: null,
|
||||
AddCpeId: null,
|
||||
DnssecTimestamp: null,
|
||||
DnssecLimits: null,
|
||||
DhcpAlternatePort: null,
|
||||
DhcpDuid: null,
|
||||
DhcpLuascriptPath: null,
|
||||
DhcpScriptuser: null,
|
||||
DhcpPxeVendor: null,
|
||||
UseStaleCache: null,
|
||||
AddMac: null,
|
||||
StripMac: false,
|
||||
AddSubnet: null,
|
||||
StripSubnet: false,
|
||||
Umbrella: null,
|
||||
Do0x20EncodeState: ExplicitToggleState.Default,
|
||||
Conntrack: false);
|
||||
}
|
||||
|
||||
private static DnsmasqServiceStatus EmptyStatus() =>
|
||||
new(
|
||||
SystemHostsPath: null,
|
||||
SystemHostsPathExists: false,
|
||||
ManagedHostsFilePath: null,
|
||||
ManagedHostsPathExists: false,
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: [],
|
||||
EffectiveConfig: null,
|
||||
EffectiveConfigSources: null,
|
||||
MainConfigPath: null,
|
||||
ManagedFilePath: "/managed.conf",
|
||||
LeasesPath: null,
|
||||
MainConfigPathExists: false,
|
||||
ManagedFilePathExists: true,
|
||||
LeasesPathConfigured: false,
|
||||
LeasesPathExists: false,
|
||||
ConfigFiles: [],
|
||||
ReloadCommandConfigured: false,
|
||||
StatusCommandConfigured: false,
|
||||
StatusShowConfigured: false,
|
||||
LogsConfigured: false,
|
||||
LogsPath: null,
|
||||
StatusShowCommand: null,
|
||||
LogsCommand: null,
|
||||
DnsmasqStatus: "active",
|
||||
StatusCommandExitCode: null,
|
||||
StatusCommandStdout: null,
|
||||
StatusCommandStderr: null,
|
||||
StatusShowOutput: null,
|
||||
LogsOutput: null,
|
||||
DhcpRangeStart: null,
|
||||
DhcpRangeEnd: null,
|
||||
DnsmasqVersion: null,
|
||||
MinimumSupportedDnsmasqVersion: "0",
|
||||
DnsmasqVersionSupported: true,
|
||||
DnsmasqVersionError: null,
|
||||
DnsmasqSupportsDhcp: false,
|
||||
DnsmasqSupportsTftp: false,
|
||||
DnsmasqSupportsDnssec: false,
|
||||
DnsmasqSupportsDbus: false);
|
||||
|
||||
[Fact]
|
||||
public void BuildGroups_MarksManagedAddressEditable()
|
||||
{
|
||||
var managed = new ConfigValueSource("/managed.conf", "managed.conf", IsManaged: true, 10);
|
||||
var status = MinimalStatus(
|
||||
["/ads.example/#"],
|
||||
[new ValueWithSource("/ads.example/#", managed)]);
|
||||
|
||||
var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter(), new EffectiveMultiValueProjectionService());
|
||||
var groups = svc.BuildGroups(status, new FilterPolicyQueryState("", null, null, null, null));
|
||||
var blocking = groups.First(g => g.Category == FilterPolicyCategory.Blocking);
|
||||
var row = Assert.Single(blocking.Rows);
|
||||
Assert.True(row.IsEditable);
|
||||
Assert.Equal(FilterPolicyKind.Address, row.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGroups_ReadOnlyWhenSourceNotManaged()
|
||||
{
|
||||
var ro = new ConfigValueSource("/etc/dnsmasq.d/z.conf", "z.conf", IsManaged: false, 2);
|
||||
var status = MinimalStatus(
|
||||
["/ads.example/#"],
|
||||
[new ValueWithSource("/ads.example/#", ro)]);
|
||||
|
||||
var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter(), new EffectiveMultiValueProjectionService());
|
||||
var groups = svc.BuildGroups(status, new FilterPolicyQueryState("", null, null, null, null));
|
||||
var row = Assert.Single(groups.First(g => g.Category == FilterPolicyCategory.Blocking).Rows);
|
||||
Assert.False(row.IsEditable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGroups_CurrentValuesAccessor_UsesDraftManagedRows()
|
||||
{
|
||||
var ro = new ConfigValueSource("/etc/dnsmasq.d/z.conf", "z.conf", IsManaged: false, 2);
|
||||
var status = MinimalStatus(
|
||||
["/ads.example/#"],
|
||||
[new ValueWithSource("/ads.example/#", ro)]);
|
||||
|
||||
var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter(), new EffectiveMultiValueProjectionService());
|
||||
var groups = svc.BuildGroups(
|
||||
status,
|
||||
new FilterPolicyQueryState("", null, null, null, null),
|
||||
optionName => optionName == DnsmasqConfKeys.Address ? ["/draft.example/#"] : []);
|
||||
|
||||
var row = Assert.Single(groups.First(g => g.Category == FilterPolicyCategory.Blocking).Rows);
|
||||
Assert.Equal("/draft.example/#", row.RawValue);
|
||||
Assert.True(row.IsEditable);
|
||||
Assert.True(row.IsDraftOnly);
|
||||
Assert.Equal("/managed.conf", row.SourcePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
|
||||
namespace DnsmasqWebUI.Tests.Services.Hosts;
|
||||
|
||||
public class HostsPageProjectionServiceTests
|
||||
{
|
||||
private readonly HostsPageProjectionService _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void BuildGroups_SourcePathFilter_KeepsOnlyThatFile()
|
||||
{
|
||||
var rows = new HostsPageRow[]
|
||||
{
|
||||
new(
|
||||
"m1",
|
||||
HostsRowSourceKind.Managed,
|
||||
"/data/managed.hosts",
|
||||
IsEditable: true,
|
||||
IsActive: true,
|
||||
InactiveReason: null,
|
||||
Address: "10.0.0.1",
|
||||
Names: new[] { "a" },
|
||||
EffectiveNames: new[] { "a" },
|
||||
IsComment: false,
|
||||
LineNumber: 1),
|
||||
new(
|
||||
"x1",
|
||||
HostsRowSourceKind.AddnHosts,
|
||||
"/etc/extra.hosts",
|
||||
IsEditable: false,
|
||||
IsActive: true,
|
||||
InactiveReason: null,
|
||||
Address: "10.0.0.2",
|
||||
Names: new[] { "b" },
|
||||
EffectiveNames: new[] { "b" },
|
||||
IsComment: false,
|
||||
LineNumber: 1),
|
||||
};
|
||||
|
||||
var query = new HostsPageQueryState(
|
||||
Search: "",
|
||||
Grouping: HostsGroupingMode.Source,
|
||||
Sort: HostsSortMode.LineNumber,
|
||||
Descending: false,
|
||||
SourceKindFilter: null,
|
||||
SourcePathFilter: "/etc/extra.hosts",
|
||||
EditableFilter: null,
|
||||
ActiveFilter: null);
|
||||
|
||||
var groups = _sut.BuildGroups(rows, query);
|
||||
|
||||
Assert.Single(groups);
|
||||
Assert.Equal("/etc/extra.hosts", groups[0].Subtitle);
|
||||
Assert.Single(groups[0].Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGroups_SourcePathFilter_EmptyMeansAllFiles()
|
||||
{
|
||||
var rows = new HostsPageRow[]
|
||||
{
|
||||
new("m1", HostsRowSourceKind.Managed, "/a", true, true, null, "1.1.1.1", new[] { "a" }, new[] { "a" }, false, 1),
|
||||
new("x1", HostsRowSourceKind.AddnHosts, "/b", false, true, null, "2.2.2.2", new[] { "b" }, new[] { "b" }, false, 1),
|
||||
};
|
||||
|
||||
var query = new HostsPageQueryState(
|
||||
"",
|
||||
HostsGroupingMode.Source,
|
||||
HostsSortMode.LineNumber,
|
||||
false,
|
||||
null,
|
||||
SourcePathFilter: null,
|
||||
null,
|
||||
null);
|
||||
|
||||
var groups = _sut.BuildGroups(rows, query);
|
||||
Assert.Equal(2, groups.Count);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using DnsmasqWebUI.Models.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Client.Http.Abstractions;
|
||||
|
||||
/// <summary>Typed client for GET api/config/set.</summary>
|
||||
public interface IConfigSetClient
|
||||
{
|
||||
Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.Status;
|
||||
|
||||
namespace DnsmasqWebUI.Client.Http.Abstractions;
|
||||
|
||||
/// <summary>Typed client for GET/PUT api/dhcp/hosts.</summary>
|
||||
public interface IDhcpHostsClient
|
||||
{
|
||||
Task<IReadOnlyList<DhcpHostEntry>> GetDhcpHostsAsync(CancellationToken ct = default);
|
||||
Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using DnsmasqWebUI.Models.Status;
|
||||
|
||||
namespace DnsmasqWebUI.Client.Http.Abstractions;
|
||||
|
||||
/// <summary>Typed client for GET/PUT api/hosts.</summary>
|
||||
public interface IHostsClient
|
||||
{
|
||||
Task<IReadOnlyList<HostEntry>> GetHostsAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ReadOnlyHostsFile>> GetReadOnlyHostsAsync(CancellationToken ct = default);
|
||||
Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using DnsmasqWebUI.Services.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Client.Http.Abstractions;
|
||||
|
||||
/// <summary>Typed client for POST api/reload.</summary>
|
||||
public interface IReloadClient
|
||||
{
|
||||
Task<ReloadResult> ReloadAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using DnsmasqWebUI.Models.Status;
|
||||
|
||||
namespace DnsmasqWebUI.Client.Http.Abstractions;
|
||||
|
||||
/// <summary>Typed client for GET api/status.</summary>
|
||||
public interface IStatusClient
|
||||
{
|
||||
Task<DnsmasqServiceStatus> GetStatusAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DnsmasqWebUI.Client.Http.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.Status;
|
||||
|
||||
namespace DnsmasqWebUI.Client.Http;
|
||||
|
||||
public sealed class DhcpHostsClient : IDhcpHostsClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public DhcpHostsClient(HttpClient http) => _http = http;
|
||||
|
||||
public async Task<IReadOnlyList<DhcpHostEntry>> GetDhcpHostsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var list = await _http.GetFromJsonAsync<List<DhcpHostEntry>>("api/dhcp/hosts", ct);
|
||||
return list ?? new List<DhcpHostEntry>();
|
||||
}
|
||||
|
||||
public async Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
var msg = response.ReasonPhrase ?? response.StatusCode.ToString();
|
||||
if (!string.IsNullOrEmpty(body) && body.TrimStart().StartsWith("{"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var err = System.Text.Json.JsonSerializer.Deserialize<JsonError>(body);
|
||||
if (!string.IsNullOrEmpty(err?.Error))
|
||||
msg = err.Error;
|
||||
}
|
||||
catch { /* use msg as-is */ }
|
||||
}
|
||||
throw new HttpRequestException(msg);
|
||||
}
|
||||
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct)
|
||||
?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts.");
|
||||
}
|
||||
|
||||
private sealed class JsonError { [JsonPropertyName("error")] public string? Error { get; set; } }
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using DnsmasqWebUI.Client.Http.Abstractions;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using DnsmasqWebUI.Models.Status;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace DnsmasqWebUI.Client.Http;
|
||||
|
||||
public sealed class HostsClient : IHostsClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public HostsClient(HttpClient http) => _http = http;
|
||||
|
||||
public async Task<IReadOnlyList<HostEntry>> GetHostsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var list = await _http.GetFromJsonAsync<List<HostEntry>>("api/hosts", ct);
|
||||
return list ?? new List<HostEntry>();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReadOnlyHostsFile>> GetReadOnlyHostsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var list = await _http.GetFromJsonAsync<List<ReadOnlyHostsFile>>("api/hosts/readonly", ct);
|
||||
return list ?? new List<ReadOnlyHostsFile>();
|
||||
}
|
||||
|
||||
public async Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.PutAsJsonAsync("api/hosts", entries, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct)
|
||||
?? throw new InvalidOperationException("Unexpected null from api/hosts.");
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using DnsmasqWebUI.Client.Http.Abstractions;
|
||||
using DnsmasqWebUI.Models.Status;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace DnsmasqWebUI.Client.Http;
|
||||
|
||||
public sealed class StatusClient : IStatusClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
/// <summary>Matches API serialization (camelCase) so nested EffectiveConfigSources and tuple (value, source) deserialize correctly.</summary>
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public StatusClient(HttpClient http) => _http = http;
|
||||
|
||||
public async Task<DnsmasqServiceStatus> GetStatusAsync(CancellationToken ct = default) =>
|
||||
await _http.GetFromJsonAsync<DnsmasqServiceStatus>("api/status", JsonOptions, ct)
|
||||
?? throw new InvalidOperationException("Unexpected null from api/status.");
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace DnsmasqWebUI.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Client-side preferences persisted in browser local storage.
|
||||
/// </summary>
|
||||
public class ClientSettings
|
||||
{
|
||||
/// <summary>Polling interval for the service status block (seconds).</summary>
|
||||
public int ServiceStatusPollingIntervalSeconds { get; set; } = 15;
|
||||
|
||||
/// <summary>Polling interval for the recent logs block (seconds).</summary>
|
||||
public int RecentLogsPollingIntervalSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Polling interval for the DHCP leases list (seconds).</summary>
|
||||
public int LeasesPollingIntervalSeconds { get; set; } = 5;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
namespace DnsmasqWebUI.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Which subset of client settings to show in the settings modal.
|
||||
/// </summary>
|
||||
public enum SettingsModalContext
|
||||
{
|
||||
/// <summary>Show all settings with optional search.</summary>
|
||||
All,
|
||||
|
||||
/// <summary>Show only service status polling interval.</summary>
|
||||
ServicePolling,
|
||||
|
||||
/// <summary>Show only recent logs polling interval.</summary>
|
||||
LogsPolling,
|
||||
|
||||
/// <summary>Show only DHCP leases refresh interval.</summary>
|
||||
LeasesPolling
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using DnsmasqWebUI.Client.Models;
|
||||
using DnsmasqWebUI.Client.Services.Abstractions;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and saves client-side settings to browser local storage via JS interop.
|
||||
/// Scoped per Blazor circuit; disposes the JS module when the scope is disposed.
|
||||
/// </summary>
|
||||
public sealed class ClientSettingsService : IClientSettingsService, IAsyncDisposable
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private IJSObjectReference? _module;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public ClientSettingsService(IJSRuntime jsRuntime) => _jsRuntime = jsRuntime;
|
||||
|
||||
private async Task<IJSObjectReference> GetModuleAsync()
|
||||
{
|
||||
if (_module != null) return _module;
|
||||
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./js/client-settings-storage.js");
|
||||
return _module;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ClientSettings> LoadSettingsAsync()
|
||||
{
|
||||
var module = await GetModuleAsync();
|
||||
var json = await module.InvokeAsync<string?>("getItem");
|
||||
if (string.IsNullOrWhiteSpace(json)) return new ClientSettings();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ClientSettings>(json, JsonOptions) ?? new ClientSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ClientSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveSettingsAsync(ClientSettings settings)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(settings, JsonOptions);
|
||||
var module = await GetModuleAsync();
|
||||
await module.InvokeVoidAsync("setItem", json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the JS module reference. Called when the Blazor circuit scope is disposed.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_module == null) return;
|
||||
try
|
||||
{
|
||||
await _module.DisposeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_module = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@using System.Reflection
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -6,26 +7,29 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="DnsmasqWebUI.styles.css" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
|
||||
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css?v=@_cacheBust" />
|
||||
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css?v=@_cacheBust" />
|
||||
<link rel="stylesheet" href="app.css?v=@_cacheBust" />
|
||||
<link rel="stylesheet" href="DnsmasqWebUI.styles.css?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png?v=@_cacheBust" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png?v=@_cacheBust" />
|
||||
<title>@_appTitle</title>
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="js/copy-to-clipboard.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@code {
|
||||
private static readonly string _cacheBust = typeof(App).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0";
|
||||
private string? _appTitle => AppOptions?.Value?.EffectiveTitle;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject INotificationService NotificationService
|
||||
@if (Rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted small">No rows for the current filters.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="app-table-scroll-shell" data-table-scroll-cue>
|
||||
<div class="app-table-scroll-region" data-table-scroll-region role="region" aria-label="DNS records table" tabindex="0">
|
||||
<table class="table table-bordered table-hover app-data-table dns-records-table mb-0" id="@TableId">
|
||||
<caption class="visually-hidden">@TableCaption</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="dns-records-type-th">
|
||||
<span class="align-middle">Type</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@TypeColumnHelpText"
|
||||
aria-label="@TypeColumnHelpText"
|
||||
@onclick="OnTypeHelpClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="dns-records-summary-th">
|
||||
<span class="align-middle">Summary</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@SummaryColumnHelpText"
|
||||
aria-label="@SummaryColumnHelpText"
|
||||
@onclick="OnSummaryHelpClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="dns-records-source-th">
|
||||
<span class="align-middle">Source</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@SourceColumnHelpText"
|
||||
aria-label="@SourceColumnHelpText"
|
||||
@onclick="OnSourceHelpClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="dns-records-status-th">
|
||||
<span class="align-middle">Status</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@StatusColumnHelpText"
|
||||
aria-label="@StatusColumnHelpText"
|
||||
@onclick="OnStatusHelpClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="dns-records-actions-col">
|
||||
<span class="align-middle">Actions</span>
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
|
||||
title="@ActionsColumnHelpText"
|
||||
aria-label="@ActionsColumnHelpText"
|
||||
@onclick="OnActionsHelpClick"
|
||||
@onclick:stopPropagation="true">
|
||||
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in Rows)
|
||||
{
|
||||
<tr @key="@row.OccurrenceId">
|
||||
<td><span class="badge bg-secondary">@row.OptionName</span></td>
|
||||
<td>
|
||||
<div class="dns-records-summary" title="@row.RawValue">@row.Summary</div>
|
||||
<code class="small text-muted dns-records-raw">@row.RawValue</code>
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (string.IsNullOrWhiteSpace(row.SourcePath))
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span title="@row.SourcePath">@(row.SourceLabel ?? row.SourcePath)</span>
|
||||
@if (row.IsDraftOnly)
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary-emphasis ms-1">draft</span>
|
||||
}
|
||||
else if (row.Source?.IsReadOnly == true)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">read-only</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success ms-1">editable</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (row.Issues.Count == 0)
|
||||
{
|
||||
<span class="text-muted">OK</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var issue in row.Issues.Take(3))
|
||||
{
|
||||
var cls = issue.Severity == FieldIssueSeverity.Error ? "text-danger" : "text-warning";
|
||||
<div class="@cls">@issue.Message</div>
|
||||
}
|
||||
@if (row.Issues.Count > 3)
|
||||
{
|
||||
<div class="text-muted">…</div>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@(!row.IsEditable)" @onclick="() => OnEditRow.InvokeAsync(row)">Edit</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" disabled="@(!row.IsEditable)" @onclick="() => OnDeleteRow.InvokeAsync(row)">Remove</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const string TypeColumnHelpText =
|
||||
"The dnsmasq option name for this line (for example cname, host-record, srv-host).";
|
||||
|
||||
private const string SummaryColumnHelpText =
|
||||
"A short plain-language description of the record. The monospace line below is the exact config value dnsmasq uses.";
|
||||
|
||||
private const string SourceColumnHelpText =
|
||||
"Which configuration file defined this line. Editable means the app can write changes to that source; read-only means it appears in the effective config but is not managed from the app’s writable file.";
|
||||
|
||||
private const string StatusColumnHelpText =
|
||||
"Per-line syntax validation from option semantics plus page-local warnings (duplicates, incomplete rows, or unusual combinations such as CNAME vs host-record on the same name).";
|
||||
|
||||
private const string ActionsColumnHelpText =
|
||||
"Edit opens the structured form for this directive when the row is editable. Remove drops this exact line from the effective multi-value list (same as deleting in Effective configuration).";
|
||||
|
||||
[Parameter] public string TableId { get; set; } = "dns-records-table";
|
||||
|
||||
[Parameter] public string TableCaption { get; set; } = "DNS record lines for scanning and editing";
|
||||
|
||||
[Parameter] public IReadOnlyList<DnsRecordRow> Rows { get; set; } = [];
|
||||
|
||||
[Parameter] public EventCallback<DnsRecordRow> OnEditRow { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<DnsRecordRow> OnDeleteRow { get; set; }
|
||||
|
||||
private void OnTypeHelpClick() => NotificationService.ShowInfo(TypeColumnHelpText);
|
||||
|
||||
private void OnSummaryHelpClick() => NotificationService.ShowInfo(SummaryColumnHelpText);
|
||||
|
||||
private void OnSourceHelpClick() => NotificationService.ShowInfo(SourceColumnHelpText);
|
||||
|
||||
private void OnStatusHelpClick() => NotificationService.ShowInfo(StatusColumnHelpText);
|
||||
|
||||
private void OnActionsHelpClick() => NotificationService.ShowInfo(ActionsColumnHelpText);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||
<h4 class="h6 mb-2">@_title</h4>
|
||||
@if (OptionName == DnsmasqConfKeys.NaptrRecord)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_nName" @bind:event="oninput" /></div>
|
||||
<div class="col-md-1"><label class="form-label small mb-0">Order</label><input class="form-control form-control-sm" @bind="_nOrder" @bind:event="oninput" /></div>
|
||||
<div class="col-md-1"><label class="form-label small mb-0">Pref</label><input class="form-control form-control-sm" @bind="_nPref" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Flags</label><input class="form-control form-control-sm" @bind="_nFlags" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Service</label><input class="form-control form-control-sm" @bind="_nService" @bind:event="oninput" /></div>
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Regexp</label><input class="form-control form-control-sm" @bind="_nRegexp" @bind:event="oninput" /></div>
|
||||
<div class="col-md-6"><label class="form-label small mb-0">Replacement (optional)</label><input class="form-control form-control-sm" @bind="_nRepl" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.CaaRecord)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_cName" @bind:event="oninput" /></div>
|
||||
<div class="col-md-1"><label class="form-label small mb-0">Flags</label><input class="form-control form-control-sm" @bind="_cFlags" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Tag</label><input class="form-control form-control-sm" @bind="_cTag" @bind:event="oninput" /></div>
|
||||
<div class="col-md-6"><label class="form-label small mb-0">Value</label><input class="form-control form-control-sm" @bind="_cValue" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.DnsRr)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_dName" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">RR number</label><input class="form-control form-control-sm" @bind="_dNum" @bind:event="oninput" /></div>
|
||||
<div class="col-md-7"><label class="form-label small mb-0">Hex data (optional)</label><input class="form-control form-control-sm" @bind="_dHex" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.DynamicHost)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_yName" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">IPv4</label><input class="form-control form-control-sm" @bind="_yV4" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">IPv6</label><input class="form-control form-control-sm" @bind="_yV6" @bind:event="oninput" /></div>
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Interface</label><input class="form-control form-control-sm" @bind="_yIface" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.InterfaceName)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label small mb-0">DNS name</label><input class="form-control form-control-sm" @bind="_iName" @bind:event="oninput" /></div>
|
||||
<div class="col-md-4"><label class="form-label small mb-0">Interface[/4|/6]</label><input class="form-control form-control-sm" @bind="_iIface" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.SynthDomain)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Domain</label><input class="form-control form-control-sm" @bind="_sDomain" @bind:event="oninput" /></div>
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Address range</label><input class="form-control form-control-sm" @bind="_sRange" @bind:event="oninput" /></div>
|
||||
<div class="col-md-4"><label class="form-label small mb-0">Prefix (optional)</label><input class="form-control form-control-sm" @bind="_sPrefix" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.AuthZone)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Domain</label><input class="form-control form-control-sm" @bind="_zDomain" @bind:event="oninput" /></div>
|
||||
<div class="col-md-9"><label class="form-label small mb-0">Subnets / exclude:… (comma-separated)</label><input class="form-control form-control-sm" @bind="_zRest" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.AuthSoa)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Serial</label><input class="form-control form-control-sm" @bind="_aSerial" @bind:event="oninput" /></div>
|
||||
<div class="col-md-3"><label class="form-label small mb-0">Hostmaster</label><input class="form-control form-control-sm" @bind="_aHm" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Refresh</label><input class="form-control form-control-sm" @bind="_aRef" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Retry</label><input class="form-control form-control-sm" @bind="_aRetry" @bind:event="oninput" /></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-0">Expiry</label><input class="form-control form-control-sm" @bind="_aEx" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.AuthSecServers)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-12"><label class="form-label small mb-0">Domains (comma-separated)</label><input class="form-control form-control-sm" @bind="_secDom" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
else if (OptionName == DnsmasqConfKeys.AuthPeer)
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-12"><label class="form-label small mb-0">IPs (comma-separated)</label><input class="form-control form-control-sm" @bind="_peerIp" @bind:event="oninput" /></div>
|
||||
</div>
|
||||
}
|
||||
<div class="mt-2 d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_error</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public string OptionName { get; set; } = "";
|
||||
[Parameter] public DnsRecordRow? Existing { get; set; }
|
||||
[Parameter] public EventCallback<DnsRecordEditorResult> OnSave { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private string _title = "Advanced record";
|
||||
private string? _error;
|
||||
|
||||
private string _nName = "", _nOrder = "", _nPref = "", _nFlags = "", _nService = "", _nRegexp = "", _nRepl = "";
|
||||
private string _cName = "", _cFlags = "", _cTag = "", _cValue = "";
|
||||
private string _dName = "", _dNum = "", _dHex = "";
|
||||
private string _yName = "", _yV4 = "", _yV6 = "", _yIface = "";
|
||||
private string _iName = "", _iIface = "";
|
||||
private string _sDomain = "", _sRange = "", _sPrefix = "";
|
||||
private string _zDomain = "", _zRest = "";
|
||||
private string _aSerial = "", _aHm = "", _aRef = "", _aRetry = "", _aEx = "";
|
||||
private string _secDom = "";
|
||||
private string _peerIp = "";
|
||||
private string? _boundRowKey;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_title = OptionName switch
|
||||
{
|
||||
DnsmasqConfKeys.NaptrRecord => "NAPTR",
|
||||
DnsmasqConfKeys.CaaRecord => "CAA",
|
||||
DnsmasqConfKeys.DnsRr => "dns-rr",
|
||||
DnsmasqConfKeys.DynamicHost => "dynamic-host",
|
||||
DnsmasqConfKeys.InterfaceName => "interface-name",
|
||||
DnsmasqConfKeys.SynthDomain => "synth-domain",
|
||||
DnsmasqConfKeys.AuthZone => "auth-zone",
|
||||
DnsmasqConfKeys.AuthSoa => "auth-soa",
|
||||
DnsmasqConfKeys.AuthSecServers => "auth-sec-servers",
|
||||
DnsmasqConfKeys.AuthPeer => "auth-peer",
|
||||
_ => OptionName
|
||||
};
|
||||
|
||||
if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing, $"add:{OptionName}"))
|
||||
return;
|
||||
|
||||
_error = null;
|
||||
switch (Existing?.Payload)
|
||||
{
|
||||
case NaptrPayload n:
|
||||
_nName = n.Name; _nOrder = n.Order; _nPref = n.Preference; _nFlags = n.Flags; _nService = n.Service; _nRegexp = n.Regexp; _nRepl = n.Replacement ?? "";
|
||||
break;
|
||||
case CaaPayload c:
|
||||
_cName = c.Name; _cFlags = c.Flags; _cTag = c.Tag; _cValue = c.Value;
|
||||
break;
|
||||
case DnsRrPayload d:
|
||||
_dName = d.Name; _dNum = d.RrNumber; _dHex = d.HexData ?? "";
|
||||
break;
|
||||
case DynamicHostPayload y:
|
||||
_yName = y.Name; _yV4 = y.IPv4 ?? ""; _yV6 = y.IPv6 ?? ""; _yIface = y.Interface;
|
||||
break;
|
||||
case InterfaceNamePayload i:
|
||||
_iName = i.DnsName; _iIface = i.InterfaceSpec;
|
||||
break;
|
||||
case SynthDomainPayload s:
|
||||
_sDomain = s.Domain; _sRange = s.AddressRange; _sPrefix = s.Prefix ?? "";
|
||||
break;
|
||||
case AuthZonePayload z:
|
||||
_zDomain = z.Domain; _zRest = z.SubnetsAndExcludes.Count > 0 ? string.Join(",", z.SubnetsAndExcludes) : "";
|
||||
break;
|
||||
case AuthSoaPayload a:
|
||||
_aSerial = a.Serial; _aHm = a.Hostmaster ?? ""; _aRef = a.Refresh ?? ""; _aRetry = a.Retry ?? ""; _aEx = a.Expiry ?? "";
|
||||
break;
|
||||
case AuthSecServersPayload sec:
|
||||
_secDom = string.Join(",", sec.Domains);
|
||||
break;
|
||||
case AuthPeerPayload ap:
|
||||
_peerIp = string.Join(",", ap.Ips);
|
||||
break;
|
||||
default:
|
||||
ClearIfNew();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearIfNew()
|
||||
{
|
||||
if (Existing != null) return;
|
||||
_nName = _nOrder = _nPref = _nFlags = _nService = _nRegexp = _nRepl = "";
|
||||
_cName = _cFlags = _cTag = _cValue = "";
|
||||
_dName = _dNum = _dHex = "";
|
||||
_yName = _yV4 = _yV6 = _yIface = "";
|
||||
_iName = _iIface = "";
|
||||
_sDomain = _sRange = _sPrefix = "";
|
||||
_zDomain = _zRest = "";
|
||||
_aSerial = _aHm = _aRef = _aRetry = _aEx = "";
|
||||
_secDom = "";
|
||||
_peerIp = "";
|
||||
}
|
||||
|
||||
private DnsRecordFamily FamilyForOption() => OptionName switch
|
||||
{
|
||||
DnsmasqConfKeys.NaptrRecord => DnsRecordFamily.Naptr,
|
||||
DnsmasqConfKeys.CaaRecord => DnsRecordFamily.Caa,
|
||||
DnsmasqConfKeys.DnsRr => DnsRecordFamily.DnsRr,
|
||||
DnsmasqConfKeys.DynamicHost => DnsRecordFamily.DynamicHost,
|
||||
DnsmasqConfKeys.InterfaceName => DnsRecordFamily.InterfaceName,
|
||||
DnsmasqConfKeys.SynthDomain => DnsRecordFamily.SynthDomain,
|
||||
DnsmasqConfKeys.AuthZone => DnsRecordFamily.AuthZone,
|
||||
DnsmasqConfKeys.AuthSoa => DnsRecordFamily.AuthSoa,
|
||||
DnsmasqConfKeys.AuthSecServers => DnsRecordFamily.AuthSecServers,
|
||||
DnsmasqConfKeys.AuthPeer => DnsRecordFamily.AuthPeer,
|
||||
_ => DnsRecordFamily.Cname
|
||||
};
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
DnsRecordPayload payload;
|
||||
try
|
||||
{
|
||||
payload = OptionName switch
|
||||
{
|
||||
DnsmasqConfKeys.NaptrRecord => new NaptrPayload(_nName.Trim(), _nOrder.Trim(), _nPref.Trim(), _nFlags.Trim(), _nService.Trim(), _nRegexp.Trim(), string.IsNullOrWhiteSpace(_nRepl) ? null : _nRepl.Trim()),
|
||||
DnsmasqConfKeys.CaaRecord => new CaaPayload(_cName.Trim(), _cFlags.Trim(), _cTag.Trim(), _cValue.Trim()),
|
||||
DnsmasqConfKeys.DnsRr => new DnsRrPayload(_dName.Trim(), _dNum.Trim(), string.IsNullOrWhiteSpace(_dHex) ? null : _dHex.Trim()),
|
||||
DnsmasqConfKeys.DynamicHost => new DynamicHostPayload(_yName.Trim(), string.IsNullOrWhiteSpace(_yV4) ? null : _yV4.Trim(), string.IsNullOrWhiteSpace(_yV6) ? null : _yV6.Trim(), _yIface.Trim()),
|
||||
DnsmasqConfKeys.InterfaceName => new InterfaceNamePayload(_iName.Trim(), _iIface.Trim()),
|
||||
DnsmasqConfKeys.SynthDomain => new SynthDomainPayload(_sDomain.Trim(), _sRange.Trim(), string.IsNullOrWhiteSpace(_sPrefix) ? null : _sPrefix.Trim()),
|
||||
DnsmasqConfKeys.AuthZone => new AuthZonePayload(_zDomain.Trim(), string.IsNullOrWhiteSpace(_zRest) ? [] : _zRest.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToArray()),
|
||||
DnsmasqConfKeys.AuthSoa => new AuthSoaPayload(_aSerial.Trim(), string.IsNullOrWhiteSpace(_aHm) ? null : _aHm.Trim(), string.IsNullOrWhiteSpace(_aRef) ? null : _aRef.Trim(), string.IsNullOrWhiteSpace(_aRetry) ? null : _aRetry.Trim(), string.IsNullOrWhiteSpace(_aEx) ? null : _aEx.Trim()),
|
||||
DnsmasqConfKeys.AuthSecServers => new AuthSecServersPayload(string.IsNullOrWhiteSpace(_secDom) ? [] : _secDom.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToArray()),
|
||||
DnsmasqConfKeys.AuthPeer => new AuthPeerPayload(string.IsNullOrWhiteSpace(_peerIp) ? [] : _peerIp.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToArray()),
|
||||
_ => throw new InvalidOperationException($"Unsupported option {OptionName}")
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
_error = "Invalid input.";
|
||||
return;
|
||||
}
|
||||
|
||||
var fam = FamilyForOption();
|
||||
var isNew = Existing == null;
|
||||
var row = isNew
|
||||
? new DnsRecordRow(
|
||||
Id: $"{OptionName}:new",
|
||||
OccurrenceId: $"{OptionName}:new",
|
||||
OptionName: OptionName,
|
||||
Family: fam,
|
||||
IndexInOption: -1,
|
||||
RawValue: "",
|
||||
Source: null,
|
||||
SourcePath: null,
|
||||
SourceLabel: null,
|
||||
IsDraftOnly: false,
|
||||
IsEditable: true,
|
||||
Payload: payload,
|
||||
Issues: [],
|
||||
Summary: "")
|
||||
: Existing! with { Payload = payload };
|
||||
|
||||
await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
|
||||
<h4 class="h6 mb-2">CNAME</h4>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-0">Aliases (comma-separated)</label>
|
||||
<input class="form-control form-control-sm" @bind="_aliases" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-0">Target</label>
|
||||
<input class="form-control form-control-sm" @bind="_target" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-0">TTL (optional)</label>
|
||||
<input class="form-control form-control-sm" type="number" min="1" @bind="_ttl" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error != null)
|
||||
{
|
||||
<p class="text-danger small mt-2 mb-0">@_error</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsRecordRow? Existing { get; set; }
|
||||
[Parameter] public EventCallback<DnsRecordEditorResult> OnSave { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private string _aliases = "";
|
||||
private string _target = "";
|
||||
private string _ttl = "";
|
||||
private string? _error;
|
||||
private string? _boundRowKey;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing))
|
||||
return;
|
||||
|
||||
if (Existing?.Payload is CnamePayload p)
|
||||
{
|
||||
_aliases = string.Join(", ", p.Aliases);
|
||||
_target = p.Target;
|
||||
_ttl = p.Ttl?.ToString() ?? "";
|
||||
}
|
||||
else
|
||||
{
|
||||
_aliases = "";
|
||||
_target = "";
|
||||
_ttl = "";
|
||||
}
|
||||
|
||||
_error = null;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
var parts = _aliases.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||
if (parts.Count == 0 || string.IsNullOrWhiteSpace(_target))
|
||||
{
|
||||
_error = "At least one alias and a target are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
int? ttl = null;
|
||||
if (!string.IsNullOrWhiteSpace(_ttl))
|
||||
{
|
||||
if (!int.TryParse(_ttl, out var t) || t <= 0)
|
||||
{
|
||||
_error = "TTL must be a positive integer.";
|
||||
return;
|
||||
}
|
||||
|
||||
ttl = t;
|
||||
}
|
||||
|
||||
var payload = new CnamePayload(parts, _target.Trim(), ttl);
|
||||
var isNew = Existing == null;
|
||||
var row = isNew
|
||||
? new DnsRecordRow(
|
||||
Id: $"{DnsmasqConfKeys.Cname}:new",
|
||||
OccurrenceId: $"{DnsmasqConfKeys.Cname}:new",
|
||||
OptionName: DnsmasqConfKeys.Cname,
|
||||
Family: DnsRecordFamily.Cname,
|
||||
IndexInOption: -1,
|
||||
RawValue: "",
|
||||
Source: null,
|
||||
SourcePath: null,
|
||||
SourceLabel: null,
|
||||
IsDraftOnly: false,
|
||||
IsEditable: true,
|
||||
Payload: payload,
|
||||
Issues: [],
|
||||
Summary: "")
|
||||
: Existing! with { Payload = payload };
|
||||
|
||||
await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user