mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+38
-8
@@ -2,7 +2,15 @@
|
|||||||
# Both run as root; file permissions work because config dirs are in the container.
|
# 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)
|
# 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.
|
# 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.
|
||||||
|
|
||||||
|
ARG DNSMASQ_VERSION=
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
@@ -19,15 +27,37 @@ RUN dotnet build "DnsmasqWebUI.csproj" -c Release -o /app/build
|
|||||||
FROM build AS publish
|
FROM build AS publish
|
||||||
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
FROM base AS app
|
FROM base AS final
|
||||||
|
ARG DNSMASQ_VERSION=latest
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
COPY --from=publish /app/publish .
|
# Install dnsmasq either from the distro package or by building an upstream release.
|
||||||
|
RUN if [ "$DNSMASQ_VERSION" = "distro" ]; then \
|
||||||
FROM app AS final
|
apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
|
&& rm -rf /var/lib/apt/lists/*; \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
else \
|
||||||
COPY scripts/entrypoint.sh .
|
apt-get update && apt-get install -y --no-install-recommends procps curl build-essential xz-utils ca-certificates \
|
||||||
COPY scripts/dnsmasq-status.sh .
|
&& 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
|
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"]
|
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.
|
- **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`).
|
- **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.
|
- 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.
|
- **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 -->
|
<!-- SUGGESTED SCREENSHOT: MAIN CONFIG OR CONFIG EDITOR PAGE IN THE BROWSER -->
|
||||||
@@ -163,7 +165,7 @@ COPY entrypoint.sh /entrypoint.sh
|
|||||||
ENTRYPOINT ["/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:**
|
**Config summary for containers:**
|
||||||
|
|
||||||
@@ -173,8 +175,11 @@ Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via e
|
|||||||
| `Dnsmasq__MainConfigPath` | `/data/dnsmasq.conf` | Main dnsmasq config path (required) |
|
| `Dnsmasq__MainConfigPath` | `/data/dnsmasq.conf` | Main dnsmasq config path (required) |
|
||||||
| `Dnsmasq__ReloadCommand` | `pkill -HUP -x dnsmasq` | Run after config changes |
|
| `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__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 |
|
| `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.
|
See [Configuration](#configuration) for all options.
|
||||||
|
|
||||||
<!-- SUGGESTED SCREENSHOT: EXAMPLE DOCKERFILE SNIPPET OR DOCKER RUN COMMAND SHOWING DNSMASQ + APP IN ONE CONTAINER -->
|
<!-- SUGGESTED SCREENSHOT: EXAMPLE DOCKERFILE SNIPPET OR DOCKER RUN COMMAND SHOWING DNSMASQ + APP IN ONE CONTAINER -->
|
||||||
@@ -205,6 +210,9 @@ The app is configured via **appsettings.json**, **environment variables**, and *
|
|||||||
| `StatusCommand` | Optional: check if dnsmasq is running | `pgrep -x dnsmasq` |
|
| `StatusCommand` | Optional: check if dnsmasq is running | `pgrep -x dnsmasq` |
|
||||||
| `StatusShowCommand` | Optional: full status output (e.g. systemctl status) | `systemctl status dnsmasq --no-pager` |
|
| `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` |
|
| `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):
|
**Application options** (use `Application__` prefix for env, `Application` section in JSON):
|
||||||
|
|
||||||
@@ -391,7 +399,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
|||||||
|
|
||||||
### scripts/prepare-test-mount.sh
|
### 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] [--]`
|
**Usage:** `./scripts/prepare-test-mount.sh [OPTIONS] [--]`
|
||||||
|
|
||||||
@@ -401,9 +409,12 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--source DIR` | Source to copy from (default: `testdata`). |
|
| `--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`. |
|
| `--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. |
|
| `--prepare-only` | Only prepare the mount; do not run docker compose. |
|
||||||
| `--build` | Pass `--build` to docker compose (rebuild images). Default: use existing images. |
|
| `--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). |
|
| `--no-build` | Do not rebuild (default). |
|
||||||
| `--recreate` | Pass `--force-recreate` to docker compose. |
|
| `--recreate` | Pass `--force-recreate` to docker compose. |
|
||||||
| `--stop` | Stop the harness: `docker compose down` (no prepare, no start). |
|
| `--stop` | Stop the harness: `docker compose down` (no prepare, no start). |
|
||||||
@@ -413,18 +424,28 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
|||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```bash
|
```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
|
./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
|
./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
|
# Only prepare the mount; start manually later
|
||||||
./scripts/prepare-test-mount.sh --prepare-only
|
./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
|
# Fresh managed config/hosts after a rebuild (fixtures still synced; leases kept)
|
||||||
./scripts/prepare-test-mount.sh --no-clear
|
./scripts/prepare-test-mount.sh --reset-managed --build
|
||||||
|
|
||||||
# Stop the harness
|
# Stop the harness
|
||||||
./scripts/prepare-test-mount.sh --stop
|
./scripts/prepare-test-mount.sh --stop
|
||||||
@@ -449,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.
|
**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 -->
|
<!-- SUGGESTED SCREENSHOT: TERMINAL WITH PREPARE-TEST-MOUNT.SH USAGE OR DOCKER COMPOSE UP OUTPUT -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -471,10 +507,10 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
|||||||
**Codebase overview:**
|
**Codebase overview:**
|
||||||
|
|
||||||
- **Entry and config:** `Program.cs` wires the host; configuration (e.g. `Dnsmasq__*`) lives in `Models/Config/` (`DnsmasqOptions`, `ApplicationOptions`, `DnsmasqOptionsValidator`). Options are validated at startup.
|
- **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/`), **Services/** and **Services/Abstractions/** (caches, config/hosts/leases services, reload, hosted services), **Parsers/** (dnsmasq config and hosts line parsers), **Helpers/Config/** and **Helpers/Http/** (option keys, encoding, tooltips, JSON options). 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.
|
- **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).
|
- **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/`.
|
- **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/ -->
|
<!-- SUGGESTED SCREENSHOT: SOLUTION EXPLORER OR FOLDER TREE OF SRC/ -->
|
||||||
|
|
||||||
@@ -499,7 +535,7 @@ Use `appsettings.json` or launch settings to set `Dnsmasq__MainConfigPath` and o
|
|||||||
```bash
|
```bash
|
||||||
./scripts/prepare-test-mount.sh
|
./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.
|
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:
|
This will:
|
||||||
|
|
||||||
1. Clear `testdata-mount/` (unless you pass `--no-clear`)
|
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. Sync `testdata/` → `testdata-mount/`
|
2. Optionally remove managed `*dnsmasq-webui*.conf` / `*dnsmasq-webui*.hosts` in the mount **only** if you pass `--reset-managed`.
|
||||||
3. Remove any `*dnsmasq-webui*.conf` in the mount so dnsmasq starts clean
|
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 --build`
|
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`
|
- **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`
|
- **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]`
|
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):** `./scripts/prepare-test-mount.sh --no-clear --no-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
|
### 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`)
|
- `--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.
|
- `--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:
|
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/** — 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-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.
|
- **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.
|
||||||
|
|||||||
+22
-7
@@ -1,7 +1,8 @@
|
|||||||
# Run the app with real dnsmasq and several DHCP clients so hosts/leases are real.
|
# 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.
|
# 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).
|
# 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).
|
# 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
|
# StatusShowCommand/LogsCommand use ps so the Service status / Recent logs sections show something
|
||||||
@@ -13,14 +14,24 @@ services:
|
|||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
args:
|
||||||
|
DNSMASQ_VERSION: ${DNSMASQ_VERSION:-latest}
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "-s", "http://localhost:8080/healthz/ready"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 5s
|
||||||
|
retries: 3
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
environment:
|
environment:
|
||||||
DNSMASQ_CONF: /data/dnsmasq-test.conf
|
DNSMASQ_CONF: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
|
||||||
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
|
Dnsmasq__MainConfigPath: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
|
||||||
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.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__ReloadCommand: "pkill -HUP -x dnsmasq"
|
||||||
Dnsmasq__StatusCommand: "pgrep -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)
|
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
|
||||||
@@ -41,7 +52,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
testnet: {}
|
testnet: {}
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
# DHCP + periodic DNS lookups (every 20s) so DNS cache/logs show activity.
|
# DHCP + periodic DNS lookups (every 20s) so DNS cache/logs show activity.
|
||||||
dhcp-client-dns-a:
|
dhcp-client-dns-a:
|
||||||
@@ -60,7 +72,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
testnet: {}
|
testnet: {}
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
# DHCP + periodic DNS lookups (every 45s); different interval for variety.
|
# DHCP + periodic DNS lookups (every 45s); different interval for variety.
|
||||||
dhcp-client-dns-b:
|
dhcp-client-dns-b:
|
||||||
@@ -79,7 +92,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
testnet: {}
|
testnet: {}
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
# Third DHCP-only client so the leases table has more entries.
|
# Third DHCP-only client so the leases table has more entries.
|
||||||
dhcp-client-2:
|
dhcp-client-2:
|
||||||
@@ -88,7 +102,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
testnet: {}
|
testnet: {}
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
testnet:
|
testnet:
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Simulates "systemctl status dnsmasq" output when run in a container without systemd.
|
# 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.
|
# 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)
|
pid=$(pgrep -x dnsmasq)
|
||||||
if [ -z "$pid" ]; then
|
if [ -z "$pid" ]; then
|
||||||
echo "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server"
|
echo "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server"
|
||||||
echo " Loaded: (container, no systemd)"
|
echo " Loaded: (container, no systemd)"
|
||||||
echo " Active: inactive (dead)"
|
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 ""
|
echo ""
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ except ImportError:
|
|||||||
OUT_DIR = "/out"
|
OUT_DIR = "/out"
|
||||||
URL = os.environ.get("URL", "https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html")
|
URL = os.environ.get("URL", "https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html")
|
||||||
|
|
||||||
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections. server + local = one UI row.
|
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections.
|
||||||
OPTION_KEYS = [
|
OPTION_KEYS = [
|
||||||
"no-hosts",
|
"no-hosts",
|
||||||
"addn-hosts",
|
"addn-hosts",
|
||||||
@@ -122,6 +122,7 @@ OPTION_KEYS = [
|
|||||||
"dhcp-hostsfile",
|
"dhcp-hostsfile",
|
||||||
"dhcp-optsfile",
|
"dhcp-optsfile",
|
||||||
"dhcp-hostsdir",
|
"dhcp-hostsdir",
|
||||||
|
"dhcp-optsdir",
|
||||||
"dhcp-boot",
|
"dhcp-boot",
|
||||||
"dhcp-ignore",
|
"dhcp-ignore",
|
||||||
"dhcp-vendorclass",
|
"dhcp-vendorclass",
|
||||||
@@ -171,6 +172,59 @@ OPTION_KEYS = [
|
|||||||
"keep-in-foreground",
|
"keep-in-foreground",
|
||||||
"no-daemon",
|
"no-daemon",
|
||||||
"conntrack",
|
"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)
|
long_opt_re = re.compile(r"--([a-z][a-z0-9-]*)(?:=[^,\s]*)?", re.IGNORECASE)
|
||||||
|
|||||||
+133
-10
@@ -3,8 +3,8 @@
|
|||||||
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
|
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
|
||||||
#
|
#
|
||||||
# What this script does:
|
# What this script does:
|
||||||
# Start (default): sync source -> mount (preserving existing unless --clear), clean up previous test data,
|
# Start (default): sync source -> mount (preserving existing unless --clear); optional --reset-managed
|
||||||
# then docker compose up -d [--build] [--force-recreate].
|
# removes app-written files in the mount. Then docker compose up -d [--build] [--force-recreate].
|
||||||
# --stop: docker compose down (stop and remove containers/networks).
|
# --stop: docker compose down (stop and remove containers/networks).
|
||||||
# --tidy: docker compose down, then clear the mount directory for a clean next run.
|
# --tidy: docker compose down, then clear the mount directory for a clean next run.
|
||||||
set -e
|
set -e
|
||||||
@@ -15,12 +15,16 @@ COMPOSE_FILE="docker-compose.test.yml"
|
|||||||
|
|
||||||
SOURCE_DIR=""
|
SOURCE_DIR=""
|
||||||
MOUNT_DIR=""
|
MOUNT_DIR=""
|
||||||
|
DNSMASQ_VERSION=""
|
||||||
PREPARE_ONLY=false
|
PREPARE_ONLY=false
|
||||||
BUILD=false
|
BUILD=false
|
||||||
|
NO_CACHE_BUILD=false
|
||||||
RECREATE=false
|
RECREATE=false
|
||||||
CLEAR=false
|
CLEAR=false
|
||||||
|
RESET_MANAGED=false
|
||||||
STOP=false
|
STOP=false
|
||||||
TIDY=false
|
TIDY=false
|
||||||
|
MINIMAL_CONF=false
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: $0 [OPTIONS] [--]"
|
echo "Usage: $0 [OPTIONS] [--]"
|
||||||
@@ -30,24 +34,35 @@ usage() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Steps (when starting):"
|
echo "Steps (when starting):"
|
||||||
echo " 1. Optionally clear mount dir (only with --clear), then sync source -> mount."
|
echo " 1. Optionally clear mount dir (only with --clear), then sync source -> mount."
|
||||||
echo " 2. Clean up previous test data (e.g. managed config) so the harness starts clean."
|
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 " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
|
||||||
echo ""
|
echo ""
|
||||||
echo "Path options:"
|
echo "Path options:"
|
||||||
echo " --source DIR Source to copy from (default: testdata)"
|
echo " --source DIR Source to copy from (default: testdata)"
|
||||||
echo " --mount DIR Target mount directory (default: testdata-mount)"
|
echo " --mount DIR Target mount directory (default: testdata-mount)"
|
||||||
echo " Compose uses TESTDATA_MOUNT; script exports it if you use --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 ""
|
||||||
echo "Mount behaviour:"
|
echo "Mount behaviour:"
|
||||||
echo " (default) Preserve mount dir; sync source over existing contents."
|
echo " (default) Preserve mount dir; sync source over existing contents."
|
||||||
echo " --clear Clear mount dir completely before sync. Use for a clean run."
|
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 ""
|
||||||
echo "Compose behaviour:"
|
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 " --prepare-only Only prepare the mount; do not run docker compose."
|
||||||
echo " Use to inspect or edit the mount before starting containers."
|
echo " Use to inspect or edit the mount before starting containers."
|
||||||
echo " --build Pass --build to docker compose (rebuild images before starting)."
|
echo " --build Pass --build to docker compose (rebuild images before starting)."
|
||||||
echo " Use after changing the app or Dockerfile. Default: use existing images."
|
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 " --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 " --recreate Pass --force-recreate to docker compose (recreate containers)."
|
||||||
echo " Use to ensure fresh container state and mounts."
|
echo " Use to ensure fresh container state and mounts."
|
||||||
echo ""
|
echo ""
|
||||||
@@ -69,9 +84,15 @@ usage() {
|
|||||||
echo " $0 --recreate"
|
echo " $0 --recreate"
|
||||||
echo " Sync, then up -d --force-recreate (fresh containers)."
|
echo " Sync, then up -d --force-recreate (fresh containers)."
|
||||||
echo ""
|
echo ""
|
||||||
|
echo " $0 --no-cache-build"
|
||||||
|
echo " Force a fresh image build (no Docker build cache), then start containers."
|
||||||
|
echo ""
|
||||||
echo " $0 --clear"
|
echo " $0 --clear"
|
||||||
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
|
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
|
||||||
echo ""
|
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 " $0 --prepare-only"
|
||||||
echo " Only 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 " Then run: docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||||
@@ -85,6 +106,38 @@ usage() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo " $0 --tidy"
|
echo " $0 --tidy"
|
||||||
echo " Stop harness and clear testdata-mount for a clean next run."
|
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
|
while [ $# -gt 0 ]; do
|
||||||
@@ -105,10 +158,20 @@ while [ $# -gt 0 ]; do
|
|||||||
MOUNT_DIR="$1"
|
MOUNT_DIR="$1"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--dnsmasq-version)
|
||||||
|
shift
|
||||||
|
[ $# -gt 0 ] || { echo "Error: --dnsmasq-version requires VERSION" >&2; exit 1; }
|
||||||
|
DNSMASQ_VERSION="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--clear)
|
--clear)
|
||||||
CLEAR=true
|
CLEAR=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--reset-managed)
|
||||||
|
RESET_MANAGED=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--prepare-only)
|
--prepare-only)
|
||||||
PREPARE_ONLY=true
|
PREPARE_ONLY=true
|
||||||
shift
|
shift
|
||||||
@@ -117,14 +180,24 @@ while [ $# -gt 0 ]; do
|
|||||||
BUILD=true
|
BUILD=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--no-cache-build)
|
||||||
|
NO_CACHE_BUILD=true
|
||||||
|
BUILD=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--no-build)
|
--no-build)
|
||||||
BUILD=false
|
BUILD=false
|
||||||
|
NO_CACHE_BUILD=false
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--recreate)
|
--recreate)
|
||||||
RECREATE=true
|
RECREATE=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--minimal-conf)
|
||||||
|
MINIMAL_CONF=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--stop)
|
--stop)
|
||||||
STOP=true
|
STOP=true
|
||||||
shift
|
shift
|
||||||
@@ -160,6 +233,7 @@ cd "$REPO_ROOT"
|
|||||||
|
|
||||||
: "${SOURCE_DIR:=testdata}"
|
: "${SOURCE_DIR:=testdata}"
|
||||||
: "${MOUNT_DIR:=testdata-mount}"
|
: "${MOUNT_DIR:=testdata-mount}"
|
||||||
|
: "${DNSMASQ_VERSION:=latest}"
|
||||||
|
|
||||||
# Stop and/or tidy: no prepare, no start
|
# Stop and/or tidy: no prepare, no start
|
||||||
if [ "$STOP" = true ] || [ "$TIDY" = true ]; then
|
if [ "$STOP" = true ] || [ "$TIDY" = true ]; then
|
||||||
@@ -182,6 +256,17 @@ if [ ! -d "$SOURCE_DIR" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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.
|
# 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"
|
echo "Stopping test harness: docker compose -f $COMPOSE_FILE down"
|
||||||
docker compose -f "$COMPOSE_FILE" down
|
docker compose -f "$COMPOSE_FILE" down
|
||||||
@@ -193,15 +278,22 @@ if [ "$CLEAR" = true ]; then
|
|||||||
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
|
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
|
||||||
fi
|
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
|
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
|
else
|
||||||
cp -r "$SOURCE_DIR/." "$MOUNT_DIR/"
|
(cd "$SOURCE_DIR" && tar cf - --exclude=leases --exclude=zz-dnsmasq-webui.conf --exclude=zz-dnsmasq-webui.hosts .) | (cd "$MOUNT_DIR" && tar xf -)
|
||||||
rm -f "$MOUNT_DIR/leases"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean up previous test data (e.g. managed config) so the harness starts clean (app will create zz-dnsmasq-webui.conf on startup).
|
if [ "$RESET_MANAGED" = true ]; then
|
||||||
find "$MOUNT_DIR" -name '*dnsmasq-webui*.conf' -type f -delete 2>/dev/null || true
|
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)."
|
echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR)."
|
||||||
|
|
||||||
@@ -210,7 +302,16 @@ if [ "$PREPARE_ONLY" = true ]; then
|
|||||||
/*) MOUNT_EXPORT="$MOUNT_DIR" ;;
|
/*) MOUNT_EXPORT="$MOUNT_DIR" ;;
|
||||||
*) MOUNT_EXPORT="./$MOUNT_DIR" ;;
|
*) MOUNT_EXPORT="./$MOUNT_DIR" ;;
|
||||||
esac
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -220,8 +321,30 @@ case "$MOUNT_DIR" in
|
|||||||
*) export TESTDATA_MOUNT="./$MOUNT_DIR" ;;
|
*) export TESTDATA_MOUNT="./$MOUNT_DIR" ;;
|
||||||
esac
|
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"
|
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"
|
COMPOSE_CMD="$COMPOSE_CMD --build"
|
||||||
fi
|
fi
|
||||||
if [ "$RECREATE" = true ]; then
|
if [ "$RECREATE" = true ]; then
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
using DnsmasqWebUI.Models.Dnsmasq.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 fileName = "02.conf";
|
|
||||||
var fullPath = $"/etc/dnsmasq.d/{fileName}";
|
|
||||||
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: null);
|
|
||||||
var tooltip = source.GetReadOnlyTooltip();
|
|
||||||
Assert.NotNull(tooltip);
|
|
||||||
Assert.Contains(fileName, tooltip);
|
|
||||||
Assert.Contains("readonly", tooltip);
|
|
||||||
Assert.Contains(fullPath, tooltip);
|
|
||||||
Assert.DoesNotContain("line", tooltip);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetReadOnlyTooltip_WhenReadOnlyWithLineNumber_IncludesLineNumber()
|
|
||||||
{
|
|
||||||
var fileName = "02.conf";
|
|
||||||
var fullPath = $"/etc/dnsmasq.d/{fileName}";
|
|
||||||
int? lineNumber = 3;
|
|
||||||
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: lineNumber);
|
|
||||||
var tooltip = source.GetReadOnlyTooltip();
|
|
||||||
Assert.NotNull(tooltip);
|
|
||||||
Assert.Contains(fileName, tooltip);
|
|
||||||
Assert.Contains($"line {lineNumber}", tooltip);
|
|
||||||
Assert.Contains("readonly", tooltip);
|
|
||||||
Assert.Contains(fullPath, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
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.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>
|
/// <summary>
|
||||||
/// Resolves paths to testdata files. Files are copied to output (testdata/) when the project builds.
|
/// Resolves paths to testdata files. Files are copied to output (testdata/) when the project builds.
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -1,7 +1,9 @@
|
|||||||
using DnsmasqWebUI.Models.Dhcp;
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
using DnsmasqWebUI.Tests.Helpers;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for DnsmasqConfDhcpHostLineParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore]
|
/// Tests for DnsmasqConfDhcpHostLineParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore]
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for DnsmasqConfDirectiveParser. Key/value parsing (TryParseKeyValue) and comment stripping (StripComment)
|
/// Tests for DnsmasqConfDirectiveParser. Key/value parsing (TryParseKeyValue) and comment stripping (StripComment)
|
||||||
+4
-2
@@ -1,8 +1,10 @@
|
|||||||
using DnsmasqWebUI.Models.Config;
|
using DnsmasqWebUI.Models.Config;
|
||||||
using DnsmasqWebUI.Models.Dhcp;
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
using DnsmasqWebUI.Tests.Helpers;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for DnsmasqConfFileLineParser. Parses dnsmasq config file lines into Blank, Comment, AddnHosts, DhcpHost, Other.
|
/// Tests for DnsmasqConfFileLineParser. Parses dnsmasq config file lines into Blank, Comment, AddnHosts, DhcpHost, Other.
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Parser semantics: flags (option present/absent/with value), last-wins, case-sensitive, path order.</summary>
|
/// <summary>Parser semantics: flags (option present/absent/with value), last-wins, case-sensitive, path order.</summary>
|
||||||
+24
-6
@@ -1,8 +1,8 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>DHCP advanced, TFTP/PXE, DNSSEC, enable-dbus/ubus, fast-dns-retry.</summary>
|
/// <summary>DHCP advanced, TFTP/PXE, DNSSEC, enable-dbus/ubus, fast-dns-retry.</summary>
|
||||||
@@ -283,7 +283,7 @@ public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetFlagFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsTrue()
|
public void GetLastValueFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsValue()
|
||||||
{
|
{
|
||||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-" + Guid.NewGuid().ToString("N"));
|
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-" + Guid.NewGuid().ToString("N"));
|
||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
@@ -291,8 +291,26 @@ public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.WriteAllText(conf, "dnssec-check-unsigned\n");
|
File.WriteAllText(conf, "dnssec-check-unsigned\n");
|
||||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
|
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||||
Assert.True(result);
|
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
|
finally
|
||||||
{
|
{
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
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>
|
/// <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>
|
||||||
+6
-4
@@ -1,8 +1,10 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
using DnsmasqWebUI.Tests.Helpers;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Hosts (addn-hosts, hostsdir) and DHCP lease file parsing.</summary>
|
/// <summary>Hosts (addn-hosts, hostsdir) and DHCP lease file parsing.</summary>
|
||||||
@@ -26,7 +28,7 @@ public class DnsmasqConfIncludeParserHostsAndLeaseTests
|
|||||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||||
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
|
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
|
||||||
var paths = new[] { mainPath };
|
var paths = new[] { mainPath };
|
||||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||||
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
|
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
|
||||||
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
|
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Config discovery: conf-file=, conf-dir=, GetIncludedPaths, GetFirstConfDir, GetIncludedPathsWithSource, conf-dir suffix.</summary>
|
/// <summary>Config discovery: conf-file=, conf-dir=, GetIncludedPaths, GetFirstConfDir, GetIncludedPathsWithSource, conf-dir suffix.</summary>
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Interfaces, auth-server, DHCP/option/resolv multi-value, rebind/bogus/ignore/alias, filter-rr, cache-rr.</summary>
|
/// <summary>Interfaces, auth-server, DHCP/option/resolv multi-value, rebind/bogus/ignore/alias, filter-rr, cache-rr.</summary>
|
||||||
+163
-8
@@ -1,8 +1,8 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Positive parser tests for added options: key wired and "option present" behaviour.
|
/// Positive parser tests for added options: key wired and "option present" behaviour.
|
||||||
@@ -79,6 +79,137 @@ public class DnsmasqConfIncludeParserNewOptionsTests
|
|||||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.ProxyDnssec);
|
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) ---
|
// --- Multi-value (6): key=value line(s) → GetMultiValue returns value(s) ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -258,16 +389,27 @@ public class DnsmasqConfIncludeParserNewOptionsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetLastValueFromConfigFiles_Conntrack_WhenSet_ReturnsValue()
|
public void GetFlagFromConfigFiles_Conntrack_WhenPresent_ReturnsTrue()
|
||||||
{
|
{
|
||||||
var dir = CreateTempDir();
|
var dir = CreateTempDir();
|
||||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||||
var value = "42";
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.WriteAllText(conf, $"conntrack={value}\n");
|
File.WriteAllText(conf, "conntrack\n");
|
||||||
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack);
|
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack));
|
||||||
Assert.Equal(value, result);
|
}
|
||||||
|
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); }
|
finally { Directory.Delete(dir, recursive: true); }
|
||||||
}
|
}
|
||||||
@@ -284,6 +426,19 @@ public class DnsmasqConfIncludeParserNewOptionsTests
|
|||||||
finally { Directory.Delete(dir, recursive: true); }
|
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()
|
private static string CreateTempDir()
|
||||||
{
|
{
|
||||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-newopts-" + Guid.NewGuid().ToString("N"));
|
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-newopts-" + Guid.NewGuid().ToString("N"));
|
||||||
+26
-30
@@ -1,7 +1,8 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
using DnsmasqWebUI.Tests.Helpers;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests that the parser correctly handles the official dnsmasq example (testdata/dnsmasq.official.conf.example).
|
/// Tests that the parser correctly handles the official dnsmasq example (testdata/dnsmasq.official.conf.example).
|
||||||
@@ -45,7 +46,8 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
|
|||||||
{
|
{
|
||||||
var mainPath = GetOfficialExamplePath();
|
var mainPath = GetOfficialExamplePath();
|
||||||
var paths = new[] { mainPath };
|
var paths = new[] { mainPath };
|
||||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||||
|
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
|
||||||
var dhcpOptionForce = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpOptionForce);
|
var dhcpOptionForce = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpOptionForce);
|
||||||
var ipset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Ipset);
|
var ipset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Ipset);
|
||||||
var nftset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Nftset);
|
var nftset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Nftset);
|
||||||
@@ -53,6 +55,7 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
|
|||||||
var dhcpNameMatch = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpNameMatch);
|
var dhcpNameMatch = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpNameMatch);
|
||||||
var dhcpIgnoreNames = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpIgnoreNames);
|
var dhcpIgnoreNames = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpIgnoreNames);
|
||||||
Assert.Empty(servers);
|
Assert.Empty(servers);
|
||||||
|
Assert.Empty(locals);
|
||||||
Assert.Empty(dhcpOptionForce);
|
Assert.Empty(dhcpOptionForce);
|
||||||
Assert.Empty(ipset);
|
Assert.Empty(ipset);
|
||||||
Assert.Empty(nftset);
|
Assert.Empty(nftset);
|
||||||
@@ -77,32 +80,6 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
|
|||||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.LogDhcp));
|
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.LogDhcp));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void OfficialExample_FileContainsExpectedOptionNames()
|
|
||||||
{
|
|
||||||
var path = GetOfficialExamplePath();
|
|
||||||
var content = File.ReadAllText(path);
|
|
||||||
// Sanity: official example documents the options we support (as commented lines).
|
|
||||||
Assert.Contains("read-ethers", content);
|
|
||||||
Assert.Contains("dhcp-option-force", content);
|
|
||||||
Assert.Contains("dhcp-rapid-commit", content);
|
|
||||||
Assert.Contains("dhcp-script", content);
|
|
||||||
Assert.Contains("tftp-no-fail", content);
|
|
||||||
Assert.Contains("tftp-no-blocksize", content);
|
|
||||||
Assert.Contains("mx-target", content);
|
|
||||||
Assert.Contains("localmx", content);
|
|
||||||
Assert.Contains("selfmx", content);
|
|
||||||
Assert.Contains("enable-ra", content);
|
|
||||||
Assert.Contains("log-dhcp", content);
|
|
||||||
Assert.Contains("ipset=", content);
|
|
||||||
Assert.Contains("nftset=", content);
|
|
||||||
Assert.Contains("dhcp-mac", content);
|
|
||||||
Assert.Contains("dhcp-name-match", content);
|
|
||||||
Assert.Contains("dhcp-ignore-names", content);
|
|
||||||
// SRV records use option name srv-host (not "srv")
|
|
||||||
Assert.Contains("srv-host", content);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetAddnHostsPathsFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
|
public void GetAddnHostsPathsFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
|
||||||
{
|
{
|
||||||
@@ -121,4 +98,23 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
|
|||||||
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
|
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
|
||||||
Assert.False(noHosts);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
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>
|
/// <summary>DNS records and local names: hostsdir, domain, cname, mx-host, srv, ptr/txt/naptr/host-record, dynamic-host, interface-name.</summary>
|
||||||
+10
-8
@@ -1,8 +1,8 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Server/local, rev-server, dhcp-range, all-servers, log-queries.</summary>
|
/// <summary>Server/local, rev-server, dhcp-range, all-servers, log-queries.</summary>
|
||||||
@@ -23,11 +23,13 @@ public class DnsmasqConfIncludeParserServerAndResolvTests
|
|||||||
{
|
{
|
||||||
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
|
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
|
||||||
File.WriteAllText(f2, $"server={v3}\n");
|
File.WriteAllText(f2, $"server={v3}\n");
|
||||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
|
var resultServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Server);
|
||||||
Assert.Equal(3, result.Count);
|
var resultLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Local);
|
||||||
Assert.Equal(v1, result[0]);
|
Assert.Equal(2, resultServer.Count);
|
||||||
Assert.Equal(v2, result[1]);
|
Assert.Equal(v1, resultServer[0]);
|
||||||
Assert.Equal(v3, result[2]);
|
Assert.Equal(v3, resultServer[1]);
|
||||||
|
Assert.Single(resultLocal);
|
||||||
|
Assert.Equal(v2, resultLocal[0]);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
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>
|
/// <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>
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -1,7 +1,9 @@
|
|||||||
using DnsmasqWebUI.Models.Dhcp;
|
using DnsmasqWebUI.Models.Dhcp;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
using DnsmasqWebUI.Tests.Helpers;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for DnsmasqLeasesFileLineParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated
|
/// Tests for DnsmasqLeasesFileLineParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated
|
||||||
+15
-2
@@ -1,7 +1,9 @@
|
|||||||
using DnsmasqWebUI.Models.Hosts;
|
using DnsmasqWebUI.Models.Hosts;
|
||||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
using DnsmasqWebUI.Tests.Helpers;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for HostsFileLineParser. Format: IP_address canonical_hostname [aliases...] (hosts(5), RFC 952).
|
/// Tests for HostsFileLineParser. Format: IP_address canonical_hostname [aliases...] (hosts(5), RFC 952).
|
||||||
@@ -137,6 +139,17 @@ public class HostsFileLineParserTests
|
|||||||
Assert.Equal(address, e.Address);
|
Assert.Equal(address, e.Address);
|
||||||
Assert.Single(e.Names);
|
Assert.Single(e.Names);
|
||||||
Assert.Equal(name, 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]
|
[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,186 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -1,11 +1,11 @@
|
|||||||
using DnsmasqWebUI.Models.Config;
|
using DnsmasqWebUI.Models.Config;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
using DnsmasqWebUI.Infrastructure.Services;
|
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for DnsmasqConfigSetService, including GetEffectiveConfigWithSources.
|
/// Tests for DnsmasqConfigSetService, including GetEffectiveConfigWithSources.
|
||||||
@@ -25,7 +25,8 @@ public class DnsmasqConfigSetServiceTests
|
|||||||
Assert.Empty(config.AddnHostsPaths);
|
Assert.Empty(config.AddnHostsPaths);
|
||||||
Assert.NotNull(sources);
|
Assert.NotNull(sources);
|
||||||
Assert.Empty(sources.AddnHostsPaths);
|
Assert.Empty(sources.AddnHostsPaths);
|
||||||
Assert.Empty(sources.ServerLocalValues);
|
Assert.Empty(sources.ServerValues);
|
||||||
|
Assert.Empty(sources.LocalValues);
|
||||||
Assert.Empty(sources.DhcpRanges);
|
Assert.Empty(sources.DhcpRanges);
|
||||||
Assert.Null(sources.NoHosts);
|
Assert.Null(sources.NoHosts);
|
||||||
Assert.Null(sources.Port);
|
Assert.Null(sources.Port);
|
||||||
@@ -59,7 +60,8 @@ public class DnsmasqConfigSetServiceTests
|
|||||||
Assert.Equal(cacheSize, config.CacheSize);
|
Assert.Equal(cacheSize, config.CacheSize);
|
||||||
Assert.Single(config.AddnHostsPaths);
|
Assert.Single(config.AddnHostsPaths);
|
||||||
Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
|
Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
|
||||||
Assert.Empty(config.ServerLocalValues);
|
Assert.Empty(config.ServerValues);
|
||||||
|
Assert.Empty(config.LocalValues);
|
||||||
Assert.Empty(config.DhcpRanges);
|
Assert.Empty(config.DhcpRanges);
|
||||||
|
|
||||||
Assert.NotNull(sources);
|
Assert.NotNull(sources);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-4
@@ -1,6 +1,6 @@
|
|||||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Tests;
|
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for EffectiveDnsmasqConfig, including GetLogsPath (log-facility as file path).
|
/// Tests for EffectiveDnsmasqConfig, including GetLogsPath (log-facility as file path).
|
||||||
@@ -12,7 +12,8 @@ public class EffectiveDnsmasqConfigTests
|
|||||||
NoHosts: false,
|
NoHosts: false,
|
||||||
AddnHostsPaths: Array.Empty<string>(),
|
AddnHostsPaths: Array.Empty<string>(),
|
||||||
HostsdirPath: null,
|
HostsdirPath: null,
|
||||||
ServerLocalValues: Array.Empty<string>(),
|
ServerValues: Array.Empty<string>(),
|
||||||
|
LocalValues: Array.Empty<string>(),
|
||||||
RevServerValues: Array.Empty<string>(),
|
RevServerValues: Array.Empty<string>(),
|
||||||
AddressValues: Array.Empty<string>(),
|
AddressValues: Array.Empty<string>(),
|
||||||
Interfaces: Array.Empty<string>(),
|
Interfaces: Array.Empty<string>(),
|
||||||
@@ -53,6 +54,24 @@ public class EffectiveDnsmasqConfigTests
|
|||||||
DhcpHostsfilePaths: Array.Empty<string>(),
|
DhcpHostsfilePaths: Array.Empty<string>(),
|
||||||
DhcpOptsfilePaths: Array.Empty<string>(),
|
DhcpOptsfilePaths: Array.Empty<string>(),
|
||||||
DhcpHostsdirPaths: 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,
|
ExpandHosts: false,
|
||||||
BogusPriv: false,
|
BogusPriv: false,
|
||||||
StrictOrder: false,
|
StrictOrder: false,
|
||||||
@@ -79,7 +98,7 @@ public class EffectiveDnsmasqConfigTests
|
|||||||
TftpNoFail: false,
|
TftpNoFail: false,
|
||||||
TftpNoBlocksize: false,
|
TftpNoBlocksize: false,
|
||||||
Dnssec: false,
|
Dnssec: false,
|
||||||
DnssecCheckUnsigned: false,
|
DnssecCheckUnsigned: null,
|
||||||
ReadEthers: false,
|
ReadEthers: false,
|
||||||
DhcpRapidCommit: false,
|
DhcpRapidCommit: false,
|
||||||
Localmx: false,
|
Localmx: false,
|
||||||
@@ -89,6 +108,24 @@ public class EffectiveDnsmasqConfigTests
|
|||||||
KeepInForeground: false,
|
KeepInForeground: false,
|
||||||
NoDaemon: false,
|
NoDaemon: false,
|
||||||
ProxyDnssec: 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,
|
DhcpLeaseFilePath: null,
|
||||||
CacheSize: null,
|
CacheSize: null,
|
||||||
Port: null,
|
Port: null,
|
||||||
@@ -119,7 +156,25 @@ public class EffectiveDnsmasqConfigTests
|
|||||||
FastDnsRetry: null,
|
FastDnsRetry: null,
|
||||||
DhcpScriptPath: null,
|
DhcpScriptPath: null,
|
||||||
MxTarget: null,
|
MxTarget: null,
|
||||||
Conntrack: 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]
|
[Fact]
|
||||||
+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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<Routes @rendermode="InteractiveServer" />
|
<Routes @rendermode="InteractiveServer" />
|
||||||
<script src="js/ec-option-help.js"></script>
|
<script src="js/copy-to-clipboard.js"></script>
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
@namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs
|
||||||
@inject ILoggingClient LoggingClient
|
@inject ILoggingClient LoggingClient
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="input-group input-group-sm mb-2">
|
<div class="input-group input-group-sm mb-2">
|
||||||
<input type="text" class="form-control" placeholder="e.g. Microsoft.AspNetCore."
|
<input type="text" class="form-control" placeholder="e.g. Microsoft.AspNetCore."
|
||||||
@bind="_newPrefix" @bind:event="oninput" @onkeydown="OnNewPrefixKeydown" />
|
@bind="_newPrefix" @bind:event="oninput" @onkeydown="OnNewPrefixKeydown" />
|
||||||
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)">Add</button>
|
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)"><i class="bi bi-plus me-1" aria-hidden="true"></i>Add</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-group list-group-flush" style="max-height: 12rem; overflow-y: auto;">
|
<ul class="list-group list-group-flush" style="max-height: 12rem; overflow-y: auto;">
|
||||||
@foreach (var prefix in _prefixes)
|
@foreach (var prefix in _prefixes)
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer d-flex justify-content-between align-items-center">
|
<div class="modal-footer d-flex justify-content-between align-items-center">
|
||||||
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving">Restore defaults</button>
|
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore defaults</button>
|
||||||
<div>
|
<div>
|
||||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@(_saving ? "Saving…" : "Save")</button>
|
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@if (_saving) { <span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> } else { <i class="bi bi-floppy-fill me-1" aria-hidden="true"></i> }@(_saving ? "Saving…" : "Save")</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
+10
-14
@@ -1,9 +1,10 @@
|
|||||||
|
using DnsmasqWebUI.Extensions.Interop;
|
||||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs;
|
||||||
|
|
||||||
public partial class AppLogsFiltersModal : IAsyncDisposable
|
public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||||
{
|
{
|
||||||
@@ -51,32 +52,29 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
try
|
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/dialog.js");
|
||||||
|
if (_jsModule != null)
|
||||||
{
|
{
|
||||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
|
||||||
_dotNetRef = DotNetObjectReference.Create(this);
|
_dotNetRef = DotNetObjectReference.Create(this);
|
||||||
_moduleLoaded = true;
|
_moduleLoaded = true;
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException) { }
|
|
||||||
catch (JSDisconnectedException) { }
|
|
||||||
catch (JSException) { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsVisible && _moduleLoaded && _jsModule != null)
|
if (IsVisible && _moduleLoaded && _jsModule != null)
|
||||||
{
|
{
|
||||||
if (!_dialogInitialized)
|
if (!_dialogInitialized)
|
||||||
{
|
{
|
||||||
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
|
await _jsModule.InvokeVoidAsyncSafe("initDialog", _dialogRef, _dotNetRef);
|
||||||
_dialogInitialized = true;
|
_dialogInitialized = true;
|
||||||
}
|
}
|
||||||
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
|
await _jsModule.InvokeVoidAsyncSafe("showModal", _dialogRef);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Close()
|
private async Task Close()
|
||||||
{
|
{
|
||||||
if (_jsModule != null)
|
if (_jsModule != null)
|
||||||
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
|
await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef);
|
||||||
await OnClose.InvokeAsync();
|
await OnClose.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,10 +143,8 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
|
|||||||
_cts.Cancel();
|
_cts.Cancel();
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
_dotNetRef?.Dispose();
|
_dotNetRef?.Dispose();
|
||||||
if (_jsModule != null)
|
_dotNetRef = null;
|
||||||
{
|
await _jsModule.DisposeAsyncSafe();
|
||||||
try { await _jsModule.DisposeAsync(); }
|
_jsModule = null;
|
||||||
catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-1
@@ -1,4 +1,4 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
@namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs
|
||||||
@* App logs — live view via SignalR from ILogger *@
|
@* App logs — live view via SignalR from ILogger *@
|
||||||
@using DnsmasqWebUI.Models.Logs
|
@using DnsmasqWebUI.Models.Logs
|
||||||
@using Microsoft.AspNetCore.SignalR.Client
|
@using Microsoft.AspNetCore.SignalR.Client
|
||||||
@@ -31,3 +31,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<AppLogsFiltersModal IsVisible="_filtersModalVisible" OnClose="CloseFiltersModal" />
|
<AppLogsFiltersModal IsVisible="_filtersModalVisible" OnClose="CloseFiltersModal" />
|
||||||
<LogsPreView Id="app-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
|
<LogsPreView Id="app-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
|
||||||
|
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||||
|
<a href="api/logging/logs/download" download="app.log" class="btn btn-primary btn-sm"><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</a>
|
||||||
|
</div>
|
||||||
+36
-27
@@ -1,10 +1,11 @@
|
|||||||
|
using DnsmasqWebUI.Extensions.Interop;
|
||||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||||
using DnsmasqWebUI.Models.Logs;
|
using DnsmasqWebUI.Models.Logs;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// App logs block: connects to LogsHub, receives AppLogsUpdate pushes, polls at interval as fallback.
|
/// App logs block: connects to LogsHub, receives AppLogsUpdate pushes, polls at interval as fallback.
|
||||||
@@ -18,6 +19,7 @@ public partial class AppLogsSection : IAsyncDisposable
|
|||||||
|
|
||||||
private bool _justUpdated;
|
private bool _justUpdated;
|
||||||
private bool _logsContentReceived;
|
private bool _logsContentReceived;
|
||||||
|
private bool _refreshing;
|
||||||
private int _intervalSeconds;
|
private int _intervalSeconds;
|
||||||
private string _logLevel = "Information";
|
private string _logLevel = "Information";
|
||||||
private bool _logLevelDisabled;
|
private bool _logLevelDisabled;
|
||||||
@@ -60,13 +62,12 @@ public partial class AppLogsSection : IAsyncDisposable
|
|||||||
|
|
||||||
_hubConnection.On<LogsUpdatePayload>("AppLogsUpdate", OnAppLogsUpdate);
|
_hubConnection.On<LogsUpdatePayload>("AppLogsUpdate", OnAppLogsUpdate);
|
||||||
|
|
||||||
try
|
_logsJs = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/logs.js");
|
||||||
|
if (_logsJs == null)
|
||||||
{
|
{
|
||||||
_logsJs = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/logs.js");
|
Logger.LogDebug("AppLogsSection: logs JS module unavailable (prerender, disconnect, or import failure)");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import skipped (prerender)"); return; }
|
|
||||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import skipped (circuit disconnected)"); return; }
|
|
||||||
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import failed"); return; }
|
|
||||||
|
|
||||||
await _hubConnection.StartAsync(_cts.Token);
|
await _hubConnection.StartAsync(_cts.Token);
|
||||||
RestartPollTimer();
|
RestartPollTimer();
|
||||||
@@ -90,6 +91,25 @@ public partial class AppLogsSection : IAsyncDisposable
|
|||||||
private void OpenFiltersModal() => _filtersModalVisible = true;
|
private void OpenFiltersModal() => _filtersModalVisible = true;
|
||||||
private void CloseFiltersModal() => _filtersModalVisible = false;
|
private void CloseFiltersModal() => _filtersModalVisible = false;
|
||||||
|
|
||||||
|
private async Task RequestRefreshAsync()
|
||||||
|
{
|
||||||
|
if (_hubConnection?.State != HubConnectionState.Connected)
|
||||||
|
return;
|
||||||
|
_refreshing = true;
|
||||||
|
StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _hubConnection.InvokeAsync("RequestAppLogsSnapshot", _cts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (Exception) { /* ignore */ }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshing = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OnLogLevelChanged()
|
private async Task OnLogLevelChanged()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_logLevel)) return;
|
if (string.IsNullOrEmpty(_logLevel)) return;
|
||||||
@@ -137,23 +157,18 @@ public partial class AppLogsSection : IAsyncDisposable
|
|||||||
await InvokeAsync(async () =>
|
await InvokeAsync(async () =>
|
||||||
{
|
{
|
||||||
if (_logsJs == null) return;
|
if (_logsJs == null) return;
|
||||||
try
|
if (payload.Mode == "replace")
|
||||||
{
|
await _logsJs.InvokeVoidAsyncSafe("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||||
if (payload.Mode == "replace")
|
else
|
||||||
await _logsJs.InvokeVoidAsync("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
|
await _logsJs.InvokeVoidAsyncSafe("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||||
else
|
_logsContentReceived = true;
|
||||||
await _logsJs.InvokeVoidAsync("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
|
SetJustUpdated();
|
||||||
_logsContentReceived = true;
|
StateHasChanged();
|
||||||
SetJustUpdated();
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
|
|
||||||
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
|
|
||||||
catch (JSException) { /* JS error; ignore */ }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||||
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
||||||
|
catch (JSDisconnectedException) { /* Circuit gone before InvokeAsync completed */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetJustUpdated()
|
private void SetJustUpdated()
|
||||||
@@ -183,13 +198,7 @@ public partial class AppLogsSection : IAsyncDisposable
|
|||||||
await _hubConnection.DisposeAsync();
|
await _hubConnection.DisposeAsync();
|
||||||
_hubConnection = null;
|
_hubConnection = null;
|
||||||
}
|
}
|
||||||
if (_logsJs != null)
|
await _logsJs.DisposeAsyncSafe();
|
||||||
{
|
_logsJs = null;
|
||||||
try { await _logsJs.DisposeAsync(); }
|
|
||||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (prerender)"); }
|
|
||||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (circuit disconnected)"); }
|
|
||||||
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync failed"); }
|
|
||||||
_logsJs = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
|
||||||
|
<div class="config-file-list">
|
||||||
|
@{
|
||||||
|
var configFileList = Status.ConfigFiles?.Count > 0 ? Status.ConfigFiles : ConfigSet?.Files;
|
||||||
|
var hasList = configFileList != null && configFileList.Count > 0;
|
||||||
|
var mainEntry = hasList ? configFileList![0] : null;
|
||||||
|
var hasMoreThanOne = hasList && configFileList!.Count > 1;
|
||||||
|
var fileCount = hasList ? configFileList!.Count : 2;
|
||||||
|
var showToggle = hasMoreThanOne || (!hasList && Status.ManagedFilePath != null);
|
||||||
|
}
|
||||||
|
<div class="config-file-list-header">
|
||||||
|
<p class="config-file-list-title mb-0">Config files (load order)</p>
|
||||||
|
@if (showToggle)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="config-file-list-toggle"
|
||||||
|
@onclick="ToggleExpanded"
|
||||||
|
aria-expanded="@_expanded"
|
||||||
|
aria-label="@(_expanded ? "Show only main config" : "Show all config files")">
|
||||||
|
<i class="bi @(_expanded ? "bi-chevron-up" : "bi-chevron-down")" aria-hidden="true"></i>
|
||||||
|
<span>@(_expanded ? "Show less" : $"Show all ({fileCount} files)")</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="config-file-list-content-row">
|
||||||
|
<div class="config-file-list-list">
|
||||||
|
<ul class="list-unstyled config-file-list-main">
|
||||||
|
@if (hasList)
|
||||||
|
{
|
||||||
|
<ConfigFileListItem Entry="@mainEntry" Status="Status" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<li><strong>Main config:</strong> @(Status.MainConfigPath ?? "—") @(Status.MainConfigPathExists ? "✓" : "(missing)")</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@if (showToggle)
|
||||||
|
{
|
||||||
|
<div class="config-file-list-expandable @(_expanded ? "expanded" : null)">
|
||||||
|
<div class="config-file-list-expandable-inner">
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
@if (hasList)
|
||||||
|
{
|
||||||
|
@foreach (var f in configFileList!.Skip(1))
|
||||||
|
{
|
||||||
|
<ConfigFileListItem Entry="f" Status="Status" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<li><strong>Managed file:</strong> @(Status.ManagedFilePath ?? "—") @(Status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public DnsmasqServiceStatus Status { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public DnsmasqConfigSet? ConfigSet { get; set; }
|
||||||
|
|
||||||
|
private bool _expanded;
|
||||||
|
|
||||||
|
private void ToggleExpanded() => _expanded = !_expanded;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/* Config file list: card-style block with expandable list */
|
||||||
|
.config-file-list {
|
||||||
|
--cfl-radius: var(--app-radius, 0.375rem);
|
||||||
|
--cfl-spacing: var(--app-spacing-md, 1rem);
|
||||||
|
--cfl-border: 1px solid #e9ecef;
|
||||||
|
--cfl-bg: #f8f9fa;
|
||||||
|
--cfl-bg-hover: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list {
|
||||||
|
border-radius: var(--cfl-radius);
|
||||||
|
border: var(--cfl-border);
|
||||||
|
background: var(--cfl-bg);
|
||||||
|
padding: var(--cfl-spacing);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list .config-file-list-title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #212529;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list ul li {
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List content row (toggle is in header now) */
|
||||||
|
.config-file-list-content-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list-list {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list .config-file-list-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #0d6efd;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--cfl-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list .config-file-list-toggle:hover {
|
||||||
|
background: var(--cfl-bg-hover);
|
||||||
|
color: #0a58ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list .config-file-list-toggle:focus-visible {
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list .config-file-list-toggle .bi {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expandable section: animate height with grid 0fr → 1fr */
|
||||||
|
.config-file-list-expandable {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list-expandable.expanded {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list-expandable-inner {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-file-list-expandable-inner ul {
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
|
||||||
|
<li>
|
||||||
|
@if (Entry.IsManaged)
|
||||||
|
{
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.Managed" Text="managed" Class="me-1" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.SourceMain" Text="@(Entry.Source.ToString().ToLowerInvariant())" Class="me-1" />
|
||||||
|
}
|
||||||
|
@Entry.Path
|
||||||
|
@{
|
||||||
|
var isMain = string.Equals(Entry.Path, Status.MainConfigPath, StringComparison.Ordinal);
|
||||||
|
var isManaged = Entry.IsManaged;
|
||||||
|
var exists = isMain ? Status.MainConfigPathExists : (isManaged ? Status.ManagedFilePathExists : (bool?)null);
|
||||||
|
}
|
||||||
|
@if (exists == true) { <text> ✓</text> }
|
||||||
|
@if (exists == false) { <text> @(isManaged ? "(not created yet)" : "(missing)")</text> }
|
||||||
|
</li>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public DnsmasqConfigSetEntry Entry { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public DnsmasqServiceStatus Status { get; set; } = null!;
|
||||||
|
}
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
|
||||||
|
|
||||||
@* Reusable value-only display for boolean/flag options. Edit mode: checkbox (unless readonly); View: "Enabled" / "Disabled". *@
|
|
||||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
|
|
||||||
{
|
|
||||||
var isSet = Descriptor.GetValue() is bool b && b;
|
|
||||||
<input type="checkbox" class="form-check-input" checked="@isSet" @onchange="OnCheckedChange" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@(Descriptor.GetValue() is bool isSet && isSet ? "Enabled" : "Disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
|
||||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
|
||||||
[Parameter] public EventCallback<bool> CheckedChanged { get; set; }
|
|
||||||
|
|
||||||
private async Task OnCheckedChange(ChangeEventArgs e)
|
|
||||||
{
|
|
||||||
await CheckedChanged.InvokeAsync(e.Value is true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
|
||||||
|
|
||||||
@* Value-only display for the port option. Edit mode: number input (unless readonly); View: text with "(default DNS port)". *@
|
|
||||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
|
|
||||||
{
|
|
||||||
var port = Descriptor.GetValue() is int p ? p : 53;
|
|
||||||
<input type="number" class="form-control form-control-sm" style="max-width: 6rem;" min="1" max="65535" value="@port" @oninput="OnPortInput" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@(Descriptor.GetValue() is int port ? $"{port} (default DNS port)" : "(not set)")
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
|
||||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
|
||||||
[Parameter] public EventCallback<int?> PortChanged { get; set; }
|
|
||||||
|
|
||||||
private async Task OnPortInput(ChangeEventArgs e)
|
|
||||||
{
|
|
||||||
if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535)
|
|
||||||
await PortChanged.InvokeAsync(port);
|
|
||||||
else
|
|
||||||
await PortChanged.InvokeAsync(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
|
||||||
@using DnsmasqWebUI.Models.Config
|
|
||||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
|
||||||
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
|
|
||||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
|
||||||
|
|
||||||
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. *@
|
|
||||||
@if (_customFragment is { } fragment)
|
|
||||||
{
|
|
||||||
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
|
||||||
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
|
||||||
<span id="@_anchorId" class="ec-field-label @(_optionHelpKey != null ? "ec-field-label-help" : null)" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Descriptor.OptionName:</strong></span>
|
|
||||||
<span class="ec-field-value">
|
|
||||||
@fragment(Descriptor)
|
|
||||||
@if (Descriptor.GetSource()?.IsReadOnly == true)
|
|
||||||
{
|
|
||||||
<span class="badge bg-secondary ec-badge-inline">readonly</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
else if (Descriptor.IsMultiValue)
|
|
||||||
{
|
|
||||||
var items = Descriptor.GetItems();
|
|
||||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var value = Descriptor.GetValue();
|
|
||||||
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
|
|
||||||
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" />
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
|
||||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
|
||||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
|
||||||
|
|
||||||
private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8];
|
|
||||||
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
|
|
||||||
private string? _labelTooltip;
|
|
||||||
private string? _optionHelpKey;
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
|
||||||
{
|
|
||||||
_customFragment = RenderFragmentRegistry.GetDisplayFragment(Descriptor.SectionId, Descriptor.OptionName);
|
|
||||||
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
|
||||||
_optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RaiseOptionHelpRequested()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(_optionHelpKey)) return;
|
|
||||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = _optionHelpKey, OptionLabel = Descriptor.OptionName, AnchorId = _anchorId });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RaiseLabelMouseLeave() => await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = _anchorId, IsLabelMouseLeave = true });
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
|
||||||
|
|
||||||
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
|
|
||||||
@Value
|
|
||||||
@if (Source?.IsReadOnly == true)
|
|
||||||
{
|
|
||||||
<span class="badge bg-secondary ec-badge-inline">readonly</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string Value { get; set; } = "";
|
|
||||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
|
||||||
}
|
|
||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
|
||||||
|
|
||||||
<li class="ec-field">
|
|
||||||
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@LabelTooltip" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
|
|
||||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit)
|
|
||||||
{
|
|
||||||
<span class="ec-field-value ec-field-value-multi">
|
|
||||||
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="ec-field-value ec-field-value-multi">
|
|
||||||
@if (Items?.Count > 0)
|
|
||||||
{
|
|
||||||
<ol class="ec-field-value-list">
|
|
||||||
@foreach (var item in Items!)
|
|
||||||
{
|
|
||||||
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" /></li>
|
|
||||||
}
|
|
||||||
</ol>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="text-muted">(none)</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string Label { get; set; } = "";
|
|
||||||
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
|
|
||||||
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
|
|
||||||
[Parameter] public string? LabelTooltip { get; set; }
|
|
||||||
[Parameter] public string? OptionHelpKey { get; set; }
|
|
||||||
[Parameter] public string? OptionHelpAnchorId { get; set; }
|
|
||||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
|
||||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
|
||||||
|
|
||||||
private string _editLines = "";
|
|
||||||
|
|
||||||
private async Task RaiseOptionHelpRequested()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
|
||||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RaiseLabelMouseLeave()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
|
||||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
|
||||||
{
|
|
||||||
if (Items != null && Items.Count > 0)
|
|
||||||
_editLines = string.Join("\n", Items.Select(i => i.Value));
|
|
||||||
else
|
|
||||||
_editLines = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnLinesInput(ChangeEventArgs e)
|
|
||||||
{
|
|
||||||
var text = e.Value?.ToString() ?? "";
|
|
||||||
_editLines = text;
|
|
||||||
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
|
||||||
await ItemsChanged.InvokeAsync(lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
|
||||||
|
|
||||||
<li class="ec-field" title="@(Source?.GetReadOnlyTooltip())">
|
|
||||||
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
|
|
||||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Source?.IsReadOnly != true)
|
|
||||||
{
|
|
||||||
<span class="ec-field-value">
|
|
||||||
<input type="text" class="form-control form-control-sm" value="@DisplayValue" @oninput="OnValueInput" />
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="ec-field-value">
|
|
||||||
@DisplayValue
|
|
||||||
@if (Source?.IsReadOnly == true)
|
|
||||||
{
|
|
||||||
<span class="badge bg-secondary ec-badge-inline">readonly</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string Label { get; set; } = "";
|
|
||||||
[Parameter] public string DisplayValue { get; set; } = "";
|
|
||||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
|
||||||
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
|
|
||||||
[Parameter] public string? LabelTooltip { get; set; }
|
|
||||||
[Parameter] public string? OptionHelpKey { get; set; }
|
|
||||||
[Parameter] public string? OptionHelpAnchorId { get; set; }
|
|
||||||
/// <summary>When Edit and not readonly: raised when the user changes the value. Not yet wired to save.</summary>
|
|
||||||
[Parameter] public EventCallback<string> DisplayValueChanged { get; set; }
|
|
||||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
|
||||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
|
||||||
|
|
||||||
private async Task RaiseOptionHelpRequested()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
|
||||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RaiseLabelMouseLeave()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
|
||||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnValueInput(ChangeEventArgs e)
|
|
||||||
{
|
|
||||||
var value = e.Value?.ToString() ?? "";
|
|
||||||
await DisplayValueChanged.InvokeAsync(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
|
||||||
@implements IAsyncDisposable
|
|
||||||
|
|
||||||
@if (Status != null)
|
|
||||||
{
|
|
||||||
<div class="ec-container">
|
|
||||||
<ConfigOptionHelpModal HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Visible="@_showOptionHelp" Title="@_optionHelpTitle"
|
|
||||||
OnClose="CloseOptionHelp" OnMouseEnterModal="CancelCloseTimerAsync" OnMouseLeaveModal="ScheduleCloseAsync" />
|
|
||||||
@if (ShowSearchBox)
|
|
||||||
{
|
|
||||||
<div class="ec-toolbar">
|
|
||||||
<input type="search" id="ec-search-input" class="form-control form-control-sm" placeholder="Search..." autocomplete="off"
|
|
||||||
@bind="_searchTerm" @bind:event="oninput" @bind:after="StateHasChanged" />
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CollapseAll" title="Collapse all sections" aria-label="Collapse all sections">
|
|
||||||
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="ec-sections small">
|
|
||||||
@{
|
|
||||||
var views = EffectiveConfigViews.GetViewsForContext(Context);
|
|
||||||
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views);
|
|
||||||
var hasSearch = !string.IsNullOrWhiteSpace(_searchTerm);
|
|
||||||
var term = _searchTerm?.Trim() ?? "";
|
|
||||||
}
|
|
||||||
@foreach (var view in views)
|
|
||||||
{
|
|
||||||
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
|
|
||||||
continue;
|
|
||||||
var sectionDescriptors = hasSearch
|
|
||||||
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
|
|
||||||
: contextDescriptors.ToList();
|
|
||||||
if (sectionDescriptors.Count == 0)
|
|
||||||
continue;
|
|
||||||
var isOpen = hasSearch ? true : IsOpen(view.SectionId);
|
|
||||||
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle" DisplayMode="@DisplayMode" OnOptionHelpRequested="@HandleOptionHelpRequested" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
|
||||||
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
|
|
||||||
[Parameter] public bool ShowSearchBox { get; set; } = true;
|
|
||||||
/// <summary>View = read-only; Edit = editable (future). Cascaded to field displays.</summary>
|
|
||||||
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
|
||||||
|
|
||||||
private string _searchTerm = "";
|
|
||||||
private HashSet<string> _openPanels = new();
|
|
||||||
private string? _optionHelpKey;
|
|
||||||
private string? _optionHelpAnchorId;
|
|
||||||
private string _optionHelpTitle = "Option help";
|
|
||||||
private bool _showOptionHelp;
|
|
||||||
private CancellationTokenSource? _closeCts;
|
|
||||||
|
|
||||||
private bool IsOpen(string id) => _openPanels.Contains(id);
|
|
||||||
|
|
||||||
private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args)
|
|
||||||
{
|
|
||||||
if (args.IsLabelMouseLeave)
|
|
||||||
{
|
|
||||||
if (args.AnchorId == _optionHelpAnchorId)
|
|
||||||
ScheduleClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_optionHelpKey = args.HelpKey;
|
|
||||||
_optionHelpTitle = args.OptionLabel;
|
|
||||||
_optionHelpAnchorId = args.AnchorId;
|
|
||||||
_showOptionHelp = true;
|
|
||||||
CancelCloseTimer();
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelCloseTimer()
|
|
||||||
{
|
|
||||||
_closeCts?.Cancel();
|
|
||||||
_closeCts = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task CancelCloseTimerAsync() { CancelCloseTimer(); return Task.CompletedTask; }
|
|
||||||
|
|
||||||
private void ScheduleClose()
|
|
||||||
{
|
|
||||||
_closeCts?.Cancel();
|
|
||||||
_closeCts = new CancellationTokenSource();
|
|
||||||
var token = _closeCts.Token;
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(300, token);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { return; }
|
|
||||||
await InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
if (token.IsCancellationRequested) return;
|
|
||||||
_showOptionHelp = false;
|
|
||||||
_closeCts = null;
|
|
||||||
StateHasChanged();
|
|
||||||
});
|
|
||||||
}, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task ScheduleCloseAsync() { ScheduleClose(); return Task.CompletedTask; }
|
|
||||||
|
|
||||||
private void CloseOptionHelp()
|
|
||||||
{
|
|
||||||
CancelCloseTimer();
|
|
||||||
_showOptionHelp = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSectionToggle(string id)
|
|
||||||
{
|
|
||||||
if (_openPanels.Contains(id))
|
|
||||||
_openPanels.Remove(id);
|
|
||||||
else
|
|
||||||
_openPanels.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CollapseAll()
|
|
||||||
{
|
|
||||||
_openPanels.Clear();
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(term)) return true;
|
|
||||||
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
|
|
||||||
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
_closeCts?.Cancel();
|
|
||||||
_closeCts?.Dispose();
|
|
||||||
_closeCts = null;
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
@namespace DnsmasqWebUI.Components.Dnsmasq.Logs
|
||||||
@* Renders a pre element for logs. Once Freeze is true, stops re-rendering so Blazor does not overwrite JS-owned content. *@
|
@* Renders a pre element for logs. Once Freeze is true, stops re-rendering so Blazor does not overwrite JS-owned content. *@
|
||||||
<pre id="@Id" class="pre-block mb-0" style="max-height: 24rem;">@Placeholder</pre>
|
<pre id="@Id" class="pre-block mb-0" style="max-height: 24rem;">@Placeholder</pre>
|
||||||
|
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
@namespace DnsmasqWebUI.Components.Dnsmasq.Logs
|
||||||
@* Recent logs — live view via SignalR from LogsCommand; client polls at interval, server pushes deltas *@
|
@* Recent logs — live view via SignalR from LogsCommand; client polls at interval, server pushes deltas *@
|
||||||
@using DnsmasqWebUI.Models.Logs
|
@using DnsmasqWebUI.Models.Logs
|
||||||
@using Microsoft.AspNetCore.SignalR.Client
|
@using Microsoft.AspNetCore.SignalR.Client
|
||||||
@@ -27,14 +27,14 @@
|
|||||||
<LogsPreView Id="dnsmasq-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
|
<LogsPreView Id="dnsmasq-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
|
||||||
}
|
}
|
||||||
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
|
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
|
||||||
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing">Refresh</button>
|
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||||
@if (_status.LogsPath != null)
|
@if (_status.LogsPath != null)
|
||||||
{
|
{
|
||||||
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
|
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm"><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download.">Download logs</button>
|
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download."><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
+16
-27
@@ -1,10 +1,11 @@
|
|||||||
|
using DnsmasqWebUI.Extensions.Interop;
|
||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using DnsmasqWebUI.Models.Logs;
|
using DnsmasqWebUI.Models.Logs;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
namespace DnsmasqWebUI.Components.Dnsmasq.Logs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recent logs block: connects to LogsHub, polls at interval via RequestDnsmasqLogs,
|
/// Recent logs block: connects to LogsHub, polls at interval via RequestDnsmasqLogs,
|
||||||
@@ -62,13 +63,12 @@ public partial class LogsSection : IAsyncDisposable
|
|||||||
|
|
||||||
_hubConnection.On<LogsUpdatePayload>("DnsmasqLogsUpdate", OnDnsmasqLogsUpdate);
|
_hubConnection.On<LogsUpdatePayload>("DnsmasqLogsUpdate", OnDnsmasqLogsUpdate);
|
||||||
|
|
||||||
try
|
_logsJs = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/logs.js");
|
||||||
|
if (_logsJs == null)
|
||||||
{
|
{
|
||||||
_logsJs = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/logs.js");
|
Logger.LogDebug("LogsSection: logs JS module unavailable (prerender, disconnect, or import failure)");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (prerender)"); return; }
|
|
||||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (circuit disconnected)"); return; }
|
|
||||||
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: JS import failed"); return; }
|
|
||||||
|
|
||||||
await _hubConnection.StartAsync(_cts.Token);
|
await _hubConnection.StartAsync(_cts.Token);
|
||||||
RestartPollTimer();
|
RestartPollTimer();
|
||||||
@@ -123,23 +123,18 @@ public partial class LogsSection : IAsyncDisposable
|
|||||||
await InvokeAsync(async () =>
|
await InvokeAsync(async () =>
|
||||||
{
|
{
|
||||||
if (_logsJs == null) return;
|
if (_logsJs == null) return;
|
||||||
try
|
if (payload.Mode == "replace")
|
||||||
{
|
await _logsJs.InvokeVoidAsyncSafe("replaceLogs", LogsPreId, payload.Content, LogsOptions);
|
||||||
if (payload.Mode == "replace")
|
else
|
||||||
await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions);
|
await _logsJs.InvokeVoidAsyncSafe("appendLogs", LogsPreId, payload.Content, LogsOptions);
|
||||||
else
|
_logsContentReceived = true;
|
||||||
await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions);
|
SetJustUpdated();
|
||||||
_logsContentReceived = true;
|
StateHasChanged();
|
||||||
SetJustUpdated();
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
|
|
||||||
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
|
|
||||||
catch (JSException) { /* JS error; ignore */ }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||||
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
||||||
|
catch (JSDisconnectedException) { /* Circuit gone before InvokeAsync completed */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetJustUpdated()
|
private void SetJustUpdated()
|
||||||
@@ -169,13 +164,7 @@ public partial class LogsSection : IAsyncDisposable
|
|||||||
await _hubConnection.DisposeAsync();
|
await _hubConnection.DisposeAsync();
|
||||||
_hubConnection = null;
|
_hubConnection = null;
|
||||||
}
|
}
|
||||||
if (_logsJs != null)
|
await _logsJs.DisposeAsyncSafe();
|
||||||
{
|
_logsJs = null;
|
||||||
try { await _logsJs.DisposeAsync(); }
|
|
||||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (prerender)"); }
|
|
||||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (circuit disconnected)"); }
|
|
||||||
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync failed"); }
|
|
||||||
_logsJs = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+17
-3
@@ -1,4 +1,4 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
@namespace DnsmasqWebUI.Components.Dnsmasq.Status
|
||||||
@* Service status — output of StatusShowCommand; only this component re-renders on its timer *@
|
@* Service status — output of StatusShowCommand; only this component re-renders on its timer *@
|
||||||
@if (_status != null && _status.StatusShowConfigured)
|
@if (_status != null && _status.StatusShowConfigured)
|
||||||
{
|
{
|
||||||
@@ -24,7 +24,21 @@
|
|||||||
{
|
{
|
||||||
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
|
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
|
||||||
}
|
}
|
||||||
<div class="mt-2">
|
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
|
||||||
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
|
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||||
|
@if (ReloadCommandConfigured && OnReloadRequested.HasDelegate)
|
||||||
|
{
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="InvokeReloadAsync" disabled="@IsReloading" title="Reload dnsmasq config (e.g. SIGHUP)">
|
||||||
|
@if (IsReloading)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>
|
||||||
|
}
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
+8
-1
@@ -2,7 +2,7 @@ using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
|||||||
using DnsmasqWebUI.Models.Dnsmasq;
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
namespace DnsmasqWebUI.Components.Dnsmasq.Status;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service status block: polls StatusShowCommand output at its own interval and re-renders only itself.
|
/// Service status block: polls StatusShowCommand output at its own interval and re-renders only itself.
|
||||||
@@ -19,6 +19,11 @@ public partial class StatusSection : IDisposable
|
|||||||
|
|
||||||
[Parameter] public bool IsReloading { get; set; }
|
[Parameter] public bool IsReloading { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set and reload is configured, show a "Reload config" button next to Refresh.</summary>
|
||||||
|
[Parameter] public EventCallback OnReloadRequested { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public bool ReloadCommandConfigured { get; set; }
|
||||||
|
|
||||||
[Parameter] public EventCallback OnOpenSettings { get; set; }
|
[Parameter] public EventCallback OnOpenSettings { get; set; }
|
||||||
|
|
||||||
[Inject] private IStatusClient StatusClient { get; set; } = null!;
|
[Inject] private IStatusClient StatusClient { get; set; } = null!;
|
||||||
@@ -55,6 +60,8 @@ public partial class StatusSection : IDisposable
|
|||||||
|
|
||||||
private static readonly TimeSpan MinUpdatingDuration = TimeSpan.FromSeconds(1);
|
private static readonly TimeSpan MinUpdatingDuration = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
private Task InvokeReloadAsync() => OnReloadRequested.InvokeAsync();
|
||||||
|
|
||||||
private async Task RefreshAsync()
|
private async Task RefreshAsync()
|
||||||
{
|
{
|
||||||
var started = DateTime.UtcNow;
|
var started = DateTime.UtcNow;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Extensions.Interop
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-1 @CssClass">
|
||||||
|
<code class="flex-grow-1 text-break @CodeCssClass">@Text</code>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyAsync" title="@CopyTitle"><i class="bi bi-clipboard me-1" aria-hidden="true"></i>Copy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Text { get; set; } = "";
|
||||||
|
[Parameter] public string? CssClass { get; set; }
|
||||||
|
[Parameter] public string? CodeCssClass { get; set; }
|
||||||
|
[Parameter] public string CopyTitle { get; set; } = "Copy";
|
||||||
|
|
||||||
|
private async Task CopyAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(Text)) return;
|
||||||
|
// window.copyToClipboard from wwwroot/js/copy-to-clipboard.js; swallow disconnect / clipboard failures.
|
||||||
|
await JSRuntime.InvokeVoidAsyncSafe("copyToClipboard", args: new object?[] { Text });
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Components.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for custom effective-config multi-value displays; mirrors <see cref="EffectiveConfigValueDisplayBase"/> for singles.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class EffectiveConfigMultiValueDisplayBase : ComponentBase
|
||||||
|
{
|
||||||
|
[Parameter]
|
||||||
|
public EffectiveConfigMultiDisplayContext Context { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
|
||||||
|
|
||||||
|
protected IReadOnlyList<ValueWithSource> EffectiveItems => Context.EffectiveItems;
|
||||||
|
|
||||||
|
protected IReadOnlyList<string> EffectiveValues => Context.EffectiveValues;
|
||||||
|
|
||||||
|
protected Task NotifyValuesChanged(IReadOnlyList<string> values) => OnValuesChanged.InvokeAsync(values);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
|
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Components.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for effective-config custom value displays. Provides shared parameters,
|
||||||
|
/// <see cref="NotifyValueChanged"/>, and <see cref="GetEffectiveDisplayValue"/> so pending changes are shown before save.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class EffectiveConfigValueDisplayBase : ComponentBase
|
||||||
|
{
|
||||||
|
[Parameter]
|
||||||
|
public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
public bool IsActiveEditor { get; set; }
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<object?> ValueChanged { get; set; }
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
public IEffectiveConfigEditSession? Session { get; set; }
|
||||||
|
|
||||||
|
protected Task NotifyValueChanged(object? value) => ValueChanged.InvokeAsync(value);
|
||||||
|
|
||||||
|
/// <summary>Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk).</summary>
|
||||||
|
protected object? GetEffectiveDisplayValue()
|
||||||
|
{
|
||||||
|
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
|
||||||
|
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return pending != null ? pending.NewValue : Descriptor.GetValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@inject IEffectiveConfigDescriptorProvider DescriptorProvider
|
||||||
|
|
||||||
|
<div class="ec-sections small">
|
||||||
|
@{
|
||||||
|
var views = EffectiveConfigViews.GetViewsForContext(Context);
|
||||||
|
var descriptorsBySection = Status != null ? DescriptorProvider.GetBySection(Status, views) : new Dictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>>();
|
||||||
|
var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm);
|
||||||
|
var term = SearchTerm?.Trim() ?? "";
|
||||||
|
}
|
||||||
|
@foreach (var view in views)
|
||||||
|
{
|
||||||
|
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
|
||||||
|
continue;
|
||||||
|
var sectionDescriptors = hasSearch
|
||||||
|
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
|
||||||
|
: contextDescriptors.ToList();
|
||||||
|
if (sectionDescriptors.Count == 0)
|
||||||
|
continue;
|
||||||
|
var isOpen = hasSearch || IsSectionOpen(view.SectionId);
|
||||||
|
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle"
|
||||||
|
DisplayMode="@( IsEditMode ? EffectiveConfigDisplayMode.Edit : EffectiveConfigDisplayMode.View )"
|
||||||
|
IsEditMode="@IsEditMode" ActiveFieldKey="@ActiveFieldKey" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||||
|
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
|
||||||
|
[Parameter] public string SearchTerm { get; set; } = "";
|
||||||
|
[Parameter] public HashSet<string> OpenSectionIds { get; set; } = new();
|
||||||
|
[Parameter] public EventCallback<string> OnSectionToggle { get; set; }
|
||||||
|
[Parameter] public bool IsEditMode { get; set; }
|
||||||
|
[Parameter] public string? ActiveFieldKey { get; set; }
|
||||||
|
|
||||||
|
private bool IsSectionOpen(string id) => OpenSectionIds.Contains(id);
|
||||||
|
|
||||||
|
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(term)) return true;
|
||||||
|
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
|
||||||
|
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions
|
||||||
|
@inject IEffectiveConfigEditSession Session
|
||||||
|
@inject IEffectiveConfigCrossOptionValidationService CrossOptionValidation
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
@if (Status != null)
|
||||||
|
{
|
||||||
|
<div class="ec-container" @onclick="DeactivateActiveFieldIfClicked">
|
||||||
|
<OptionHelpController @ref="_optionHelpControllerRef" Visible="@_showOptionHelp" HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Title="@_optionHelpTitle" OnCloseRequested="CloseOptionHelp" />
|
||||||
|
<ReadonlyPopoverController @ref="_readonlyPopoverControllerRef" Visible="@_showReadonlyPopover" AnchorId="@_readonlyPopoverAnchorId" Source="@_readonlyPopoverSource" OptionName="@(_readonlyPopoverOptionName ?? "")" Value="@_readonlyPopoverValue" ManagedFilePath="@(Status?.ManagedFilePath)" OnCloseRequested="CloseReadonlyPopover" />
|
||||||
|
@{
|
||||||
|
var _sectionViews = EffectiveConfigViews.GetViewsForContext(Context);
|
||||||
|
var _allSectionsExpanded = _sectionViews.Count > 0 && _sectionViews.All(v => _openPanels.Contains(v.SectionId));
|
||||||
|
}
|
||||||
|
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
|
||||||
|
IsEditMode="@Session.Ui.IsEditMode" PendingChangesCount="@Session.Draft.PendingChanges.Count"
|
||||||
|
ValidationSummary="@Session.Draft.GetValidationSummary()"
|
||||||
|
AllSectionsExpanded="@_allSectionsExpanded"
|
||||||
|
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
|
||||||
|
<CascadingValue Value="@Session">
|
||||||
|
<CascadingValue Value="@_uiContext">
|
||||||
|
<EffectiveConfigPanels Status="@Status" Context="@Context" SearchTerm="@_searchTerm" OpenSectionIds="@_openPanels"
|
||||||
|
OnSectionToggle="OnSectionToggle" IsEditMode="@Session.Ui.IsEditMode" ActiveFieldKey="@Session.Ui.ActiveFieldKey" />
|
||||||
|
</CascadingValue>
|
||||||
|
</CascadingValue>
|
||||||
|
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.Draft.PendingChanges" Status="@Status" Session="@Session"
|
||||||
|
OnClose="CloseSaveModal" OnRevertedNotify="OnRevertedFromSaveModal" OnSaveCompleted="OnSaveCompleted" OnContinueEditingAfterSavedFailure="ContinueEditingAfterSavedFailureAsync" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||||
|
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
|
||||||
|
[Parameter] public bool ShowSearchBox { get; set; } = true;
|
||||||
|
[Parameter] public EventCallback OnSaveCompleted { get; set; }
|
||||||
|
|
||||||
|
private string _searchTerm = "";
|
||||||
|
private HashSet<string> _openPanels = new();
|
||||||
|
private bool _showSaveModal;
|
||||||
|
|
||||||
|
private string? _optionHelpKey;
|
||||||
|
private string? _optionHelpAnchorId;
|
||||||
|
private string _optionHelpTitle = "Option help";
|
||||||
|
private bool _showOptionHelp;
|
||||||
|
private OptionHelpController? _optionHelpControllerRef;
|
||||||
|
|
||||||
|
private bool _showReadonlyPopover;
|
||||||
|
private ReadonlyPopoverController? _readonlyPopoverControllerRef;
|
||||||
|
private string? _readonlyPopoverAnchorId;
|
||||||
|
private ConfigValueSource? _readonlyPopoverSource;
|
||||||
|
private string? _readonlyPopoverOptionName;
|
||||||
|
private object? _readonlyPopoverValue;
|
||||||
|
|
||||||
|
private EffectiveConfigUiContext? _uiContext;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Session.Changed += HandleSessionChanged;
|
||||||
|
_uiContext = new EffectiveConfigUiContext
|
||||||
|
{
|
||||||
|
RequestOptionHelpAsync = HandleOptionHelpRequested,
|
||||||
|
RequestReadonlyPopoverAsync = HandleReadonlyBadgeClicked,
|
||||||
|
ScheduleReadonlyPopoverCloseAsync = () =>
|
||||||
|
{
|
||||||
|
_readonlyPopoverControllerRef?.ScheduleCloseFromBadgeLeave();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
|
||||||
|
DeactivateFieldAsync = () => { Session.Ui.DeactivateField(); return InvokeAsync(StateHasChanged); },
|
||||||
|
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; },
|
||||||
|
RevertFieldAsync = (sectionId, optionName) => { Session.Draft.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleSessionChanged() => _ = InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Session.Changed -= HandleSessionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSearchTermChanged(string value)
|
||||||
|
{
|
||||||
|
_searchTerm = value ?? "";
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseOptionHelp()
|
||||||
|
{
|
||||||
|
_showOptionHelp = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.IsLabelMouseLeave)
|
||||||
|
{
|
||||||
|
if (args.AnchorId == _optionHelpAnchorId)
|
||||||
|
_optionHelpControllerRef?.ScheduleCloseFromLabelLeave();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_optionHelpControllerRef?.CancelCloseFromLabelEnter();
|
||||||
|
_optionHelpKey = args.HelpKey;
|
||||||
|
_optionHelpTitle = args.OptionLabel;
|
||||||
|
_optionHelpAnchorId = args.AnchorId;
|
||||||
|
_showOptionHelp = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseReadonlyPopover()
|
||||||
|
{
|
||||||
|
_showReadonlyPopover = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleReadonlyBadgeClicked(ReadonlyBadgeClickedEventArgs args)
|
||||||
|
{
|
||||||
|
_readonlyPopoverControllerRef?.CancelCloseFromBadgeEnter();
|
||||||
|
_readonlyPopoverAnchorId = args.AnchorId;
|
||||||
|
_readonlyPopoverSource = args.Source;
|
||||||
|
_readonlyPopoverOptionName = args.OptionName;
|
||||||
|
_readonlyPopoverValue = args.Value;
|
||||||
|
_showReadonlyPopover = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSectionToggle(string id)
|
||||||
|
{
|
||||||
|
if (_openPanels.Contains(id))
|
||||||
|
_openPanels.Remove(id);
|
||||||
|
else
|
||||||
|
_openPanels.Add(id);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExpandAll()
|
||||||
|
{
|
||||||
|
foreach (var view in EffectiveConfigViews.GetViewsForContext(Context))
|
||||||
|
_openPanels.Add(view.SectionId);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CollapseAll()
|
||||||
|
{
|
||||||
|
_openPanels.Clear();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnterEditMode()
|
||||||
|
{
|
||||||
|
Session.Ui.EnterEditMode();
|
||||||
|
RefreshCrossOptionAfterEnteringEditMode();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExitEditModeWithConfirmAsync()
|
||||||
|
{
|
||||||
|
if (Session.Draft.PendingChanges.Count > 0)
|
||||||
|
{
|
||||||
|
var module = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/dialog.js");
|
||||||
|
if (module == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var confirmed = await module.InvokeAsyncSafe<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
|
||||||
|
if (confirmed != true)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await module.DisposeAsyncSafe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Session.Draft.DiscardAllDraft();
|
||||||
|
Session.Ui.ExitEditMode();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeactivateActiveFieldIfClicked()
|
||||||
|
{
|
||||||
|
if (Session.Ui.IsEditMode && Session.Ui.ActiveFieldKey != null)
|
||||||
|
{
|
||||||
|
Session.Ui.DeactivateField();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ActivateField(string fieldKey)
|
||||||
|
{
|
||||||
|
Session.Ui.ActivateField(fieldKey);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
|
||||||
|
{
|
||||||
|
Session.Draft.TrackCommit(args);
|
||||||
|
Session.Ui.DeactivateField();
|
||||||
|
RunCrossOptionEvaluator();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunCrossOptionEvaluator()
|
||||||
|
{
|
||||||
|
var optionChanges = Session.Draft.PendingChanges.OfType<PendingOptionChange>().ToList();
|
||||||
|
var issues = CrossOptionValidation.Validate(Status, optionChanges);
|
||||||
|
Session.Draft.SetCrossOptionIssues(issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// After entering edit mode, repopulate cross-option issues from disk + pending.
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshCrossOptionAfterEnteringEditMode()
|
||||||
|
{
|
||||||
|
if (Status != null)
|
||||||
|
RunCrossOptionEvaluator();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRevertedFromSaveModal()
|
||||||
|
{
|
||||||
|
RunCrossOptionEvaluator();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenSaveModal()
|
||||||
|
{
|
||||||
|
var hasPending = Session.Draft.PendingChanges.Count > 0;
|
||||||
|
var hasValidation = Session.Draft.GetValidationSummary().Count > 0;
|
||||||
|
if (!hasPending && !hasValidation)
|
||||||
|
return;
|
||||||
|
_showSaveModal = true;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseSaveModal()
|
||||||
|
{
|
||||||
|
_showSaveModal = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ContinueEditingAfterSavedFailureAsync()
|
||||||
|
{
|
||||||
|
_showSaveModal = false;
|
||||||
|
Session.Draft.DiscardAllDraft();
|
||||||
|
Session.Ui.ExitEditMode();
|
||||||
|
await OnSaveCompleted.InvokeAsync();
|
||||||
|
Session.Ui.EnterEditMode();
|
||||||
|
RefreshCrossOptionAfterEnteringEditMode();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseSaveModalAndEndEdit()
|
||||||
|
{
|
||||||
|
_showSaveModal = false;
|
||||||
|
Session.Draft.DiscardAllDraft();
|
||||||
|
Session.Ui.ExitEditMode();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
-9
@@ -13,16 +13,37 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ec-toolbar {
|
/* Toolbar: single row, search left and actions right. Use ::deep so styles apply to EffectiveConfigToolbar (child) markup. */
|
||||||
|
::deep .ec-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ec-toolbar input[type="search"] {
|
::deep .ec-toolbar-search {
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .ec-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .ec-toolbar-changes-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .ec-toolbar-changes-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ec-sections {
|
.ec-sections {
|
||||||
@@ -104,6 +125,22 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animated expand/collapse: grid 0fr → 1fr */
|
||||||
|
::deep .ec-section-collapse {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .ec-section-collapse-open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .ec-section-collapse-inner {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
::deep .ec-section-body {
|
::deep .ec-section-body {
|
||||||
padding: 0.25rem 1rem 0.5rem 1rem;
|
padding: 0.25rem 1rem 0.5rem 1rem;
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
@@ -135,6 +172,16 @@
|
|||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Option label + field-level badges (editable / ok / cancel) on one row */
|
||||||
|
::deep .ec-field-label-row {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem 0.5rem;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
::deep .ec-field:last-child {
|
::deep .ec-field:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -156,10 +203,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::deep .ec-field-value {
|
::deep .ec-field-value {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When a field is active: value + badges in one block so any click inside (checkbox, input, ok/cancel) doesn't deactivate */
|
||||||
|
::deep .ec-field-value-when-editing {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edit wrapper (checkbox/input) shrinks to content */
|
||||||
|
::deep .ec-field-edit-wrapper {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input/select in value area can shrink so the editable badge stays on the same line */
|
||||||
|
::deep .ec-field-value > .form-control,
|
||||||
|
::deep .ec-field-value > .form-select {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Multi-value: full width below label, indented ordered list. min-width: 0 so flex doesn't prevent wrapping. */
|
/* Multi-value: full width below label, indented ordered list. min-width: 0 so flex doesn't prevent wrapping. */
|
||||||
@@ -190,6 +267,24 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::deep .ec-field-value-list li.ec-multi-list-empty-hint {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: -1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared with default and custom (e.g. domain) multi-value editors */
|
||||||
|
::deep .ec-field-value-list li.ec-multi-list-add-item {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: -1.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
border-top: 1px dashed var(--bs-border-color, #dee2e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .ec-field-value-list li.ec-multi-list-add-item > .ec-multi-list-row-controls > .status-badge-inline {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Long paths/URLs wrap; allow break anywhere so nothing is clipped */
|
/* Long paths/URLs wrap; allow break anywhere so nothing is clipped */
|
||||||
::deep .ec-field-value-item {
|
::deep .ec-field-value-item {
|
||||||
display: inline;
|
display: inline;
|
||||||
@@ -201,18 +296,31 @@
|
|||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Badge: vertically centered, inline with value so when it wraps it stays part of the value flow */
|
/* StatusBadge inside field rows: alignment; spacing comes from StatusBadge .status-badge-inline */
|
||||||
::deep .ec-field .badge,
|
::deep .ec-field .status-badge,
|
||||||
::deep .ec-field-value-item .badge {
|
::deep .ec-field-value-item .status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline badge (inside value): gap from text; when it wraps to own line the margin gives a subtle indent */
|
/* Only when field is in edit mode: push ok/cancel/revert to end and allow them to wrap first on narrow widths */
|
||||||
::deep .ec-badge-inline {
|
::deep .ec-field-value-when-editing > .status-badge-inline {
|
||||||
margin-left: 0.35rem;
|
flex: 0 0 auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
::deep .ec-field-value-when-editing {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .ec-field-value-when-editing > .status-badge-inline {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
+11
-7
@@ -1,4 +1,4 @@
|
|||||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
|
||||||
@@ -8,14 +8,18 @@
|
|||||||
<span class="ec-section-title fw-bold">@Title</span>
|
<span class="ec-section-title fw-bold">@Title</span>
|
||||||
<i class="bi @(IsOpen ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
<i class="bi @(IsOpen ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse @(IsOpen ? "show" : "")">
|
<div class="ec-section-collapse @(IsOpen ? "ec-section-collapse-open" : "")">
|
||||||
<div class="ec-section-body">
|
<div class="ec-section-collapse-inner">
|
||||||
|
<div class="ec-section-body">
|
||||||
<ul class="list-unstyled mb-0">
|
<ul class="list-unstyled mb-0">
|
||||||
@foreach (var f in Descriptors)
|
@foreach (var f in Descriptors)
|
||||||
{
|
{
|
||||||
<EffectiveConfigFieldDisplay Descriptor="@f" OnOptionHelpRequested="@OnOptionHelpRequested" />
|
var fieldKey = $"{SectionId}:{f.OptionName}";
|
||||||
|
<EffectiveConfigFieldDisplay Descriptor="@f" FieldKey="@fieldKey"
|
||||||
|
IsEditMode="@IsEditMode" IsActive="@(ActiveFieldKey == fieldKey)" />
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,10 +31,10 @@
|
|||||||
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
|
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
|
||||||
[Parameter] public bool IsOpen { get; set; }
|
[Parameter] public bool IsOpen { get; set; }
|
||||||
[Parameter] public EventCallback<string> OnToggle { get; set; }
|
[Parameter] public EventCallback<string> OnToggle { get; set; }
|
||||||
/// <summary>Raised when user requests option help (click or mouse leave from label). Passed through from Section.</summary>
|
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable.</summary>
|
||||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
|
||||||
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable (future).</summary>
|
|
||||||
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||||
|
[Parameter] public bool IsEditMode { get; set; }
|
||||||
|
[Parameter] public string? ActiveFieldKey { get; set; }
|
||||||
|
|
||||||
private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId);
|
private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
|
|
||||||
|
<div class="ec-toolbar @(IsEditMode ? "ec-edit-toolbar" : "")">
|
||||||
|
@if (ShowSearchBox)
|
||||||
|
{
|
||||||
|
<input type="search" id="ec-search-input" class="form-control form-control-sm ec-toolbar-search" placeholder="Search..." autocomplete="off"
|
||||||
|
value="@SearchTerm" @oninput="OnSearchInput" />
|
||||||
|
}
|
||||||
|
<div class="ec-toolbar-actions">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnToggleExpandCollapse"
|
||||||
|
title="@(AllSectionsExpanded ? "Collapse all sections" : "Expand all sections")"
|
||||||
|
aria-label="@(AllSectionsExpanded ? "Collapse all sections" : "Expand all sections")">
|
||||||
|
<i class="bi @(AllSectionsExpanded ? "bi-arrows-collapse" : "bi-arrows-expand")" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
@if (IsEditMode)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" @onclick="OnOpenSaveModal" disabled="@(PendingChangesCount == 0)" title="Save changes to managed config" aria-label="Save">
|
||||||
|
<i class="bi bi-floppy-fill" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnExitEditModeWithConfirm" title="Exit edit mode" aria-label="Exit edit mode">
|
||||||
|
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
@if (PendingChangesCount > 0)
|
||||||
|
{
|
||||||
|
<span class="text-muted small ec-toolbar-changes-link" role="button" tabindex="0"
|
||||||
|
@onclick="OnOpenSaveModal"
|
||||||
|
@onkeydown="OnChangesLinkKeyDown"
|
||||||
|
title="Open save dialog"
|
||||||
|
aria-label="Open save dialog">
|
||||||
|
@PendingChangesCount change(s)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (ValidationErrorCount > 0 || ValidationWarningCount > 0)
|
||||||
|
{
|
||||||
|
<span class="small ec-toolbar-validation-count" aria-live="polite">
|
||||||
|
@if (ValidationErrorCount > 0)
|
||||||
|
{
|
||||||
|
<span class="text-danger fw-medium ec-toolbar-changes-link" role="button" tabindex="0"
|
||||||
|
title="@ValidationErrorTooltip"
|
||||||
|
aria-label="Open validation summary"
|
||||||
|
@onclick="OnOpenSaveModal"
|
||||||
|
@onkeydown="OnValidationLinkKeyDown">
|
||||||
|
@ValidationErrorCount error(s)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (ValidationErrorCount > 0 && ValidationWarningCount > 0) { <span class="text-muted">·</span> }
|
||||||
|
@if (ValidationWarningCount > 0)
|
||||||
|
{
|
||||||
|
<span class="text-warning ec-toolbar-changes-link" role="button" tabindex="0"
|
||||||
|
title="@ValidationWarningTooltip"
|
||||||
|
aria-label="Open validation summary"
|
||||||
|
@onclick="OnOpenSaveModal"
|
||||||
|
@onkeydown="OnValidationLinkKeyDown">
|
||||||
|
@ValidationWarningCount warning(s)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnEnterEditMode" title="Edit effective config" aria-label="Edit">
|
||||||
|
<i class="bi bi-pencil" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool ShowSearchBox { get; set; } = true;
|
||||||
|
[Parameter] public string SearchTerm { get; set; } = "";
|
||||||
|
[Parameter] public EventCallback<string> SearchTermChanged { get; set; }
|
||||||
|
[Parameter] public bool IsEditMode { get; set; }
|
||||||
|
[Parameter] public int PendingChangesCount { get; set; }
|
||||||
|
[Parameter] public IReadOnlyList<FieldIssue>? ValidationSummary { get; set; }
|
||||||
|
private int ValidationErrorCount => ValidationSummary?.Count(i => i.Severity == FieldIssueSeverity.Error) ?? 0;
|
||||||
|
private int ValidationWarningCount => ValidationSummary?.Count(i => i.Severity == FieldIssueSeverity.Warning) ?? 0;
|
||||||
|
private string ValidationErrorTooltip => ValidationSummary == null ? "" : string.Join("\n", ValidationSummary.Where(i => i.Severity == FieldIssueSeverity.Error).Select(i => i.Message));
|
||||||
|
private string ValidationWarningTooltip => ValidationSummary == null ? "" : string.Join("\n", ValidationSummary.Where(i => i.Severity == FieldIssueSeverity.Warning).Select(i => i.Message));
|
||||||
|
[Parameter] public bool AllSectionsExpanded { get; set; }
|
||||||
|
[Parameter] public EventCallback OnExpandAll { get; set; }
|
||||||
|
[Parameter] public EventCallback OnCollapseAll { get; set; }
|
||||||
|
|
||||||
|
private async Task OnToggleExpandCollapse()
|
||||||
|
{
|
||||||
|
if (AllSectionsExpanded)
|
||||||
|
await OnCollapseAll.InvokeAsync();
|
||||||
|
else
|
||||||
|
await OnExpandAll.InvokeAsync();
|
||||||
|
}
|
||||||
|
[Parameter] public EventCallback OnEnterEditMode { get; set; }
|
||||||
|
[Parameter] public EventCallback OnExitEditModeWithConfirm { get; set; }
|
||||||
|
[Parameter] public EventCallback OnOpenSaveModal { get; set; }
|
||||||
|
|
||||||
|
private async Task OnSearchInput(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
await SearchTermChanged.InvokeAsync(e.Value?.ToString() ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnChangesLinkKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Enter" || e.Key == " ")
|
||||||
|
await OnOpenSaveModal.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnValidationLinkKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Enter" || e.Key == " ")
|
||||||
|
await OnOpenSaveModal.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@inherits EffectiveConfigValueDisplayBase
|
||||||
|
|
||||||
|
@* do-0x20-encode / no-0x20-encode: tri-state. Switch shows Enabled vs not-Enabled; uncheck maps back to Default when that was the value at activation, else Disabled. *@
|
||||||
|
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||||
|
{
|
||||||
|
var isOn = GetState() == ExplicitToggleState.Enabled;
|
||||||
|
<div class="form-check form-switch ec-effective-switch">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="form-check-input"
|
||||||
|
checked="@isOn"
|
||||||
|
aria-label="Enable @Descriptor.OptionName"
|
||||||
|
title="Set @Descriptor.OptionName"
|
||||||
|
@onchange="OnCheckedChange"
|
||||||
|
@onfocusout="OnCommitRequested" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* Intentionally show both Default and Disabled as "Disabled" (product choice: collapse unset and off). Do not "fix" to show "Default" for unset. *@
|
||||||
|
@(GetState() == ExplicitToggleState.Enabled ? "Enabled" : "Disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private ExplicitToggleState _stateAtActivation;
|
||||||
|
private bool _wasActiveEditor;
|
||||||
|
|
||||||
|
private ExplicitToggleState GetState()
|
||||||
|
{
|
||||||
|
var v = GetEffectiveDisplayValue();
|
||||||
|
if (v is ExplicitToggleState s) return s;
|
||||||
|
return ExplicitToggleState.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (!IsActiveEditor)
|
||||||
|
_stateAtActivation = GetState();
|
||||||
|
else if (!_wasActiveEditor)
|
||||||
|
_stateAtActivation = GetState();
|
||||||
|
_wasActiveEditor = IsActiveEditor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnCheckedChange(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
var isChecked = e.Value is true;
|
||||||
|
if (isChecked)
|
||||||
|
{
|
||||||
|
await NotifyValueChanged(ExplicitToggleState.Enabled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
|
||||||
|
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (pending?.OldValue is ExplicitToggleState previousState)
|
||||||
|
{
|
||||||
|
await NotifyValueChanged(previousState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No existing pending edit for this field: map unchecked based on the state at activation.
|
||||||
|
// Default and Disabled both display as "Disabled" in view mode, so we preserve true intent here.
|
||||||
|
await NotifyValueChanged(_stateAtActivation == ExplicitToggleState.Default ? ExplicitToggleState.Default : ExplicitToggleState.Disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
+579
@@ -0,0 +1,579 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||||
|
@inject INotificationService NotificationService
|
||||||
|
|
||||||
|
<div class="ec-field-value ec-field-value-multi ec-domain-rule-editor" @onclick:stopPropagation="true">
|
||||||
|
<ol class="ec-field-value-list ec-domain-rule-list">
|
||||||
|
@if (_displayItems.Count > 0)
|
||||||
|
{
|
||||||
|
@for (var i = 0; i < _displayItems.Count; i++)
|
||||||
|
{
|
||||||
|
var item = _displayItems[i];
|
||||||
|
var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
|
||||||
|
var itemReadonly = item.Source?.IsReadOnly == true;
|
||||||
|
var rowIndex = i;
|
||||||
|
var rowValue = item.Value;
|
||||||
|
|
||||||
|
<li class="ec-domain-rule-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-domain-rule-row-editing" : "")" @key="anchorId">
|
||||||
|
<div class="ec-domain-rule-row-body">
|
||||||
|
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||||
|
{
|
||||||
|
<div class="ec-domain-rule-form">
|
||||||
|
<div class="ec-domain-rule-toolbar">
|
||||||
|
<div class="ec-domain-rule-toolbar__group">
|
||||||
|
<span class="form-label mb-0">Editor mode</span>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||||
|
title="@DomainRuleEditorHelpText"
|
||||||
|
aria-label="@DomainRuleEditorHelpText"
|
||||||
|
@onclick="ShowDomainRuleEditorHelp">
|
||||||
|
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ec-domain-rule-mode-toggle" role="group" aria-label="Domain rule editor mode">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm ec-domain-rule-mode-btn @(!_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
|
||||||
|
aria-pressed="@(!_draftUseRaw ? "true" : "false")"
|
||||||
|
@onclick="() => OnDraftUseRawChanged(false)">
|
||||||
|
Guided
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm ec-domain-rule-mode-btn @(_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
|
||||||
|
aria-pressed="@(_draftUseRaw ? "true" : "false")"
|
||||||
|
@onclick="() => OnDraftUseRawChanged(true)">
|
||||||
|
Raw line
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_draftUseRaw)
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm font-monospace"
|
||||||
|
@bind="_draftRawLine" @bind:event="oninput"
|
||||||
|
placeholder="domain[,subnet|IP|interface[,local]]" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="@($"{anchorId}-dom")">Domain</label>
|
||||||
|
<input id="@($"{anchorId}-dom")" class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. home.arpa or #"
|
||||||
|
@bind="_draftDomain" @bind:event="oninput" />
|
||||||
|
<div class="ec-domain-rule-hint-row">
|
||||||
|
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||||
|
title="@HashDomainHelpText"
|
||||||
|
aria-label="@HashDomainHelpText"
|
||||||
|
@onclick="ShowHashDomainHelp">
|
||||||
|
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="@($"{anchorId}-scope")">Applies to</label>
|
||||||
|
<select id="@($"{anchorId}-scope")" class="form-select form-select-sm"
|
||||||
|
@bind="_draftScopeSelect" @bind:after="OnDraftScopeAfterChange">
|
||||||
|
<option value="unconditional">Any client address (default)</option>
|
||||||
|
<option value="range">Limit to this subnet or IP</option>
|
||||||
|
<option value="interface">Limit to subnets on this interface</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@if (_draftScopeSelect == "range")
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="@($"{anchorId}-mid")">Subnet or IP</label>
|
||||||
|
<input id="@($"{anchorId}-mid")" class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. 192.168.0.0/24 or 192.168.0.1"
|
||||||
|
@bind="_draftMiddle" @bind:event="oninput" />
|
||||||
|
<div class="ec-domain-rule-hint">Single IP, CIDR, or start,end pair per dnsmasq docs.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc")"
|
||||||
|
@bind="_draftAddLocal" />
|
||||||
|
<label class="form-check-label" for="@($"{anchorId}-loc")">
|
||||||
|
Also add matching <code>--local</code> forward and reverse DNS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_draftScopeSelect == "interface")
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="@($"{anchorId}-if")">Interface</label>
|
||||||
|
<input id="@($"{anchorId}-if")" class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. eth0 or br0"
|
||||||
|
@bind="_draftMiddle" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_draftScopeSelect == "unconditional")
|
||||||
|
{
|
||||||
|
<div class="form-check ec-domain-rule-local-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc2")"
|
||||||
|
@bind="_draftAddLocal" />
|
||||||
|
<label class="form-check-label" for="@($"{anchorId}-loc2")">
|
||||||
|
Append <code>,local</code>
|
||||||
|
</label>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||||
|
title="@LocalSuffixHelpText"
|
||||||
|
aria-label="@LocalSuffixHelpText"
|
||||||
|
@onclick="ShowLocalSuffixHelp">
|
||||||
|
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div class="ec-domain-rule-actions">
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
|
||||||
|
Title="Apply change" AriaLabel="Apply change"
|
||||||
|
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||||
|
Title="Discard edit" AriaLabel="Cancel edit"
|
||||||
|
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="ec-domain-rule-summary">
|
||||||
|
<span class="ec-field-value-item">@DisplayLine(rowValue)</span>
|
||||||
|
@if (!string.Equals(DomainRuleFields.FormatSummary(rowValue), rowValue, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
<code class="text-muted small">@rowValue</code>
|
||||||
|
}
|
||||||
|
<span class="status-badge-inline">
|
||||||
|
<OptionStateBadge
|
||||||
|
AnchorId="@anchorId"
|
||||||
|
Source="@item.Source"
|
||||||
|
OptionName="@OptionName"
|
||||||
|
Value="@rowValue"
|
||||||
|
IsInteractive="@IsEditMode"
|
||||||
|
ShowEditableBadge="@(!itemReadonly)"
|
||||||
|
EditableAsButton="@(!itemReadonly)"
|
||||||
|
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
|
||||||
|
@if (!itemReadonly)
|
||||||
|
{
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
|
||||||
|
Title="Remove this value" AriaLabel="Remove"
|
||||||
|
PreventBlurOnMouseDown="true"
|
||||||
|
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!AllowAddManagedValue)
|
||||||
|
{
|
||||||
|
<li class="ec-multi-list-row ec-multi-list-empty-hint">
|
||||||
|
<span class="text-muted">(none)</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (AllowAddManagedValue)
|
||||||
|
{
|
||||||
|
<li class="ec-multi-list-row ec-multi-list-add-item @(_addingNew ? "ec-multi-list-row-with-controls" : "")" @key="AddItemKey">
|
||||||
|
@if (_addingNew)
|
||||||
|
{
|
||||||
|
<div class="ec-domain-rule-form">
|
||||||
|
<div class="ec-domain-rule-toolbar">
|
||||||
|
<div class="ec-domain-rule-toolbar__group">
|
||||||
|
<span class="form-label mb-0">Editor mode</span>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||||
|
title="@DomainRuleEditorHelpText"
|
||||||
|
aria-label="@DomainRuleEditorHelpText"
|
||||||
|
@onclick="ShowDomainRuleEditorHelp">
|
||||||
|
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ec-domain-rule-mode-toggle" role="group" aria-label="Domain rule editor mode">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm ec-domain-rule-mode-btn @(!_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
|
||||||
|
aria-pressed="@(!_draftUseRaw ? "true" : "false")"
|
||||||
|
@onclick="() => OnDraftUseRawChanged(false)">
|
||||||
|
Guided
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm ec-domain-rule-mode-btn @(_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
|
||||||
|
aria-pressed="@(_draftUseRaw ? "true" : "false")"
|
||||||
|
@onclick="() => OnDraftUseRawChanged(true)">
|
||||||
|
Raw line
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_draftUseRaw)
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm font-monospace"
|
||||||
|
@bind="_draftRawLine" @bind:event="oninput"
|
||||||
|
placeholder="domain[,subnet|IP|interface[,local]]" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="domain-add-dom">Domain</label>
|
||||||
|
<input id="domain-add-dom" class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. home.arpa or #"
|
||||||
|
@bind="_draftDomain" @bind:event="oninput" />
|
||||||
|
<div class="ec-domain-rule-hint-row">
|
||||||
|
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||||
|
title="@HashDomainHelpText"
|
||||||
|
aria-label="@HashDomainHelpText"
|
||||||
|
@onclick="ShowHashDomainHelp">
|
||||||
|
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="domain-add-scope">Applies to</label>
|
||||||
|
<select id="domain-add-scope" class="form-select form-select-sm"
|
||||||
|
@bind="_draftScopeSelect" @bind:after="OnDraftScopeAfterChange">
|
||||||
|
<option value="unconditional">Any client address (default)</option>
|
||||||
|
<option value="range">Limit to this subnet or IP</option>
|
||||||
|
<option value="interface">Limit to subnets on this interface</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@if (_draftScopeSelect == "range")
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="domain-add-mid">Subnet or IP</label>
|
||||||
|
<input id="domain-add-mid" class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. 192.168.0.0/24 or 192.168.0.1"
|
||||||
|
@bind="_draftMiddle" @bind:event="oninput" />
|
||||||
|
<div class="ec-domain-rule-hint">Single IP, CIDR, or start,end pair per dnsmasq docs.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="domain-add-loc"
|
||||||
|
@bind="_draftAddLocal" />
|
||||||
|
<label class="form-check-label" for="domain-add-loc">
|
||||||
|
Also add matching <code>--local</code> forward and reverse DNS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_draftScopeSelect == "interface")
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="domain-add-if">Interface</label>
|
||||||
|
<input id="domain-add-if" class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. eth0 or br0"
|
||||||
|
@bind="_draftMiddle" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_draftScopeSelect == "unconditional")
|
||||||
|
{
|
||||||
|
<div class="form-check ec-domain-rule-local-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="domain-add-loc2"
|
||||||
|
@bind="_draftAddLocal" />
|
||||||
|
<label class="form-check-label" for="domain-add-loc2">
|
||||||
|
Append <code>,local</code>
|
||||||
|
</label>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
|
||||||
|
title="@LocalSuffixHelpText"
|
||||||
|
aria-label="@LocalSuffixHelpText"
|
||||||
|
@onclick="ShowLocalSuffixHelp">
|
||||||
|
<i class="bi bi-question-circle" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div class="ec-domain-rule-actions">
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
|
||||||
|
Title="Add this value" AriaLabel="Add"
|
||||||
|
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||||
|
Title="Discard" AriaLabel="Cancel"
|
||||||
|
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<StatusBadge Kind="StatusBadgeKind.Editable" Text="add item" Clickable="true"
|
||||||
|
Title="Add a new value" AriaLabel="Add item"
|
||||||
|
OnClick="BeginAdd" Class="status-badge-inline ec-multi-list-add-btn" />
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
|
||||||
|
[Parameter] public string OptionName { get; set; } = "domain";
|
||||||
|
[Parameter] public string ReadonlyBadgeAnchorIdBase { get; set; } = "";
|
||||||
|
|
||||||
|
[Parameter] public bool IsEditMode { get; set; }
|
||||||
|
[Parameter] public bool AllowAddManagedValue { get; set; } = true;
|
||||||
|
|
||||||
|
[Parameter] public IMultiValueEditBehavior? Behavior { get; set; }
|
||||||
|
[Parameter] public IMultiValueOptionValidator? Validator { get; set; }
|
||||||
|
[Parameter] public string? FieldKey { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
|
||||||
|
|
||||||
|
private const string AddItemKey = "domain-add-item";
|
||||||
|
private static readonly TimeSpan ValidationToastDedupeWindow = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
private int? _editingIndex;
|
||||||
|
private bool _addingNew;
|
||||||
|
private bool _didAutoOpenAdd;
|
||||||
|
private string? _lastValidationErrorMessage;
|
||||||
|
private DateTime _lastValidationErrorUtc;
|
||||||
|
|
||||||
|
private List<ValueWithSource> _displayItems = new();
|
||||||
|
|
||||||
|
private bool _draftUseRaw;
|
||||||
|
private string _draftRawLine = "";
|
||||||
|
private string _draftDomain = "";
|
||||||
|
private string _draftScopeSelect = "unconditional";
|
||||||
|
private string _draftMiddle = "";
|
||||||
|
private bool _draftAddLocal;
|
||||||
|
|
||||||
|
private IReadOnlyList<string> _currentValues => _displayItems.Select(x => x.Value).ToList();
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
_displayItems = (SourceItems ?? Array.Empty<ValueWithSource>()).ToList();
|
||||||
|
|
||||||
|
if (!_didAutoOpenAdd && (SourceItems?.Count ?? 0) == 0 && AllowAddManagedValue)
|
||||||
|
{
|
||||||
|
_didAutoOpenAdd = true;
|
||||||
|
_addingNew = true;
|
||||||
|
ResetDraftForNew();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DisplayLine(string rowValue) =>
|
||||||
|
DomainRuleFields.FormatSummary(rowValue);
|
||||||
|
|
||||||
|
private void ResetDraftForNew()
|
||||||
|
{
|
||||||
|
_draftUseRaw = false;
|
||||||
|
_draftRawLine = "";
|
||||||
|
_draftDomain = "";
|
||||||
|
_draftScopeSelect = "unconditional";
|
||||||
|
_draftMiddle = "";
|
||||||
|
_draftAddLocal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginEdit(int index, string current)
|
||||||
|
{
|
||||||
|
_addingNew = false;
|
||||||
|
_editingIndex = index;
|
||||||
|
LoadDraftFromLine(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginAdd()
|
||||||
|
{
|
||||||
|
_editingIndex = null;
|
||||||
|
_addingNew = true;
|
||||||
|
ResetDraftForNew();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelInline()
|
||||||
|
{
|
||||||
|
_editingIndex = null;
|
||||||
|
_addingNew = false;
|
||||||
|
_lastValidationErrorMessage = null;
|
||||||
|
ResetDraftForNew();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadDraftFromLine(string line)
|
||||||
|
{
|
||||||
|
var parsed = DomainRuleFields.Parse(line);
|
||||||
|
if (parsed.IsRaw)
|
||||||
|
{
|
||||||
|
_draftUseRaw = true;
|
||||||
|
_draftRawLine = parsed.RawLineFallback ?? line;
|
||||||
|
_draftDomain = "";
|
||||||
|
_draftScopeSelect = "unconditional";
|
||||||
|
_draftMiddle = "";
|
||||||
|
_draftAddLocal = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_draftUseRaw = false;
|
||||||
|
_draftRawLine = "";
|
||||||
|
_draftDomain = parsed.DomainPart;
|
||||||
|
_draftScopeSelect = parsed.Scope switch
|
||||||
|
{
|
||||||
|
DomainRuleScope.AddressRangeOrIp => "range",
|
||||||
|
DomainRuleScope.InterfaceName => "interface",
|
||||||
|
_ => "unconditional",
|
||||||
|
};
|
||||||
|
_draftMiddle = parsed.MiddlePart;
|
||||||
|
_draftAddLocal = parsed.AddLocalForRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDraftUseRawChanged(bool useRaw)
|
||||||
|
{
|
||||||
|
if (useRaw && !_draftUseRaw)
|
||||||
|
{
|
||||||
|
var fields = BuildFieldsFromDraftStructured();
|
||||||
|
_draftRawLine = fields.ToConfigLine();
|
||||||
|
}
|
||||||
|
else if (!useRaw && _draftUseRaw)
|
||||||
|
LoadDraftFromLine(_draftRawLine.Trim());
|
||||||
|
|
||||||
|
_draftUseRaw = useRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowDomainRuleEditorHelp() => NotificationService.ShowInfo(DomainRuleEditorHelpText);
|
||||||
|
|
||||||
|
private void ShowLocalSuffixHelp() => NotificationService.ShowInfo(LocalSuffixHelpText);
|
||||||
|
|
||||||
|
private void ShowHashDomainHelp() => NotificationService.ShowInfo(HashDomainHelpText);
|
||||||
|
|
||||||
|
private void OnDraftScopeAfterChange()
|
||||||
|
{
|
||||||
|
if (_draftScopeSelect == "unconditional")
|
||||||
|
_draftMiddle = "";
|
||||||
|
if (_draftScopeSelect == "interface")
|
||||||
|
_draftAddLocal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DomainRuleFields BuildFieldsFromDraftStructured()
|
||||||
|
{
|
||||||
|
var dom = (_draftDomain ?? "").Trim();
|
||||||
|
var mid = (_draftMiddle ?? "").Trim();
|
||||||
|
var scope = _draftScopeSelect switch
|
||||||
|
{
|
||||||
|
"range" => DomainRuleScope.AddressRangeOrIp,
|
||||||
|
"interface" => DomainRuleScope.InterfaceName,
|
||||||
|
_ => DomainRuleScope.Unconditional,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (scope == DomainRuleScope.Unconditional)
|
||||||
|
return new DomainRuleFields(dom, DomainRuleScope.Unconditional, "", _draftAddLocal, null);
|
||||||
|
|
||||||
|
if (scope == DomainRuleScope.InterfaceName)
|
||||||
|
return new DomainRuleFields(dom, DomainRuleScope.InterfaceName, mid, false, null);
|
||||||
|
|
||||||
|
if (mid.Length == 0 && _draftAddLocal)
|
||||||
|
return new DomainRuleFields(dom, DomainRuleScope.Unconditional, "", true, null);
|
||||||
|
|
||||||
|
return new DomainRuleFields(dom, DomainRuleScope.AddressRangeOrIp, mid, _draftAddLocal, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DomainRuleFields BuildFieldsFromDraft() =>
|
||||||
|
_draftUseRaw ? DomainRuleFields.FromRawConfigLine(_draftRawLine) : BuildFieldsFromDraftStructured();
|
||||||
|
|
||||||
|
private void ShowValidationError(string message)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (string.Equals(_lastValidationErrorMessage, message, StringComparison.Ordinal) &&
|
||||||
|
(now - _lastValidationErrorUtc) < ValidationToastDedupeWindow)
|
||||||
|
return;
|
||||||
|
_lastValidationErrorMessage = message;
|
||||||
|
_lastValidationErrorUtc = now;
|
||||||
|
NotificationService.ShowError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveEdit(int index)
|
||||||
|
{
|
||||||
|
var next = _currentValues.ToList();
|
||||||
|
if (index < 0 || index >= next.Count) return;
|
||||||
|
|
||||||
|
var fields = BuildFieldsFromDraft();
|
||||||
|
var normalized = fields.ToConfigLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
ShowValidationError("Domain name (or #) is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_draftUseRaw && _draftScopeSelect == "interface" && string.IsNullOrWhiteSpace(_draftMiddle))
|
||||||
|
{
|
||||||
|
ShowValidationError("Interface name is required for this scope.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = Behavior?.Normalize(normalized) ?? normalized.Trim();
|
||||||
|
|
||||||
|
var validationError = Validator?.ValidateItem(normalized, _currentValues, index);
|
||||||
|
if (validationError != null)
|
||||||
|
{
|
||||||
|
ShowValidationError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Behavior?.AllowDuplicates == false && next.Where((v, i) => i != index).Any(v => string.Equals(v, normalized, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
ShowValidationError("Duplicate values are not allowed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next[index] = normalized;
|
||||||
|
_displayItems[index] = _displayItems[index] with { Value = normalized };
|
||||||
|
await OnValuesChanged.InvokeAsync(next);
|
||||||
|
CancelInline();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveNew()
|
||||||
|
{
|
||||||
|
var fields = BuildFieldsFromDraft();
|
||||||
|
var normalized = fields.ToConfigLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
ShowValidationError("Domain name (or #) is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_draftUseRaw && _draftScopeSelect == "interface" && string.IsNullOrWhiteSpace(_draftMiddle))
|
||||||
|
{
|
||||||
|
ShowValidationError("Interface name is required for this scope.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = Behavior?.Normalize(normalized) ?? normalized.Trim();
|
||||||
|
|
||||||
|
var current = _currentValues;
|
||||||
|
var validationError = Validator?.ValidateItem(normalized, current, null);
|
||||||
|
if (validationError != null)
|
||||||
|
{
|
||||||
|
ShowValidationError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
ShowValidationError("Duplicate values are not allowed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = current.ToList();
|
||||||
|
next.Add(normalized);
|
||||||
|
_displayItems.Add(new ValueWithSource(normalized, null));
|
||||||
|
await OnValuesChanged.InvokeAsync(next);
|
||||||
|
CancelInline();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveAt(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= _displayItems.Count) return;
|
||||||
|
var item = _displayItems[index];
|
||||||
|
if (item?.Source?.IsReadOnly == true) return;
|
||||||
|
|
||||||
|
var next = _displayItems.Select(x => x.Value).ToList();
|
||||||
|
next.RemoveAt(index);
|
||||||
|
_displayItems.RemoveAt(index);
|
||||||
|
await OnValuesChanged.InvokeAsync(next);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string DomainRuleEditorHelpText =
|
||||||
|
"domain= sets the suffix dnsmasq uses for DHCP and local DNS naming. Guided mode builds the rule from fields; Raw line mode lets you enter the exact dnsmasq config line directly.";
|
||||||
|
|
||||||
|
private const string LocalSuffixHelpText =
|
||||||
|
"Appending ,local behaves like a matching --local rule for this domain, making dnsmasq answer locally instead of forwarding it upstream. This is uncommon for an unconditional domain= rule.";
|
||||||
|
|
||||||
|
private const string HashDomainHelpText =
|
||||||
|
"Use # as the domain value to take the search domain from resolv.conf instead of hard-coding a suffix in dnsmasq.";
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
.ec-domain-rule-editor {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--bs-body-bg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep <ol> markers (decimal) like the default multi-value editor; do not reset list-style here. */
|
||||||
|
.ec-domain-rule-editor .ec-field-value-list {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-row {
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-row-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-row.ec-domain-rule-row-editing .ec-domain-rule-row-body {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 36rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-form .form-label {
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-toolbar__group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-mode-btn {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-mode-btn:hover,
|
||||||
|
.ec-domain-rule-mode-btn:focus-visible {
|
||||||
|
color: #0f172a;
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-mode-btn--active,
|
||||||
|
.ec-domain-rule-mode-btn--active:hover,
|
||||||
|
.ec-domain-rule-mode-btn--active:focus-visible {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(180deg, #0d6efd 0%, #0a58ca 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-help {
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-help:hover,
|
||||||
|
.ec-domain-rule-help:focus-visible {
|
||||||
|
color: var(--bs-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-editor .ec-field-value-list li.ec-multi-list-add-item .ec-domain-rule-actions .status-badge-inline,
|
||||||
|
.ec-domain-rule-actions .status-badge-inline {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-raw {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-hint-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-local-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-local-check .form-check-label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig
|
||||||
|
@inherits EffectiveConfigMultiValueDisplayBase
|
||||||
|
|
||||||
|
@if (Context.IsActiveEditor)
|
||||||
|
{
|
||||||
|
<DomainEffectiveConfigListEditor
|
||||||
|
SourceItems="EffectiveItems"
|
||||||
|
OptionName="@Context.Descriptor.OptionName"
|
||||||
|
ReadonlyBadgeAnchorIdBase="@_anchorBase"
|
||||||
|
IsEditMode="@Context.IsEditMode"
|
||||||
|
AllowAddManagedValue="@Context.IsEditMode"
|
||||||
|
FieldKey="@Context.FieldKey"
|
||||||
|
Behavior="@_multiDesc?.Behavior"
|
||||||
|
Validator="@_multiDesc?.Validator"
|
||||||
|
OnValuesChanged="@NotifyValuesChanged" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="ec-field-value ec-field-value-multi">
|
||||||
|
<ol class="ec-field-value-list">
|
||||||
|
@if (Context.EffectiveItems.Count > 0)
|
||||||
|
{
|
||||||
|
@foreach (var (item, index) in Context.EffectiveItems.Select((item, index) => (item, index)))
|
||||||
|
{
|
||||||
|
var anchorId = $"{_anchorBase}-{index}";
|
||||||
|
<li>
|
||||||
|
<EffectiveConfigMultiValueItem
|
||||||
|
Value="@item.Value"
|
||||||
|
DisplayValue="@DomainRuleFields.FormatSummary(item.Value)"
|
||||||
|
Source="@item.Source"
|
||||||
|
OptionName="@Context.Descriptor.OptionName"
|
||||||
|
IsEditMode="@Context.IsEditMode"
|
||||||
|
ReadonlyBadgeAnchorId="@anchorId"
|
||||||
|
FieldKey="@Context.FieldKey" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<li class="ec-multi-list-empty-hint">
|
||||||
|
<span class="text-muted">(none)</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly string _anchorBase = "ec-domain-" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
|
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc =>
|
||||||
|
Context.Descriptor as EffectiveMultiValueConfigFieldDescriptor;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@inherits EffectiveConfigValueDisplayBase
|
||||||
|
|
||||||
|
@* Reusable value-only display for boolean/flag options. When active editor: switch (unless readonly); View: "Enabled" / "Disabled". Pending value shown until save. *@
|
||||||
|
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||||
|
{
|
||||||
|
var isSet = GetEffectiveDisplayValue() is bool b && b;
|
||||||
|
<div class="form-check form-switch ec-effective-switch">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="form-check-input"
|
||||||
|
checked="@isSet"
|
||||||
|
aria-label="Enable @Descriptor.OptionName"
|
||||||
|
title="Set @Descriptor.OptionName"
|
||||||
|
@onchange="OnCheckedChange"
|
||||||
|
@onfocusout="OnCommitRequested" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@(GetEffectiveDisplayValue() is bool isSet && isSet ? "Enabled" : "Disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private async Task OnCheckedChange(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
await NotifyValueChanged(e.Value is true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@inherits EffectiveConfigValueDisplayBase
|
||||||
|
|
||||||
|
@* Value-only display for int? options. Pending value shown until save. *@
|
||||||
|
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||||
|
{
|
||||||
|
var current = GetEffectiveDisplayValue() is int i ? i : DefaultValue;
|
||||||
|
<input type="number" class="form-control form-control-sm" style="max-width: 8rem;"
|
||||||
|
min="@Min" max="@Max" value="@(current?.ToString() ?? "")" @oninput="OnInput" @onfocusout="OnCommitRequested" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var v = GetEffectiveDisplayValue() is int n ? n : (int?)null;
|
||||||
|
if (v is null)
|
||||||
|
{
|
||||||
|
@("(not set)")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@v
|
||||||
|
@if (!string.IsNullOrEmpty(ViewSuffix)) { <text> @ViewSuffix</text> }
|
||||||
|
@if (!string.IsNullOrEmpty(Unit)) { <text> @Unit</text> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private EffectiveIntegerConfigFieldDescriptor? IntDescriptor => Descriptor as EffectiveIntegerConfigFieldDescriptor;
|
||||||
|
private string? ViewSuffix => IntDescriptor?.ViewSuffix;
|
||||||
|
private string? Unit => IntDescriptor?.Unit;
|
||||||
|
private int Min => IntDescriptor?.Min ?? 0;
|
||||||
|
private int Max => IntDescriptor?.Max ?? int.MaxValue;
|
||||||
|
private int? DefaultValue => IntDescriptor?.DefaultValue;
|
||||||
|
|
||||||
|
private async Task OnInput(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
var raw = e.Value?.ToString();
|
||||||
|
object? value = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
value = null;
|
||||||
|
}
|
||||||
|
else if (int.TryParse(raw, out var num))
|
||||||
|
{
|
||||||
|
value = num;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
value = raw;
|
||||||
|
}
|
||||||
|
await NotifyValueChanged(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user