mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9809ce9f7b | ||
|
|
181cbbed48 | ||
|
|
36a160dfc7 | ||
|
|
1bf33f1a9b | ||
|
|
b4d1f39762 | ||
|
|
214d6c686e | ||
|
|
4c1c19b792 | ||
|
|
98d1bc560e | ||
|
|
fd16d50107 | ||
|
|
de8afed68c | ||
|
|
9e42a4e2df | ||
|
|
d910688742 | ||
|
|
a03e93bcd3 | ||
|
|
6cf4c07695 | ||
|
|
f2e73107d1 | ||
|
|
ff94be87b2 | ||
|
|
a9f8961c32 | ||
|
|
baaba38e23 | ||
|
|
5f728900dc | ||
|
|
87179f0195 | ||
|
|
3b22e4fb7d | ||
|
|
a3cc3968af | ||
|
|
8ed66cdf48 | ||
|
|
a776f445b6 | ||
|
|
b0ce7afb58 | ||
|
|
2cfbaf000c | ||
|
|
f8754d8d6e | ||
|
|
71a8a61cb3 | ||
|
|
bf1f6a0d82 | ||
|
|
91beb9d181 | ||
|
|
4e122ba439 | ||
|
|
6a7c0f62e1 | ||
|
|
f22b039e74 | ||
|
|
906e222ec1 | ||
|
|
ecd9e5cc7b | ||
|
|
e7c2560d07 | ||
|
|
439192058e | ||
|
|
7965fc669b | ||
|
|
dfee96650b | ||
|
|
cefc488712 | ||
|
|
5ac2006b80 |
@@ -30,7 +30,7 @@ jobs:
|
||||
docker run --rm \
|
||||
-v "${{ github.workspace }}:/src" -w /src \
|
||||
mcr.microsoft.com/dotnet/sdk:10.0 \
|
||||
./scripts/publish-self-contained.sh ${{ matrix.rid }}
|
||||
./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: Publish for ${{ matrix.rid }} (musl on host)
|
||||
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
|
||||
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }}
|
||||
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
|
||||
|
||||
- name: Zip publish output
|
||||
run: |
|
||||
|
||||
+38
-8
@@ -2,7 +2,15 @@
|
||||
# Both run as root; file permissions work because config dirs are in the container.
|
||||
# For dnsmasq on the host, use the self-contained publish (scripts/publish-self-contained.sh)
|
||||
# or run the app on the host; see DnsmasqOptions XML doc for permissions and ReloadCommand scope.
|
||||
#
|
||||
# Dnsmasq install mode for the test harness:
|
||||
# - DNSMASQ_VERSION=latest (default): build the latest stable upstream dnsmasq release.
|
||||
# - DNSMASQ_VERSION=<version> : build that exact upstream release (e.g. 2.91).
|
||||
# - DNSMASQ_VERSION=distro : use the distro package from apt.
|
||||
#
|
||||
# This keeps day-to-day harness use close to upstream while still allowing pinned-version testing.
|
||||
|
||||
ARG DNSMASQ_VERSION=
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
@@ -19,15 +27,37 @@ RUN dotnet build "DnsmasqWebUI.csproj" -c Release -o /app/build
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS app
|
||||
FROM base AS final
|
||||
ARG DNSMASQ_VERSION=latest
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
FROM app AS final
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY scripts/entrypoint.sh .
|
||||
COPY scripts/dnsmasq-status.sh .
|
||||
# Install dnsmasq either from the distro package or by building an upstream release.
|
||||
RUN if [ "$DNSMASQ_VERSION" = "distro" ]; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
else \
|
||||
apt-get update && apt-get install -y --no-install-recommends procps curl build-essential xz-utils ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& if [ "$DNSMASQ_VERSION" = "latest" ]; then \
|
||||
DNSMASQ_VERSION="$(curl -fsSL https://thekelleys.org.uk/dnsmasq/ | grep -o 'dnsmasq-[0-9][0-9.]*\.tar\.xz' | sed 's/^dnsmasq-//; s/\.tar\.xz$//' | sort -V | tail -n1)"; \
|
||||
fi \
|
||||
&& test -n "$DNSMASQ_VERSION" \
|
||||
&& echo "Building dnsmasq $DNSMASQ_VERSION from upstream source" \
|
||||
&& curl -fsSL "https://thekelleys.org.uk/dnsmasq/dnsmasq-${DNSMASQ_VERSION}.tar.xz" -o /tmp/dnsmasq.tar.xz \
|
||||
&& tar -C /tmp -xJf /tmp/dnsmasq.tar.xz \
|
||||
&& make -C /tmp/dnsmasq-${DNSMASQ_VERSION} -j"$(nproc)" \
|
||||
&& make -C /tmp/dnsmasq-${DNSMASQ_VERSION} install PREFIX=/usr \
|
||||
&& rm -rf /tmp/dnsmasq-${DNSMASQ_VERSION} /tmp/dnsmasq.tar.xz \
|
||||
&& apt-get purge -y build-essential curl xz-utils \
|
||||
&& apt-get autoremove -y --purge \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
COPY scripts/entrypoint.sh scripts/dnsmasq-status.sh ./
|
||||
RUN chmod +x entrypoint.sh dnsmasq-status.sh
|
||||
# curl for HEALTHCHECK (aspnet image has no curl/wget)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
# .NET publish last - only this layer invalidates when code changes
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f -s http://localhost:8080/healthz/ready || exit 1
|
||||
|
||||
@@ -19,6 +19,8 @@ A self-hosted web UI for managing [dnsmasq](https://thekelleys.org.uk/dnsmasq/do
|
||||
- **DHCP host entries** (reservations) if you use DHCP; edit in the UI, written into the managed config.
|
||||
- **Reload dnsmasq** after config changes (configurable command, e.g. `systemctl reload dnsmasq` or `pkill -HUP -x dnsmasq`).
|
||||
- Optional **status** and **recent logs** commands (e.g. systemctl/journalctl or custom scripts) shown on the Dnsmasq page.
|
||||
- **Minimum dnsmasq version** check (configurable; e.g. 2.91). The UI can refuse to start or block config save if the detected version is too old. Version is shown on the Dnsmasq page with a link to release notes.
|
||||
- **Readiness endpoint** `GET /healthz/ready` for orchestration (Kubernetes, Docker, load balancers). Returns healthy when dnsmasq version meets the minimum and, if configured, dnsmasq is running. The Docker image includes a HEALTHCHECK that uses this endpoint.
|
||||
- **Self-contained Linux binaries** per OS/arch (RID); no .NET install required. Can run in Docker or directly on the host.
|
||||
|
||||
<!-- SUGGESTED SCREENSHOT: MAIN CONFIG OR CONFIG EDITOR PAGE IN THE BROWSER -->
|
||||
@@ -163,7 +165,7 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
```
|
||||
|
||||
Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via environment variables or an `appsettings.json` in `/app`. Mount your config dir and expose 8080.
|
||||
Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via environment variables or an `appsettings.json` in `/app`. Mount your config dir and expose 8080. The image includes a **HEALTHCHECK** that calls `GET /healthz/ready`; orchestration (e.g. Kubernetes readiness probe, or Compose `depends_on: condition: service_healthy`) can use the same endpoint.
|
||||
|
||||
**Config summary for containers:**
|
||||
|
||||
@@ -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__ReloadCommand` | `pkill -HUP -x dnsmasq` | Run after config changes |
|
||||
| `Dnsmasq__StatusShowCommand` | `/app/dnsmasq-status.sh` | Optional status output (e.g. our script in test harness) |
|
||||
| `Dnsmasq__MinimumVersion` | `2.91` | Minimum dnsmasq version (optional; default 2.91). Set `Dnsmasq__EnforceMinimumVersion=false` to allow older versions. |
|
||||
| `ASPNETCORE_URLS` | `http://+:8080` | Port the app listens on |
|
||||
|
||||
**Readiness:** `GET http://localhost:8080/healthz/ready` returns JSON `{"status":"ok"}` when dnsmasq version meets the minimum and (if `StatusCommand` is set) dnsmasq is running. Use this for Kubernetes readiness probes, Docker HEALTHCHECK, or load balancer health checks.
|
||||
|
||||
See [Configuration](#configuration) for all options.
|
||||
|
||||
<!-- SUGGESTED SCREENSHOT: EXAMPLE DOCKERFILE SNIPPET OR DOCKER RUN COMMAND SHOWING DNSMASQ + APP IN ONE CONTAINER -->
|
||||
@@ -205,6 +210,9 @@ The app is configured via **appsettings.json**, **environment variables**, and *
|
||||
| `StatusCommand` | Optional: check if dnsmasq is running | `pgrep -x dnsmasq` |
|
||||
| `StatusShowCommand` | Optional: full status output (e.g. systemctl status) | `systemctl status dnsmasq --no-pager` |
|
||||
| `LogsCommand` | Optional: recent logs (e.g. journalctl) | `journalctl -u dnsmasq -n 100 --no-pager` |
|
||||
| `VersionCommand` | Command to probe dnsmasq version (used for minimum-version check and UI display) | `dnsmasq --version` |
|
||||
| `MinimumVersion` | Minimum dnsmasq version required (e.g. 2.91). Some options need newer dnsmasq. | `2.91` |
|
||||
| `EnforceMinimumVersion` | If true, app fails to start when version probe fails or version is below minimum. If false, only save and readiness checks enforce. | `true` |
|
||||
|
||||
**Application options** (use `Application__` prefix for env, `Application` section in JSON):
|
||||
|
||||
@@ -391,7 +399,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
|
||||
### 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`), cleans up previous test data, then runs `docker compose -f docker-compose.test.yml up -d` (or only prepares the mount with `--prepare-only`). By default the script resolves and uses the **latest stable upstream dnsmasq version**; you can pin a specific version (or use the distro package) for compatibility testing.
|
||||
|
||||
**Usage:** `./scripts/prepare-test-mount.sh [OPTIONS] [--]`
|
||||
|
||||
@@ -401,9 +409,11 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
|--------|-------------|
|
||||
| `--source DIR` | Source to copy from (default: `testdata`). |
|
||||
| `--mount DIR` | Target mount directory (default: `testdata-mount`). Compose uses `TESTDATA_MOUNT`; script exports it when you use `--mount`. |
|
||||
| `--no-clear` | Do not clear mount dir; only sync over existing contents (e.g. preserve leases between runs). |
|
||||
| `--dnsmasq-version V` | Dnsmasq version for the harness image: `latest` (default), `distro`, or an exact upstream version like `2.91`. |
|
||||
| `--clear` | Clear the mount dir completely before sync for a clean run. Default: preserve existing contents and sync over them. |
|
||||
| `--prepare-only` | Only prepare the mount; do not run docker compose. |
|
||||
| `--build` | Pass `--build` to docker compose (rebuild images). Default: use existing images. |
|
||||
| `--no-cache-build` | Run `docker compose build --pull --no-cache` before start. Use to force a fresh image build and refresh the resolved latest dnsmasq version. |
|
||||
| `--no-build` | Do not rebuild (default). |
|
||||
| `--recreate` | Pass `--force-recreate` to docker compose. |
|
||||
| `--stop` | Stop the harness: `docker compose down` (no prepare, no start). |
|
||||
@@ -419,12 +429,22 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
# Rebuild images then start
|
||||
./scripts/prepare-test-mount.sh --build
|
||||
|
||||
# Force a fresh image build with no Docker build cache
|
||||
./scripts/prepare-test-mount.sh --no-cache-build
|
||||
|
||||
# Rebuild with a specific dnsmasq version
|
||||
./scripts/prepare-test-mount.sh --dnsmasq-version 2.91 --build
|
||||
|
||||
# Use the distro package instead of an upstream build
|
||||
./scripts/prepare-test-mount.sh --dnsmasq-version distro --build
|
||||
|
||||
# Only prepare the mount; start manually later
|
||||
./scripts/prepare-test-mount.sh --prepare-only
|
||||
# Then: TESTDATA_MOUNT=./testdata-mount docker compose -f docker-compose.test.yml up -d
|
||||
# Then use the version printed by the script, e.g.:
|
||||
# TESTDATA_MOUNT=./testdata-mount DNSMASQ_VERSION=<resolved-version> docker compose -f docker-compose.test.yml up -d
|
||||
|
||||
# Preserve mount contents, sync over it, start
|
||||
./scripts/prepare-test-mount.sh --no-clear
|
||||
# Preserve mount contents, sync over it, start (default behavior)
|
||||
./scripts/prepare-test-mount.sh
|
||||
|
||||
# Stop the harness
|
||||
./scripts/prepare-test-mount.sh --stop
|
||||
@@ -449,6 +469,21 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
|
||||
**Usage:** Set `DNSMASQ_CONF` to the path of the dnsmasq config file inside the container (e.g. `/data/dnsmasq-test.conf`). No CLI arguments.
|
||||
|
||||
---
|
||||
|
||||
### scripts/extract-option-help.sh
|
||||
|
||||
**Purpose:** Fetch the dnsmasq man page HTML and extract per-option help fragments into `src/DnsmasqWebUI/wwwroot/option-help/*.html`. The Effective Config UI loads these when the user clicks an option label (tooltip is from code; full help is from these files). Requires Docker (uses a temporary `python:3-slim` container). When adding new options to the UI, add the option key to the script’s `OPTION_KEYS` list (keep in sync with `EffectiveConfigSections` / `DnsmasqOptionTooltips`), then run this script so help is available for the new option (if it exists in the man page).
|
||||
|
||||
**Usage:** `./scripts/extract-option-help.sh [--url URL] [--output-dir DIR]`
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--url URL` | Man page URL (default: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html). |
|
||||
| `--output-dir DIR` | Output directory (default: `src/DnsmasqWebUI/wwwroot/option-help`). |
|
||||
|
||||
Run from the repo root. Writes one `.html` file per option that appears in the man page OPTIONS section; options in `OPTION_KEYS` but not in the man page are skipped.
|
||||
|
||||
<!-- SUGGESTED SCREENSHOT: TERMINAL WITH PREPARE-TEST-MOUNT.SH USAGE OR DOCKER COMPOSE UP OUTPUT -->
|
||||
|
||||
---
|
||||
|
||||
@@ -17,17 +17,20 @@ From the repo root:
|
||||
|
||||
This will:
|
||||
|
||||
1. Clear `testdata-mount/` (unless you pass `--no-clear`)
|
||||
2. Sync `testdata/` → `testdata-mount/`
|
||||
3. Remove any `*dnsmasq-webui*.conf` in the mount so dnsmasq starts clean
|
||||
1. Sync `testdata/` to `testdata-mount/` (preserving existing contents unless you pass `--clear`)
|
||||
2. 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 needed
|
||||
4. Run `docker compose -f docker-compose.test.yml up -d --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`
|
||||
Then start manually: `TESTDATA_MOUNT=./testdata-mount docker compose -f docker-compose.test.yml up -d [--build]`
|
||||
- **Preserve mount (e.g. keep leases):** `./scripts/prepare-test-mount.sh --no-clear --no-build`
|
||||
Then start manually with the version printed by the script: `TESTDATA_MOUNT=./testdata-mount DNSMASQ_VERSION=<resolved-version> docker compose -f docker-compose.test.yml up -d [--build]`
|
||||
- **Preserve mount (e.g. keep leases):** `./scripts/prepare-test-mount.sh --no-build` (default mount behavior)
|
||||
|
||||
App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory.
|
||||
App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory. The app service has a healthcheck (`GET /healthz/ready`); DHCP client services use `depends_on: app: condition: service_healthy` so they start only after the app (and dnsmasq) is ready.
|
||||
|
||||
### Stop the harness
|
||||
|
||||
@@ -49,6 +52,8 @@ Stops the harness and deletes the contents of `testdata-mount/` so the next run
|
||||
|
||||
- `--source DIR` — Source to sync from (default: `testdata`)
|
||||
- `--mount DIR` — Mount directory (default: `testdata-mount`). The script exports `TESTDATA_MOUNT=./DIR` when you use `--mount`, so compose uses it.
|
||||
- `--dnsmasq-version V` — Dnsmasq version for the harness image: `latest` (default), `distro`, or an exact upstream version like `2.91`.
|
||||
- `--no-cache-build` — Force a fresh image build with `docker compose build --pull --no-cache` before start.
|
||||
|
||||
Example: sync from a custom dir and start:
|
||||
|
||||
@@ -76,5 +81,12 @@ Build only: `dotnet build`
|
||||
|
||||
- **testdata/** — Source for the harness mount. Synced to `testdata-mount/` by `prepare-test-mount.sh`. Unit tests read from the copy in the test output dir. See **testdata/README** for the full layout.
|
||||
- **testdata/dnsmasq-test.conf** — Harness main config (`/data/dnsmasq-test.conf`). Richer config: multiple `server=`, `addn-hosts=`, `address=`, `listen-address=`, `conf-dir=/data/dnsmasq.d`.
|
||||
- **testdata/dnsmasq.d/** — Included configs (01-other.conf, 02-servers.conf, dhcp.conf). App creates `zz-dnsmasq-webui.conf` here when running.
|
||||
- **testdata/dnsmasq.d/** — Included configs (01-other.conf, 02-servers.conf, 03-more.conf, dhcp.conf). App creates `zz-dnsmasq-webui.conf` here when running.
|
||||
- **testdata/hosts**, **testdata/hosts.extra** — Addn-hosts files so effective config has multiple addn-hosts; UI can show source per path.
|
||||
|
||||
## When adding new effective-config options
|
||||
|
||||
When adding a new dnsmasq option to the Effective Config UI (new key in `DnsmasqConfKeys`, section, kind map, parser/write behavior, tooltip, etc.):
|
||||
|
||||
1. **Option help:** Add the option key (lowercase, e.g. `no-round-robin`) to the `OPTION_KEYS` list in `scripts/extract-option-help.sh` (keep in sync with `EffectiveConfigSections`). Then run `./scripts/extract-option-help.sh` so `wwwroot/option-help/<key>.html` is generated if the option exists in the dnsmasq man page.
|
||||
2. **Testdata (optional):** To show the option in the UI when using the harness or testdata, add a line to e.g. `testdata/dnsmasq.d/03-more.conf` and update `testdata/README` if needed.
|
||||
|
||||
+22
-7
@@ -1,7 +1,8 @@
|
||||
# Run the app with real dnsmasq and several DHCP clients so hosts/leases are real.
|
||||
# Some clients also make periodic DNS requests so the DNS cache and logs show activity.
|
||||
# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
|
||||
# Prepare mount: ./scripts/prepare-test-mount.sh [--minimal-conf | --prepare-only | ...]
|
||||
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
|
||||
# Set TEST_DNSMASQ_CONF to override main config path (default: /data/dnsmasq-test.conf; use /data/dnsmasq-test-minimal.conf for minimal).
|
||||
#
|
||||
# No systemd in container (dnsmasq runs as a process from entrypoint, like most dnsmasq images).
|
||||
# StatusShowCommand/LogsCommand use ps so the Service status / Recent logs sections show something
|
||||
@@ -13,14 +14,24 @@ services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
DNSMASQ_VERSION: ${DNSMASQ_VERSION:-latest}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "-s", "http://localhost:8080/healthz/ready"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
DNSMASQ_CONF: /data/dnsmasq-test.conf
|
||||
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
|
||||
DNSMASQ_CONF: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
|
||||
Dnsmasq__MainConfigPath: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
|
||||
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf
|
||||
# No systemd in container: restart by killing and re-running dnsmasq (same as entrypoint.sh). Config path must match DNSMASQ_CONF/MainConfigPath.
|
||||
Dnsmasq__RestartCommand: "pkill -x dnsmasq || true; sleep 1; dnsmasq --test --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} && dnsmasq --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}"
|
||||
Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
|
||||
Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
|
||||
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
|
||||
@@ -41,7 +52,8 @@ services:
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
# DHCP + periodic DNS lookups (every 20s) so DNS cache/logs show activity.
|
||||
dhcp-client-dns-a:
|
||||
@@ -60,7 +72,8 @@ services:
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
# DHCP + periodic DNS lookups (every 45s); different interval for variety.
|
||||
dhcp-client-dns-b:
|
||||
@@ -79,7 +92,8 @@ services:
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
# Third DHCP-only client so the leases table has more entries.
|
||||
dhcp-client-2:
|
||||
@@ -88,7 +102,8 @@ services:
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
testnet:
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
#!/bin/sh
|
||||
# Simulates "systemctl status dnsmasq" output when run in a container without systemd.
|
||||
# Uses ps/pgrep. Memory is RSS. Uptime line omitted; Active line has break after semicolon with continuation indented.
|
||||
# When dnsmasq is not running, runs dnsmasq --test to show config/startup errors (use DNSMASQ_CONF for config path).
|
||||
|
||||
pid=$(pgrep -x dnsmasq)
|
||||
if [ -z "$pid" ]; then
|
||||
echo "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server"
|
||||
echo " Loaded: (container, no systemd)"
|
||||
echo " Active: inactive (dead)"
|
||||
conf="${DNSMASQ_CONF:-/etc/dnsmasq.conf}"
|
||||
if [ -f "$conf" ]; then
|
||||
err=$(dnsmasq --test --conf-file="$conf" 2>&1)
|
||||
if [ -n "$err" ]; then
|
||||
echo ""
|
||||
echo " Config test (dnsmasq --test):"
|
||||
echo "$err" | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
Executable
+307
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env bash
|
||||
# Extract per-option HTML from the dnsmasq man page for use as field help in the web UI.
|
||||
#
|
||||
# Runs a temporary Python (BeautifulSoup) container to fetch the page, parse the OPTIONS
|
||||
# <DL>/<DT>/<DD> blocks, and write one .html file per option. The container is removed
|
||||
# when done. If the page URL or structure changes, pass a new --url.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/extract-option-help.sh [--url URL] [--output-dir DIR]
|
||||
#
|
||||
# Requires: Docker (python:3-slim image will be pulled if missing)
|
||||
|
||||
set -e
|
||||
|
||||
DEFAULT_URL="https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html"
|
||||
DEFAULT_OUTPUT_DIR="src/DnsmasqWebUI/wwwroot/option-help"
|
||||
URL="$DEFAULT_URL"
|
||||
OUTPUT_DIR="$DEFAULT_OUTPUT_DIR"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--url) URL="$2"; shift 2 ;;
|
||||
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$OUTPUT_DIR" != /* ]] && OUTPUT_DIR="$PWD/$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
SCRIPT_FILE="$(mktemp)"
|
||||
trap 'rm -f "$SCRIPT_FILE"' EXIT
|
||||
|
||||
# Embed Python script (uses requests + BeautifulSoup; runs inside container)
|
||||
cat << 'PYTHON_SCRIPT' > "$SCRIPT_FILE"
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError:
|
||||
sys.stderr.write("Installing requests and beautifulsoup4...\n")
|
||||
import subprocess
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "requests", "beautifulsoup4"])
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
OUT_DIR = "/out"
|
||||
URL = os.environ.get("URL", "https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html")
|
||||
|
||||
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections.
|
||||
OPTION_KEYS = [
|
||||
"no-hosts",
|
||||
"addn-hosts",
|
||||
"hostsdir",
|
||||
"read-ethers",
|
||||
"server",
|
||||
"local",
|
||||
"rev-server",
|
||||
"address",
|
||||
"resolv-file",
|
||||
"no-resolv",
|
||||
"domain-needed",
|
||||
"port",
|
||||
"log-queries",
|
||||
"strict-order",
|
||||
"all-servers",
|
||||
"auth-ttl",
|
||||
"edns-packet-max",
|
||||
"query-port",
|
||||
"port-limit",
|
||||
"min-port",
|
||||
"max-port",
|
||||
"dns-loop-detect",
|
||||
"stop-dns-rebind",
|
||||
"rebind-localhost-ok",
|
||||
"clear-on-reload",
|
||||
"expand-hosts",
|
||||
"bogus-priv",
|
||||
"rebind-domain-ok",
|
||||
"bogus-nxdomain",
|
||||
"ignore-address",
|
||||
"alias",
|
||||
"filter-rr",
|
||||
"filterwin2k",
|
||||
"filter-A",
|
||||
"filter-AAAA",
|
||||
"localise-queries",
|
||||
"fast-dns-retry",
|
||||
"ipset",
|
||||
"nftset",
|
||||
"domain",
|
||||
"cname",
|
||||
"mx-host",
|
||||
"srv-host",
|
||||
"ptr-record",
|
||||
"txt-record",
|
||||
"naptr-record",
|
||||
"host-record",
|
||||
"dynamic-host",
|
||||
"interface-name",
|
||||
"mx-target",
|
||||
"localmx",
|
||||
"selfmx",
|
||||
"dhcp-authoritative",
|
||||
"dhcp-rapid-commit",
|
||||
"leasefile-ro",
|
||||
"dhcp-script",
|
||||
"dhcp-leasefile",
|
||||
"dhcp-lease-max",
|
||||
"dhcp-ttl",
|
||||
"dhcp-range",
|
||||
"dhcp-host",
|
||||
"dhcp-option",
|
||||
"dhcp-option-force",
|
||||
"dhcp-match",
|
||||
"dhcp-mac",
|
||||
"dhcp-name-match",
|
||||
"dhcp-ignore-names",
|
||||
"dhcp-hostsfile",
|
||||
"dhcp-optsfile",
|
||||
"dhcp-hostsdir",
|
||||
"dhcp-optsdir",
|
||||
"dhcp-boot",
|
||||
"dhcp-ignore",
|
||||
"dhcp-vendorclass",
|
||||
"dhcp-userclass",
|
||||
"ra-param",
|
||||
"slaac",
|
||||
"enable-tftp",
|
||||
"tftp-secure",
|
||||
"tftp-no-fail",
|
||||
"tftp-no-blocksize",
|
||||
"tftp-root",
|
||||
"pxe-prompt",
|
||||
"pxe-service",
|
||||
"dnssec",
|
||||
"dnssec-check-unsigned",
|
||||
"proxy-dnssec",
|
||||
"trust-anchor",
|
||||
"cache-rr",
|
||||
"cache-size",
|
||||
"local-ttl",
|
||||
"no-negcache",
|
||||
"neg-ttl",
|
||||
"max-ttl",
|
||||
"max-cache-ttl",
|
||||
"min-cache-ttl",
|
||||
"no-poll",
|
||||
"bind-interfaces",
|
||||
"bind-dynamic",
|
||||
"log-debug",
|
||||
"log-async",
|
||||
"local-service",
|
||||
"interface",
|
||||
"listen-address",
|
||||
"except-interface",
|
||||
"auth-server",
|
||||
"no-dhcp-interface",
|
||||
"no-dhcpv4-interface",
|
||||
"no-dhcpv6-interface",
|
||||
"pid-file",
|
||||
"user",
|
||||
"group",
|
||||
"log-facility",
|
||||
"enable-dbus",
|
||||
"enable-ubus",
|
||||
"enable-ra",
|
||||
"log-dhcp",
|
||||
"keep-in-foreground",
|
||||
"no-daemon",
|
||||
"conntrack",
|
||||
"connmark-allowlist-enable",
|
||||
"no-round-robin",
|
||||
"dns-forward-max",
|
||||
"connmark-allowlist",
|
||||
"do-0x20-encode",
|
||||
"caa-record",
|
||||
"dns-rr",
|
||||
"synth-domain",
|
||||
"auth-zone",
|
||||
"auth-soa",
|
||||
"auth-sec-servers",
|
||||
"auth-peer",
|
||||
"leasequery",
|
||||
"dhcp-generate-names",
|
||||
"dhcp-broadcast",
|
||||
"dhcp-sequential-ip",
|
||||
"dhcp-ignore-clid",
|
||||
"bootp-dynamic",
|
||||
"no-ping",
|
||||
"script-arp",
|
||||
"script-on-renewal",
|
||||
"dhcp-no-override",
|
||||
"dhcp-alternate-port",
|
||||
"dhcp-duid",
|
||||
"dhcp-luascript",
|
||||
"dhcp-scriptuser",
|
||||
"dhcp-relay",
|
||||
"dhcp-circuitid",
|
||||
"dhcp-remoteid",
|
||||
"dhcp-subscrid",
|
||||
"dhcp-proxy",
|
||||
"tag-if",
|
||||
"bridge-interface",
|
||||
"shared-network",
|
||||
"dhcp-pxe-vendor",
|
||||
"dhcp-option-pxe",
|
||||
"dnssec-no-timecheck",
|
||||
"dnssec-debug",
|
||||
"add-cpe-id",
|
||||
"dnssec-timestamp",
|
||||
"dnssec-limits",
|
||||
"dumpfile",
|
||||
"dumpmask",
|
||||
"use-stale-cache",
|
||||
"add-mac",
|
||||
"strip-mac",
|
||||
"add-subnet",
|
||||
"strip-subnet",
|
||||
"umbrella",
|
||||
"quiet-dhcp",
|
||||
"quiet-dhcp6",
|
||||
"quiet-ra",
|
||||
"quiet-tftp",
|
||||
]
|
||||
|
||||
long_opt_re = re.compile(r"--([a-z][a-z0-9-]*)(?:=[^,\s]*)?", re.IGNORECASE)
|
||||
|
||||
def option_names_from_dt(dt):
|
||||
return [m.group(1).lower() for m in long_opt_re.finditer(dt.get_text())]
|
||||
|
||||
def main():
|
||||
r = requests.get(URL, timeout=30)
|
||||
r.raise_for_status()
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
h2 = None
|
||||
for el in soup.find_all("h2"):
|
||||
if el.get_text(strip=True).upper() == "OPTIONS":
|
||||
h2 = el
|
||||
break
|
||||
if not h2:
|
||||
sys.stderr.write("Could not find OPTIONS H2\n")
|
||||
sys.exit(1)
|
||||
dl = h2.find_next("dl")
|
||||
if not dl:
|
||||
sys.stderr.write("Could not find DL after OPTIONS\n")
|
||||
sys.exit(1)
|
||||
# Build option -> (dt_html, dd_html) from raw HTML; split by <dt> so we get one block per option.
|
||||
raw_dl = str(dl)
|
||||
option_to_block = {}
|
||||
blocks = re.split(r"<dt\b", raw_dl, flags=re.IGNORECASE)
|
||||
for block in blocks[1:]:
|
||||
p_dt = block.find("</dt>") if "</dt>" in block.lower() else len(block) + 1
|
||||
m_dd = re.search(r"<dd\b", block, re.IGNORECASE)
|
||||
p_dd = m_dd.start() if m_dd else len(block) + 1
|
||||
end_pos = min(p_dt, p_dd)
|
||||
if end_pos > len(block):
|
||||
continue
|
||||
dt_part = block[:end_pos].strip()
|
||||
rest = block[end_pos:]
|
||||
if rest.lower().startswith("</dt>"):
|
||||
rest = rest[6:].lstrip()
|
||||
if ">" in dt_part:
|
||||
dt_part = dt_part.split(">", 1)[1]
|
||||
dt_html = "<dt>" + dt_part + "</dt>"
|
||||
dd_match = re.search(r"<dd\b[^>]*>(.*)", rest, re.DOTALL | re.IGNORECASE)
|
||||
dd_body = dd_match.group(1) if dd_match else ""
|
||||
dd_body = re.split(r"</dd>|<dt\s|<dt>", dd_body, flags=re.IGNORECASE)[0].strip()
|
||||
dd_html = "<dd>" + dd_body + "</dd>" if dd_body else "<dd></dd>"
|
||||
soup_dt = BeautifulSoup(dt_html, "html.parser").find("dt")
|
||||
if not soup_dt:
|
||||
continue
|
||||
for n in option_names_from_dt(soup_dt):
|
||||
if n not in option_to_block:
|
||||
option_to_block[n] = (dt_html, dd_html)
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
written = 0
|
||||
for key in OPTION_KEYS:
|
||||
norm = key.lower()
|
||||
if norm not in option_to_block:
|
||||
continue
|
||||
dt_html, dd_html = option_to_block[norm]
|
||||
frag = "\n".join([dt_html, dd_html])
|
||||
safe = re.sub(r"[^a-z0-9-]", "", norm) or key
|
||||
path = os.path.join(OUT_DIR, f"{safe}.html")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(frag)
|
||||
written += 1
|
||||
print(f"Wrote {written} files to {OUT_DIR}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
PYTHON_SCRIPT
|
||||
|
||||
echo "Fetching man page and extracting option help (Docker python:3-slim) ..."
|
||||
docker run --rm \
|
||||
-v "$OUTPUT_DIR:/out" \
|
||||
-v "$SCRIPT_FILE:/script.py:ro" \
|
||||
-e "URL=$URL" \
|
||||
-e "PIP_ROOT_USER_ACTION=ignore" \
|
||||
python:3-slim \
|
||||
sh -c "pip install -q requests beautifulsoup4 && python /script.py"
|
||||
|
||||
echo "Done. Files in $OUTPUT_DIR"
|
||||
@@ -15,12 +15,15 @@ COMPOSE_FILE="docker-compose.test.yml"
|
||||
|
||||
SOURCE_DIR=""
|
||||
MOUNT_DIR=""
|
||||
DNSMASQ_VERSION=""
|
||||
PREPARE_ONLY=false
|
||||
BUILD=false
|
||||
NO_CACHE_BUILD=false
|
||||
RECREATE=false
|
||||
CLEAR=false
|
||||
STOP=false
|
||||
TIDY=false
|
||||
MINIMAL_CONF=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS] [--]"
|
||||
@@ -37,17 +40,25 @@ usage() {
|
||||
echo " --source DIR Source to copy from (default: testdata)"
|
||||
echo " --mount DIR Target mount directory (default: testdata-mount)"
|
||||
echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount."
|
||||
echo " --dnsmasq-version V Dnsmasq version for the harness image:"
|
||||
echo " latest (default), distro, or an exact upstream version like 2.91."
|
||||
echo " Non-default values require --build or --no-cache-build; script fails with --no-build."
|
||||
echo ""
|
||||
echo "Mount behaviour:"
|
||||
echo " (default) Preserve mount dir; sync source over existing contents."
|
||||
echo " --clear Clear mount dir completely before sync. Use for a clean run."
|
||||
echo ""
|
||||
echo "Compose behaviour:"
|
||||
echo " --minimal-conf Use minimal dnsmasq config (dnsmasq-test-minimal.conf) so effective"
|
||||
echo " config has few readonly options; good for testing reload/restart failure flows."
|
||||
echo " --prepare-only Only prepare the mount; do not run docker compose."
|
||||
echo " Use to inspect or edit the mount before starting containers."
|
||||
echo " --build Pass --build to docker compose (rebuild images before starting)."
|
||||
echo " Use after changing the app or Dockerfile. Default: use existing images."
|
||||
echo " --no-cache-build Rebuild images with --pull --no-cache before starting."
|
||||
echo " Use to force a fresh image build and refresh 'latest' dnsmasq."
|
||||
echo " --no-build Do not rebuild (default). Use existing images for a quick restart."
|
||||
echo " With --no-build, 'latest' is not resolved (no network)."
|
||||
echo " --recreate Pass --force-recreate to docker compose (recreate containers)."
|
||||
echo " Use to ensure fresh container state and mounts."
|
||||
echo ""
|
||||
@@ -69,6 +80,9 @@ usage() {
|
||||
echo " $0 --recreate"
|
||||
echo " Sync, then up -d --force-recreate (fresh containers)."
|
||||
echo ""
|
||||
echo " $0 --no-cache-build"
|
||||
echo " Force a fresh image build (no Docker build cache), then start containers."
|
||||
echo ""
|
||||
echo " $0 --clear"
|
||||
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
|
||||
echo ""
|
||||
@@ -85,6 +99,38 @@ usage() {
|
||||
echo ""
|
||||
echo " $0 --tidy"
|
||||
echo " Stop harness and clear testdata-mount for a clean next run."
|
||||
echo ""
|
||||
echo " $0 --minimal-conf"
|
||||
echo " Use dnsmasq-test-minimal.conf (single file, few options) and start."
|
||||
echo ""
|
||||
echo " $0 --dnsmasq-version 2.91 --build"
|
||||
echo " Rebuild the harness image with dnsmasq 2.91."
|
||||
}
|
||||
|
||||
fetch_url() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$1"
|
||||
return $?
|
||||
fi
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- "$1"
|
||||
return $?
|
||||
fi
|
||||
echo "Error: need curl or wget to resolve the latest upstream dnsmasq version." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_dnsmasq_version() {
|
||||
if [ "$DNSMASQ_VERSION" != "latest" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
RESOLVED_VERSION="$(fetch_url "https://thekelleys.org.uk/dnsmasq/" | sed -n 's/.*LATEST_IS_\([0-9][0-9.]*\).*/\1/p' | head -n1)"
|
||||
if [ -z "$RESOLVED_VERSION" ]; then
|
||||
echo "Error: could not resolve the latest upstream dnsmasq version." >&2
|
||||
exit 1
|
||||
fi
|
||||
DNSMASQ_VERSION="$RESOLVED_VERSION"
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -105,6 +151,12 @@ while [ $# -gt 0 ]; do
|
||||
MOUNT_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
--dnsmasq-version)
|
||||
shift
|
||||
[ $# -gt 0 ] || { echo "Error: --dnsmasq-version requires VERSION" >&2; exit 1; }
|
||||
DNSMASQ_VERSION="$1"
|
||||
shift
|
||||
;;
|
||||
--clear)
|
||||
CLEAR=true
|
||||
shift
|
||||
@@ -117,14 +169,24 @@ while [ $# -gt 0 ]; do
|
||||
BUILD=true
|
||||
shift
|
||||
;;
|
||||
--no-cache-build)
|
||||
NO_CACHE_BUILD=true
|
||||
BUILD=true
|
||||
shift
|
||||
;;
|
||||
--no-build)
|
||||
BUILD=false
|
||||
NO_CACHE_BUILD=false
|
||||
shift
|
||||
;;
|
||||
--recreate)
|
||||
RECREATE=true
|
||||
shift
|
||||
;;
|
||||
--minimal-conf)
|
||||
MINIMAL_CONF=true
|
||||
shift
|
||||
;;
|
||||
--stop)
|
||||
STOP=true
|
||||
shift
|
||||
@@ -160,6 +222,7 @@ cd "$REPO_ROOT"
|
||||
|
||||
: "${SOURCE_DIR:=testdata}"
|
||||
: "${MOUNT_DIR:=testdata-mount}"
|
||||
: "${DNSMASQ_VERSION:=latest}"
|
||||
|
||||
# Stop and/or tidy: no prepare, no start
|
||||
if [ "$STOP" = true ] || [ "$TIDY" = true ]; then
|
||||
@@ -182,6 +245,17 @@ if [ ! -d "$SOURCE_DIR" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve "latest" only when we will build; avoid network when doing a no-build run.
|
||||
WILL_BUILD=false
|
||||
[ "$BUILD" = true ] || [ "$NO_CACHE_BUILD" = true ] && WILL_BUILD=true
|
||||
if [ "$WILL_BUILD" = false ] && [ "$DNSMASQ_VERSION" != "latest" ] && [ "$DNSMASQ_VERSION" != "distro" ]; then
|
||||
echo "Error: --dnsmasq-version $DNSMASQ_VERSION has no effect without --build or --no-cache-build. Add --build or omit --dnsmasq-version for a no-build run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$WILL_BUILD" = true ]; then
|
||||
resolve_dnsmasq_version
|
||||
fi
|
||||
|
||||
# Take the harness down so all containers (including one-shot DHCP clients) are recreated on up.
|
||||
echo "Stopping test harness: docker compose -f $COMPOSE_FILE down"
|
||||
docker compose -f "$COMPOSE_FILE" down
|
||||
@@ -210,7 +284,16 @@ if [ "$PREPARE_ONLY" = true ]; then
|
||||
/*) MOUNT_EXPORT="$MOUNT_DIR" ;;
|
||||
*) MOUNT_EXPORT="./$MOUNT_DIR" ;;
|
||||
esac
|
||||
echo "To start the harness: TESTDATA_MOUNT=$MOUNT_EXPORT docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
START_PREFIX="TESTDATA_MOUNT=$MOUNT_EXPORT DNSMASQ_VERSION=$DNSMASQ_VERSION"
|
||||
if [ "$MINIMAL_CONF" = true ]; then
|
||||
START_PREFIX="$START_PREFIX TEST_DNSMASQ_CONF=/data/dnsmasq-test-minimal.conf"
|
||||
fi
|
||||
if [ "$NO_CACHE_BUILD" = true ]; then
|
||||
START_CMD="$START_PREFIX docker compose -f $COMPOSE_FILE build --pull --no-cache && $START_PREFIX docker compose -f $COMPOSE_FILE up -d"
|
||||
else
|
||||
START_CMD="$START_PREFIX docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
fi
|
||||
echo "To start the harness: $START_CMD"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -220,8 +303,30 @@ case "$MOUNT_DIR" in
|
||||
*) export TESTDATA_MOUNT="./$MOUNT_DIR" ;;
|
||||
esac
|
||||
|
||||
export DNSMASQ_VERSION
|
||||
|
||||
case "$DNSMASQ_VERSION" in
|
||||
distro)
|
||||
echo "Using distro-packaged dnsmasq for the harness image."
|
||||
;;
|
||||
*)
|
||||
echo "Using dnsmasq $DNSMASQ_VERSION for the harness image."
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$MINIMAL_CONF" = true ]; then
|
||||
export TEST_DNSMASQ_CONF="/data/dnsmasq-test-minimal.conf"
|
||||
echo "Using minimal config: $TEST_DNSMASQ_CONF"
|
||||
fi
|
||||
|
||||
if [ "$NO_CACHE_BUILD" = true ]; then
|
||||
BUILD_CMD="docker compose -f $COMPOSE_FILE build --pull --no-cache"
|
||||
echo "Running: $BUILD_CMD"
|
||||
$BUILD_CMD
|
||||
fi
|
||||
|
||||
COMPOSE_CMD="docker compose -f $COMPOSE_FILE up -d"
|
||||
if [ "$BUILD" = true ]; then
|
||||
if [ "$BUILD" = true ] && [ "$NO_CACHE_BUILD" != true ]; then
|
||||
COMPOSE_CMD="$COMPOSE_CMD --build"
|
||||
fi
|
||||
if [ "$RECREATE" = true ]; then
|
||||
|
||||
@@ -59,14 +59,17 @@ default_rid() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Optional version override (e.g. v0.0.4 from CI tag). When set, passed to dotnet publish for assembly metadata.
|
||||
VERSION_OVERRIDE=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|-?|--help)
|
||||
echo "Usage: $0 [OPTIONS] [RID]"
|
||||
echo "Usage: $0 [OPTIONS] [RID] [VERSION]"
|
||||
echo ""
|
||||
echo "Build a self-contained folder publish for Linux. If RID is omitted, the script"
|
||||
echo "auto-detects from the current OS and architecture (recommended on Ubuntu and Alpine)."
|
||||
echo "Pass options first, then RID if desired (e.g. $0 --trim linux-x64)."
|
||||
echo "Pass options first, then RID, then optional VERSION (e.g. v0.0.4 from a release tag)."
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --trim Enable trimming (smaller output; can cause 404/routing issues with Blazor)"
|
||||
@@ -112,6 +115,10 @@ while [ $# -gt 0 ]; do
|
||||
*)
|
||||
RID="$1"
|
||||
shift
|
||||
if [ $# -gt 0 ] && [ -n "$1" ] && [ "${1#-}" = "$1" ]; then
|
||||
VERSION_OVERRIDE="$1"
|
||||
shift
|
||||
fi
|
||||
break
|
||||
;;
|
||||
esac
|
||||
@@ -148,19 +155,29 @@ if [ "$CLEAN" = true ]; then
|
||||
dotnet clean "$PROJECT" -c Release -nologo -v q
|
||||
fi
|
||||
|
||||
VERSION_ARGS=""
|
||||
if [ -n "$VERSION_OVERRIDE" ]; then
|
||||
# Strip leading 'v' (e.g. v0.0.4 -> 0.0.4) for assembly metadata
|
||||
VER="${VERSION_OVERRIDE#v}"
|
||||
VERSION_ARGS="-p:Version=$VER -p:InformationalVersion=$VER"
|
||||
echo "Using version: $VER (from $VERSION_OVERRIDE)"
|
||||
fi
|
||||
|
||||
if [ "$TRIM" = true ]; then
|
||||
echo "Publishing self-contained (trimmed, folder) for $RID..."
|
||||
dotnet publish "$PROJECT" \
|
||||
-c Release \
|
||||
-r "$RID" \
|
||||
--self-contained true \
|
||||
-p:PublishTrimmed=true
|
||||
-p:PublishTrimmed=true \
|
||||
$VERSION_ARGS
|
||||
else
|
||||
echo "Publishing self-contained (no trim, folder) for $RID..."
|
||||
dotnet publish "$PROJECT" \
|
||||
-c Release \
|
||||
-r "$RID" \
|
||||
--self-contained true
|
||||
--self-contained true \
|
||||
$VERSION_ARGS
|
||||
fi
|
||||
|
||||
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish"
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -70,4 +70,24 @@ public class DnsmasqConfDirectiveParserTests
|
||||
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
|
||||
Assert.Equal(value, kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_ValueContainsEquals_ParsesFirstEqualsAsKeyValueSeparator()
|
||||
{
|
||||
var line = "server=/local/1.2.3.4";
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal("server", kv!.Value.key);
|
||||
Assert.Equal("/local/1.2.3.4", kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_ErrorOverload_InvalidLine_ReturnsFalseAndSetsError()
|
||||
{
|
||||
// KeyValueLine requires at least one key character before '='; "=no-key" has no key and fails.
|
||||
var ok = DnsmasqConfDirectiveParser.TryParseKeyValue("=no-key", out var kv, out var error, out var errorPosition);
|
||||
Assert.False(ok);
|
||||
Assert.NotNull(error);
|
||||
Assert.True(errorPosition.Absolute >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Parser semantics: flags (option present/absent/with value), last-wins, case-sensitive, path order.</summary>
|
||||
public class DnsmasqConfIncludeParserCoreBehaviourTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.True(result);
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "port=53\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var firstVal = "100";
|
||||
var secondVal = "200";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"cache-size={firstVal}\n");
|
||||
File.WriteAllText(f2, $"cache-size={secondVal}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal(secondVal, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var logPath = "/data/dnsmasq.log";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"log-facility={logPath}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal(logPath, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var firstLogPath = "/var/log/dnsmasq.log";
|
||||
var secondLogPath = "/data/dnsmasq.log";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"log-facility={firstLogPath}\n");
|
||||
File.WriteAllText(f2, $"log-facility={secondLogPath}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal(secondLogPath, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Relative_ResolvesAgainstDir()
|
||||
{
|
||||
var dir = Path.GetTempPath();
|
||||
var subDirName = "sub";
|
||||
var fileName = "pid.pid";
|
||||
var relativePath = $"{subDirName}/{fileName}";
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(relativePath, dir);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subDirName, fileName)), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Absolute_ReturnsAsIs()
|
||||
{
|
||||
var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid");
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir");
|
||||
Assert.Equal(Path.GetFullPath(absPath), result);
|
||||
}
|
||||
|
||||
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
|
||||
|
||||
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var invalidValue = "1";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"expand-hosts={invalidValue}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var wrongKey = "Port";
|
||||
var value = "53";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"{wrongKey}={value}\n");
|
||||
var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
|
||||
Assert.Null(parsedValue);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
var port1 = "53";
|
||||
var port2 = "5353";
|
||||
var port3 = "54";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n");
|
||||
File.WriteAllText(extra, $"port={port3}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.Equal(port3, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- pathToLines overloads: same result as file-based when content is pre-read ---
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var lines = new[] { "expand-hosts", "no-resolv" };
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, string.Join("\n", lines) + "\n");
|
||||
var paths = new[] { conf };
|
||||
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[Path.GetFullPath(conf)] = lines
|
||||
};
|
||||
var fromFile = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts);
|
||||
var fromDict = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.Equal(fromFile, fromDict);
|
||||
Assert.True(fromDict);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-last-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var value = "1000";
|
||||
var lines = new[] { $"cache-size={value}" };
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, lines[0] + "\n");
|
||||
var paths = new[] { conf };
|
||||
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[Path.GetFullPath(conf)] = lines
|
||||
};
|
||||
var (fromFileVal, fromFileDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
|
||||
var (fromDictVal, fromDictDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal(fromFileVal, fromDictVal);
|
||||
Assert.Equal(value, fromDictVal);
|
||||
Assert.Equal(fromFileDir, fromDictDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-multi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var lines = new[] { "server=1.1.1.1", "server=8.8.8.8" };
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, string.Join("\n", lines) + "\n");
|
||||
var paths = new[] { conf };
|
||||
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[Path.GetFullPath(conf)] = lines
|
||||
};
|
||||
var fromFile = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var fromDict = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Server);
|
||||
Assert.Equal(fromFile, fromDict);
|
||||
Assert.Equal(2, fromDict.Count);
|
||||
Assert.Equal("1.1.1.1", fromDict[0]);
|
||||
Assert.Equal("8.8.8.8", fromDict[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Empty path list ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EmptyPathList_ReturnsNullAndNull()
|
||||
{
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(Array.Empty<string>(), DnsmasqConfKeys.Port);
|
||||
Assert.Null(value);
|
||||
Assert.Null(configDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_EmptyPathList_ReturnsEmptyList()
|
||||
{
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(Array.Empty<string>(), DnsmasqConfKeys.Server);
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
// --- Non-existent file in path list: skipped without throw, last-wins from existing ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_NonExistentFileInList_SkipsMissingFileLastWinsFromExisting()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-missing-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var existing = Path.Combine(dir, "existing.conf");
|
||||
var missing = Path.Combine(dir, "missing.conf");
|
||||
var other = Path.Combine(dir, "other.conf");
|
||||
var valueInExisting = "100";
|
||||
var valueInOther = "200";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(existing, $"port={valueInExisting}\n");
|
||||
File.WriteAllText(other, $"port={valueInOther}\n");
|
||||
Assert.False(File.Exists(missing));
|
||||
var paths = new[] { existing, missing, other };
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.Equal(valueInOther, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>DHCP advanced, TFTP/PXE, DNSSEC, enable-dbus/ubus, fast-dns-retry.</summary>
|
||||
public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
|
||||
{
|
||||
// --- One test per option: key wired, when set returns value(s) ---
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpMatch_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpmatch-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:bios,option:client-arch,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-match={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpMatch);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpBoot_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpboot-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "tag:bios-x86,firmware/ipxe.pxe,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-boot={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpBoot);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpIgnore_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpignore-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "tag:!known";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-ignore={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpIgnore);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpVendorclass_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpvc-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:pxe,option:vendor-class-identifier,PXEClient";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-vendorclass={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpVendorclass);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpUserclass_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpuc-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:ipxe,iPXE";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-userclass={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpUserclass);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_RaParam_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-raparam-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "eth0,high,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ra-param={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RaParam);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Slaac_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-slaac-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "eth0,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"slaac={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Slaac);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_EnableTftp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftp-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "enable-tftp\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableTftp);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_TftpRoot_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftproot-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/var/lib/tftpboot";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"tftp-root={path}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TftpRoot);
|
||||
Assert.Equal(path, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_TftpSecure_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftpsec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "tftp-secure\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TftpSecure);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_PxeService_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pxe-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "x86PC,pxelinux.0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"pxe-service={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PxeService);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_PxePrompt_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pxeprompt-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "timeout,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"pxe-prompt={line}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PxePrompt);
|
||||
Assert.Equal(line, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Dnssec_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnssec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dnssec\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Dnssec);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_TrustAnchor_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-trust-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = ". IN DS 19036 8 2 49AAC11D7B6F6446702E54A1607371607A1A41855200FD2CE1CDE32F024E8FC5";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"trust-anchor={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TrustAnchor);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dnssec-check-unsigned\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
Assert.NotNull(value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DnssecCheckUnsigned_WhenEqualsNo_ReturnsNo()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-no-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dnssec-check-unsigned=no\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
Assert.Equal("no", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EnableDbus_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dbus-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var svc = "org.freedesktop.NetworkManager";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"enable-dbus={svc}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableDbus);
|
||||
Assert.Equal(svc, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EnableUbus_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ubus-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var svc = "com.example.dnsmasq";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"enable-ubus={svc}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableUbus);
|
||||
Assert.Equal(svc, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_FastDnsRetry_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-fastretry-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "500,5000";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"fast-dns-retry={line}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FastDnsRetry);
|
||||
Assert.Equal(line, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Boolean flags: no-hosts, bogus-priv, strict-order, domain-needed, no-poll, bind-*, rebind-*, clear-on-reload, filter*, localise-queries, log-debug, dhcp-authoritative, leasefile-ro.</summary>
|
||||
public class DnsmasqConfIncludeParserFlagsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_BogusPriv_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-boguspriv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "bogus-priv\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BogusPriv));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_StrictOrder_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-strict-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "strict-order\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.StrictOrder));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DomainNeeded_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-domainneed-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "domain-needed\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DomainNeeded));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoPoll_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nopoll-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-poll\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoPoll));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_BindInterfaces_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-bindif-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "bind-interfaces\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BindInterfaces));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_BindDynamic_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-binddyn-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "bind-dynamic\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BindDynamic));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoNegcache_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noneg-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-negcache\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoNegcache));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DnsLoopDetect_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-loop-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dns-loop-detect\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnsLoopDetect));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_StopDnsRebind_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebind-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "stop-dns-rebind\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.StopDnsRebind));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_RebindLocalhostOk_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebindlo-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "rebind-localhost-ok\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RebindLocalhostOk));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ClearOnReload_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-clear-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "clear-on-reload\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ClearOnReload));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Filterwin2k_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filtw2k-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "filterwin2k\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Filterwin2k));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FilterA_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filta-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "filter-A\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterA));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FilterAaaa_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filtaaaa-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "filter-AAAA\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterAaaa));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LocaliseQueries_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localise-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "localise-queries\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocaliseQueries));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LogDebug_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logdbg-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "log-debug\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogDebug));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpAuthoritative_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpauth-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-authoritative\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpAuthoritative));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Hosts (addn-hosts, hostsdir) and DHCP lease file parsing.</summary>
|
||||
public class DnsmasqConfIncludeParserHostsAndLeaseTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
|
||||
var paths = new[] { mainPath };
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.GetFullPath("/data/leases"), result);
|
||||
}
|
||||
|
||||
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
|
||||
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
|
||||
Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines");
|
||||
Assert.Contains("1.1.1.1", servers);
|
||||
Assert.Contains("8.8.8.8", servers);
|
||||
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
|
||||
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
|
||||
Assert.Single(listenAddrs);
|
||||
Assert.Equal("172.28.0.2", listenAddrs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var firstLeasePath = "/var/first.leases";
|
||||
var secondLeasePath = "/var/second.leases";
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, $"dhcp-leasefile={firstLeasePath}\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(Path.GetFullPath(secondLeasePath), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var subdir = "subdir";
|
||||
var leasesFile = "leases";
|
||||
var relativePath = $"{subdir}/{leasesFile}";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"dhcp-leasefile={relativePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty()
|
||||
{
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty<string>());
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var path1 = "/etc/hosts.d/first";
|
||||
var path2 = "/data/hosts";
|
||||
var path3 = "/data/extra.hosts";
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, $"addn-hosts={path1}\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, $"addn-hosts={path2}\naddn-hosts={path3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath(path1), result[0]);
|
||||
Assert.Equal(Path.GetFullPath(path2), result[1]);
|
||||
Assert.Equal(Path.GetFullPath(path3), result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var pathPart1 = "hosts.d";
|
||||
var pathPart2 = "app.hosts";
|
||||
var addnRelativePath = $"{pathPart1}/{pathPart2}";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"addn-hosts={addnRelativePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
|
||||
Assert.Single(result);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, pathPart1, pathPart2)), result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var managedPath = Path.Combine(dir, "managed.conf");
|
||||
var path1 = "/etc/hosts.a";
|
||||
var path2 = "/etc/hosts.b";
|
||||
var path3 = "/etc/hosts.c";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"addn-hosts={path1}\n");
|
||||
File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath(path1), result[0].Path);
|
||||
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath(path2), result[1].Path);
|
||||
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath(path3), result[2].Path);
|
||||
Assert.Equal(2, result[2].Source.LineNumber);
|
||||
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedPath = Path.Combine(dir, "zz-managed.conf");
|
||||
var readOnlyPath = "/other/hosts";
|
||||
var managedAddnPath = "/managed/hosts";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n");
|
||||
File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.True(result[1].Source.IsManaged);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Config discovery: conf-file=, conf-dir=, GetIncludedPaths, GetFirstConfDir, GetIncludedPathsWithSource, conf-dir suffix.</summary>
|
||||
public class DnsmasqConfIncludeParserIncludeTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath()
|
||||
{
|
||||
var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainOnly_ReturnsSinglePath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var includeFileName = "extra.conf";
|
||||
var includePath = Path.Combine(dir, includeFileName);
|
||||
File.WriteAllText(includePath, "# extra\n");
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-file={includeFileName}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(includePath), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDirName = "d";
|
||||
var subDir = Path.Combine(baseDir, confDirName);
|
||||
Directory.CreateDirectory(subDir);
|
||||
var file1 = "aa.conf";
|
||||
var file2 = "zz.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, file2), "");
|
||||
File.WriteAllText(Path.Combine(subDir, file1), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-dir={confDirName}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_NoConfDir_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\nconf-file=other.conf\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.Null(first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDirName = "dnsmasq.d";
|
||||
var subDir = Path.Combine(baseDir, confDirName);
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-dir={confDirName}\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(Path.GetFullPath(subDir), first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_CommentsAndBlanks_Ignored()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
File.WriteAllText(extra, "");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(extra), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDirMissing_ReturnsMainOnly()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-missing-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=nonexistent-subdir\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirrors harness layout: main + conf-dir with multiple files (including lan-dns.conf) + conf-file. Asserts all appear in load order with correct sources.</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPathsWithSource_ConfDirAndConfFile_ReturnsAllFilesInLoadOrderWithSources()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-harness-layout-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDir = Path.Combine(baseDir, "dnsmasq.d");
|
||||
Directory.CreateDirectory(confDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(confDir, "01-other.conf"), "# other\n");
|
||||
File.WriteAllText(Path.Combine(confDir, "02-servers.conf"), "# servers\n");
|
||||
File.WriteAllText(Path.Combine(confDir, "lan-dns.conf"), "# lan dns\n");
|
||||
File.WriteAllText(Path.Combine(baseDir, "zz-dnsmasq-webui.conf"), "# managed\n");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=dnsmasq.d\nconf-file=zz-dnsmasq-webui.conf\n");
|
||||
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(main);
|
||||
Assert.Equal(5, withSource.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), withSource[0].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.Main, withSource[0].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "01-other.conf")), withSource[1].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[1].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "02-servers.conf")), withSource[2].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[2].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "lan-dns.conf")), withSource[3].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[3].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "zz-dnsmasq-webui.conf")), withSource[4].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfFile, withSource[4].Source);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
var fileConf1 = "a.conf";
|
||||
var fileTxt = "b.txt";
|
||||
var fileConf2 = "c.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, fileConf1), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileTxt), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileConf2), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,*.conf\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal));
|
||||
Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
var fileIncluded = "a.conf";
|
||||
var fileExcluded = "b.conf.bak";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, fileIncluded), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileExcluded), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,.bak\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Direct test for EnumerateConfigLinesInDnsmasqOrder: main has line1, conf-file=extra, line2; extra has extra1. Asserts interleaving order.</summary>
|
||||
[Fact]
|
||||
public void EnumerateConfigLinesInDnsmasqOrder_ConfFileInMiddle_InterleavesMainThenIncludedThenMain()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-enum-order-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(main, "line1\nconf-file=extra.conf\nline2\n");
|
||||
File.WriteAllText(extra, "extra1\n");
|
||||
var mainFull = Path.GetFullPath(main);
|
||||
var extraFull = Path.GetFullPath(extra);
|
||||
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
|
||||
var seq = DnsmasqConfIncludeParser.EnumerateConfigLinesInDnsmasqOrder(mainFull, mainDir, DnsmasqConfFileSource.Main, null).ToList();
|
||||
Assert.Equal(5, seq.Count);
|
||||
Assert.Equal((mainFull, 0, "", DnsmasqConfFileSource.Main), seq[0]);
|
||||
Assert.Equal((mainFull, 1, "line1", DnsmasqConfFileSource.Main), seq[1]);
|
||||
Assert.Equal((extraFull, 0, "", DnsmasqConfFileSource.ConfFile), seq[2]);
|
||||
Assert.Equal((extraFull, 1, "extra1", DnsmasqConfFileSource.ConfFile), seq[3]);
|
||||
Assert.Equal((mainFull, 3, "line2", DnsmasqConfFileSource.Main), seq[4]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Interfaces, auth-server, DHCP/option/resolv multi-value, rebind/bogus/ignore/alias, filter-rr, cache-rr.</summary>
|
||||
public class DnsmasqConfIncludeParserInterfacesAndFilteringTests
|
||||
{
|
||||
// --- Additional tests: one per conf field so every option has a documented example test ---
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Interface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-if-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "interface=eth0\ninterface=wlan0\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Interface);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("eth0", result[0]);
|
||||
Assert.Equal("wlan0", result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ExceptInterface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-except-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "except-interface=eth1\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExceptInterface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth1", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NoDhcpInterface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcpi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-dhcp-interface=eth2\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpInterface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth2", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NoDhcpv4Interface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcp4-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-dhcpv4-interface=eth0\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpv4Interface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth0", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NoDhcpv6Interface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcp6-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-dhcpv6-interface=eth0\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpv6Interface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth0", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_AuthServer_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-auth-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "lan,eth0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"auth-server={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AuthServer);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpHost_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcphost-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "00:11:22:33:44:55,192.168.1.100,router,12h";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHost);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpOption_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpopt-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "option:router,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-option={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOption);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ResolvFile_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-resolv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/resolv.dnsmasq";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"resolv-file={path}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ResolvFile);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(path, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_RebindDomainOk_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebind-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "/local.lan/";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"rebind-domain-ok={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RebindDomainOk);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_BogusNxdomain_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-bogusnx-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "64.94.110.11";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"bogus-nxdomain={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BogusNxdomain);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_IgnoreAddress_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignoreaddr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "192.168.1.0/24";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ignore-address={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.IgnoreAddress);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Alias_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-alias-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "1.2.3.0,6.7.8.0,255.255.255.0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"alias={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Alias);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_FilterRr_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filterrr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "TXT,MX";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"filter-rr={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterRr);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_CacheRr_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cacherr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "TXT";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"cache-rr={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.CacheRr);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoHosts_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nohosts-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-hosts\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Positive parser tests for added options: key wired and "option present" behaviour.
|
||||
/// Ensures key names in DnsmasqConfKeys match what the parser expects (complement to OfficialExampleTests which assert "all commented → false/empty").
|
||||
/// Covers process flags (keep-in-foreground, no-daemon), DHCP include paths (dhcp-hostsfile, dhcp-optsfile, dhcp-hostsdir), proxy-dnssec, conntrack.
|
||||
/// </summary>
|
||||
public class DnsmasqConfIncludeParserNewOptionsTests
|
||||
{
|
||||
// --- Flags (8): key-only line → GetFlag returns true ---
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ReadEthers_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.ReadEthers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpRapidCommit_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpRapidCommit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_TftpNoFail_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.TftpNoFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_TftpNoBlocksize_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.TftpNoBlocksize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Localmx_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.Localmx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Selfmx_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.Selfmx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_EnableRa_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.EnableRa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LogDhcp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.LogDhcp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_KeepInForeground_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.KeepInForeground);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoDaemon_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoDaemon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ProxyDnssec_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.ProxyDnssec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_ConnmarkAllowlistEnable_WhenPresent_ReturnsValue()
|
||||
{
|
||||
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.ConnmarkAllowlistEnable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoRoundRobin_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoRoundRobin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DnssecNoTimecheck_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DnssecNoTimecheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DnssecDebug_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DnssecDebug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Leasequery_WhenPresent_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "leasequery\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Leasequery);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("", result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Leasequery_WithValue_IsParsed()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "leasequery=10.0.0.0/24\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Leasequery);
|
||||
Assert.Equal("10.0.0.0/24", Assert.Single(result));
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_QuietDhcp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietDhcp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_QuietTftp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietTftp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpGenerateNames_WhenPresent_ReturnsValue()
|
||||
{
|
||||
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.DhcpGenerateNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpBroadcast_WhenPresent_ReturnsValue()
|
||||
{
|
||||
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.DhcpBroadcast);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpSequentialIp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpSequentialIp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpIgnoreClid_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpIgnoreClid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_BootpDynamic_WhenPresent_ReturnsValue()
|
||||
{
|
||||
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.BootpDynamic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoPing_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoPing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ScriptArp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.ScriptArp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ScriptOnRenewal_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.ScriptOnRenewal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpNoOverride_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpNoOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_QuietDhcp6_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietDhcp6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_QuietRa_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietRa);
|
||||
}
|
||||
|
||||
// --- Multi-value (6): key=value line(s) → GetMultiValue returns value(s) ---
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpOptionForce_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "208,f1:00:74:7e";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-option-force={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOptionForce);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Ipset_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "/example.com/vpn,search";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ipset={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Ipset);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Nftset_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "/example.com/ip#test#vpn";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"nftset={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Nftset);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpMac_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:red,00:60:8C:*:*:*";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-mac={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpMac);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpNameMatch_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:wpad-ignore,wpad";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-name-match={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpNameMatch);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpIgnoreNames_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "tag:wpad-ignore";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-ignore-names={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpIgnoreNames);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpHostsfile_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/dnsmasq.d/hosts.d";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-hostsfile={path}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHostsfile);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(path, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpOptsfile_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/dnsmasq.d/opts.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-optsfile={path}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOptsfile);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(path, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpHostsdir_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/dnsmasq.d/hosts.d";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-hostsdir={path}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHostsdir);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(path, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
// --- Single-value (2): key=value → GetLastValue returns value ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MxTarget_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var value = "mail.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"mx-target={value}\n");
|
||||
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MxTarget);
|
||||
Assert.Equal(value, result);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpScript_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var value = "/usr/bin/dnsmasq-script";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-script={value}\n");
|
||||
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpScript);
|
||||
Assert.Equal(value, result);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Conntrack_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "conntrack\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack));
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Conntrack_WhenAbsent_ReturnsFalse()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "port=53\n");
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack));
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
private static void WriteFlagAndAssertTrue(string key)
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, key + "\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, key));
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
private static void WriteLastWinsKeyOnlyAndAssertPresent(string key)
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, key + "\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, key);
|
||||
Assert.NotNull(value);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
private static string CreateTempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-newopts-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the parser correctly handles the official dnsmasq example (testdata/dnsmasq.official.conf.example).
|
||||
/// All options in that file are commented out, so parsing it should yield no active values (single path, empty/false/null).
|
||||
/// </summary>
|
||||
public class DnsmasqConfIncludeParserOfficialExampleTests
|
||||
{
|
||||
private static string GetOfficialExamplePath()
|
||||
{
|
||||
var path = TestDataHelper.GetPath("dnsmasq.official.conf.example");
|
||||
Assert.True(File.Exists(path), "Testdata dnsmasq.official.conf.example required (download from https://github.com/imp/dnsmasq)");
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OfficialExample_ReturnsSinglePath()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(mainPath), paths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_OfficialExample_AllCommented_ReturnsNull()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
var (cacheVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
|
||||
var (mxTargetVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.MxTarget);
|
||||
var (dhcpScriptVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpScript);
|
||||
Assert.Null(portVal);
|
||||
Assert.Null(cacheVal);
|
||||
Assert.Null(mxTargetVal);
|
||||
Assert.Null(dhcpScriptVal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
|
||||
var dhcpOptionForce = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpOptionForce);
|
||||
var ipset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Ipset);
|
||||
var nftset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Nftset);
|
||||
var dhcpMac = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpMac);
|
||||
var dhcpNameMatch = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpNameMatch);
|
||||
var dhcpIgnoreNames = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpIgnoreNames);
|
||||
Assert.Empty(servers);
|
||||
Assert.Empty(locals);
|
||||
Assert.Empty(dhcpOptionForce);
|
||||
Assert.Empty(ipset);
|
||||
Assert.Empty(nftset);
|
||||
Assert.Empty(dhcpMac);
|
||||
Assert.Empty(dhcpNameMatch);
|
||||
Assert.Empty(dhcpIgnoreNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OfficialExample_AllCommented_ReturnsFalse()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ReadEthers));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.DhcpRapidCommit));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.TftpNoFail));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.TftpNoBlocksize));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.Localmx));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.Selfmx));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.EnableRa));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.LogDhcp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 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]
|
||||
public void GetAddnHostsPathsFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
Assert.NotNull(addnHosts);
|
||||
Assert.Empty(addnHosts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNoHostsFromConfigFiles_OfficialExample_AllCommented_ReturnsFalse()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
|
||||
Assert.False(noHosts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>DNS records and local names: hostsdir, domain, cname, mx-host, srv, ptr/txt/naptr/host-record, dynamic-host, interface-name.</summary>
|
||||
public class DnsmasqConfIncludeParserRecordsTests
|
||||
{
|
||||
// --- One test per option: key wired, when set returns value(s) ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_Hostsdir_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hostsdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/dnsmasq.d/hosts";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"hostsdir={path}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Hostsdir);
|
||||
Assert.Equal(path, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Domain_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-domain-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "home,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"domain={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Domain);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Cname_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cname-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "mail.example.com,real.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"cname={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Cname);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_MxHost_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-mx-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "example.com,mail.example.com,50";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"mx-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MxHost);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Srv_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-srv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "_http._tcp.example.com,server.example.com,80";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"srv-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Srv);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_PtrRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "1.168.192.in-addr.arpa,host.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ptr-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PtrRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_TxtRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-txt-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "example.com,v=spf1 include:_spf.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"txt-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TxtRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NaptrRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-naptr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "example.com,100,10,u,sip+E2U,sips:.*@example.com,.";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"naptr-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NaptrRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_HostRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hostrecord-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "router.example.com,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"host-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.HostRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DynamicHost_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dynamichost-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "hostname,example.com,192.168.1.50";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dynamic-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DynamicHost);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_InterfaceName_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ifname-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "eth0,router.local";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"interface-name={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.InterfaceName);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Server/local, rev-server, dhcp-range, all-servers, log-queries.</summary>
|
||||
public class DnsmasqConfIncludeParserServerAndResolvTests
|
||||
{
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var v1 = "1.1.1.1";
|
||||
var v2 = "/local/";
|
||||
var v3 = "/example.com/192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
|
||||
File.WriteAllText(f2, $"server={v3}\n");
|
||||
var resultServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Server);
|
||||
var resultLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Local);
|
||||
Assert.Equal(2, resultServer.Count);
|
||||
Assert.Equal(v1, resultServer[0]);
|
||||
Assert.Equal(v3, resultServer[1]);
|
||||
Assert.Single(resultLocal);
|
||||
Assert.Equal(v2, resultLocal[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var range1 = "172.28.0.10,172.28.0.50,12h";
|
||||
var range2 = "192.168.1.10,192.168.1.100,255.255.255.0,24h";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-range={range1}\ndhcp-range={range2}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(range1, result[0]);
|
||||
Assert.Equal(range2, result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_AllServers_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-allsrv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "all-servers\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AllServers);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogQueries_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logq-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "log-queries=extra\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogQueries);
|
||||
Assert.Equal("extra", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_RevServer_ReturnsAllValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rev-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var v1 = "1.2.3.0/24,192.168.0.1";
|
||||
var v2 = "10.0.0.0/8,10.0.0.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"rev-server={v1}\nrev-server={v2}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RevServer);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(v1, result[0]);
|
||||
Assert.Equal(v2, result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Single-value options (last wins): local-ttl, pid-file, user, group, auth-ttl, edns-packet-max, query-port, port-limit, min/max-port, log-async, local-service, dhcp-lease-max, neg/max/min-cache-ttl, dhcp-ttl.</summary>
|
||||
public class DnsmasqConfIncludeParserSingleValueTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LeasefileRo_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-leasero-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "leasefile-ro\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LeasefileRo));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LocalTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "local-ttl=300\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocalTtl);
|
||||
Assert.Equal("300", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_PidFile_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pid-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/var/run/dnsmasq.pid";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"pid-file={path}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PidFile);
|
||||
Assert.Equal(path, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_User_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-user-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "user=nobody\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.User);
|
||||
Assert.Equal("nobody", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_Group_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-group-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "group=dip\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Group);
|
||||
Assert.Equal("dip", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_AuthTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-authttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "auth-ttl=60\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AuthTtl);
|
||||
Assert.Equal("60", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EdnsPacketMax_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-edns-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "edns-packet-max=1232\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EdnsPacketMax);
|
||||
Assert.Equal("1232", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_QueryPort_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-qport-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "query-port=0\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.QueryPort);
|
||||
Assert.Equal("0", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_PortLimit_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-portlimit-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "port-limit=3\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PortLimit);
|
||||
Assert.Equal("3", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MinPort_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-minport-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "min-port=1024\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MinPort);
|
||||
Assert.Equal("1024", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MaxPort_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxport-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "max-port=65535\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxPort);
|
||||
Assert.Equal("65535", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogAsync_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logasync-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "log-async=25\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogAsync);
|
||||
Assert.Equal("25", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LocalService_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localsvc-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "local-service=host\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocalService);
|
||||
Assert.Equal("host", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpLeaseMax_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-leasemax-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-lease-max=150\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpLeaseMax);
|
||||
Assert.Equal("150", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_NegTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-negttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "neg-ttl=60\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NegTtl);
|
||||
Assert.Equal("60", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MaxTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "max-ttl=3600\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxTtl);
|
||||
Assert.Equal("3600", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MaxCacheTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxcttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "max-cache-ttl=86400\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxCacheTtl);
|
||||
Assert.Equal("86400", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MinCacheTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-mincttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "min-cache-ttl=60\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MinCacheTtl);
|
||||
Assert.Equal("60", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-ttl=0\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpTtl);
|
||||
Assert.Equal("0", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,690 +0,0 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfIncludeParser. Discovers conf-file= and conf-dir= from main dnsmasq config
|
||||
/// to build ordered list of included file paths and first conf-dir for managed file.
|
||||
/// </summary>
|
||||
public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath()
|
||||
{
|
||||
var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainOnly_ReturnsSinglePath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var includeFileName = "extra.conf";
|
||||
var includePath = Path.Combine(dir, includeFileName);
|
||||
File.WriteAllText(includePath, "# extra\n");
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-file={includeFileName}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(includePath), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDirName = "d";
|
||||
var subDir = Path.Combine(baseDir, confDirName);
|
||||
Directory.CreateDirectory(subDir);
|
||||
var file1 = "aa.conf";
|
||||
var file2 = "zz.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, file2), "");
|
||||
File.WriteAllText(Path.Combine(subDir, file1), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-dir={confDirName}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_NoConfDir_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\nconf-file=other.conf\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.Null(first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDirName = "dnsmasq.d";
|
||||
var subDir = Path.Combine(baseDir, confDirName);
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-dir={confDirName}\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(Path.GetFullPath(subDir), first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_CommentsAndBlanks_Ignored()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
File.WriteAllText(extra, "");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(extra), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDirMissing_ReturnsMainOnly()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-missing-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=nonexistent-subdir\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirrors harness layout: main + conf-dir with multiple files (including lan-dns.conf) + conf-file. Asserts all appear in load order with correct sources.</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPathsWithSource_ConfDirAndConfFile_ReturnsAllFilesInLoadOrderWithSources()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-harness-layout-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDir = Path.Combine(baseDir, "dnsmasq.d");
|
||||
Directory.CreateDirectory(confDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(confDir, "01-other.conf"), "# other\n");
|
||||
File.WriteAllText(Path.Combine(confDir, "02-servers.conf"), "# servers\n");
|
||||
File.WriteAllText(Path.Combine(confDir, "lan-dns.conf"), "# lan dns\n");
|
||||
File.WriteAllText(Path.Combine(baseDir, "zz-dnsmasq-webui.conf"), "# managed\n");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=dnsmasq.d\nconf-file=zz-dnsmasq-webui.conf\n");
|
||||
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(main);
|
||||
Assert.Equal(5, withSource.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), withSource[0].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.Main, withSource[0].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "01-other.conf")), withSource[1].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[1].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "02-servers.conf")), withSource[2].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[2].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "lan-dns.conf")), withSource[3].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[3].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "zz-dnsmasq-webui.conf")), withSource[4].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfFile, withSource[4].Source);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
if (!File.Exists(mainPath))
|
||||
return;
|
||||
var paths = new[] { mainPath };
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.GetFullPath("/data/leases"), result);
|
||||
}
|
||||
|
||||
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
if (!File.Exists(mainPath))
|
||||
return;
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
|
||||
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
|
||||
Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines");
|
||||
Assert.Contains("1.1.1.1", servers);
|
||||
Assert.Contains("8.8.8.8", servers);
|
||||
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
|
||||
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
|
||||
Assert.Single(listenAddrs);
|
||||
Assert.Equal("172.28.0.2", listenAddrs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var firstLeasePath = "/var/first.leases";
|
||||
var secondLeasePath = "/var/second.leases";
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, $"dhcp-leasefile={firstLeasePath}\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(Path.GetFullPath(secondLeasePath), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var subdir = "subdir";
|
||||
var leasesFile = "leases";
|
||||
var relativePath = $"{subdir}/{leasesFile}";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"dhcp-leasefile={relativePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty()
|
||||
{
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty<string>());
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var path1 = "/etc/hosts.d/first";
|
||||
var path2 = "/data/hosts";
|
||||
var path3 = "/data/extra.hosts";
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, $"addn-hosts={path1}\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, $"addn-hosts={path2}\naddn-hosts={path3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath(path1), result[0]);
|
||||
Assert.Equal(Path.GetFullPath(path2), result[1]);
|
||||
Assert.Equal(Path.GetFullPath(path3), result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var pathPart1 = "hosts.d";
|
||||
var pathPart2 = "app.hosts";
|
||||
var addnRelativePath = $"{pathPart1}/{pathPart2}";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"addn-hosts={addnRelativePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
|
||||
Assert.Single(result);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, pathPart1, pathPart2)), result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.True(result);
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "port=53\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var firstVal = "100";
|
||||
var secondVal = "200";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"cache-size={firstVal}\n");
|
||||
File.WriteAllText(f2, $"cache-size={secondVal}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal(secondVal, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var logPath = "/data/dnsmasq.log";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"log-facility={logPath}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal(logPath, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var firstLogPath = "/var/log/dnsmasq.log";
|
||||
var secondLogPath = "/data/dnsmasq.log";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"log-facility={firstLogPath}\n");
|
||||
File.WriteAllText(f2, $"log-facility={secondLogPath}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal(secondLogPath, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Relative_ResolvesAgainstDir()
|
||||
{
|
||||
var dir = Path.GetTempPath();
|
||||
var subDirName = "sub";
|
||||
var fileName = "pid.pid";
|
||||
var relativePath = $"{subDirName}/{fileName}";
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(relativePath, dir);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subDirName, fileName)), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Absolute_ReturnsAsIs()
|
||||
{
|
||||
var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid");
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir");
|
||||
Assert.Equal(Path.GetFullPath(absPath), result);
|
||||
}
|
||||
|
||||
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
|
||||
|
||||
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var invalidValue = "1";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"expand-hosts={invalidValue}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var wrongKey = "Port";
|
||||
var value = "53";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"{wrongKey}={value}\n");
|
||||
var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
|
||||
Assert.Null(parsedValue);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
var port1 = "53";
|
||||
var port2 = "5353";
|
||||
var port3 = "54";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n");
|
||||
File.WriteAllText(extra, $"port={port3}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.Equal(port3, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
var fileConf1 = "a.conf";
|
||||
var fileTxt = "b.txt";
|
||||
var fileConf2 = "c.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, fileConf1), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileTxt), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileConf2), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,*.conf\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal));
|
||||
Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
var fileIncluded = "a.conf";
|
||||
var fileExcluded = "b.conf.bak";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, fileIncluded), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileExcluded), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,.bak\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var managedPath = Path.Combine(dir, "managed.conf");
|
||||
var path1 = "/etc/hosts.a";
|
||||
var path2 = "/etc/hosts.b";
|
||||
var path3 = "/etc/hosts.c";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"addn-hosts={path1}\n");
|
||||
File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath(path1), result[0].Path);
|
||||
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath(path2), result[1].Path);
|
||||
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath(path3), result[2].Path);
|
||||
Assert.Equal(2, result[2].Source.LineNumber);
|
||||
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedPath = Path.Combine(dir, "zz-managed.conf");
|
||||
var readOnlyPath = "/other/hosts";
|
||||
var managedAddnPath = "/managed/hosts";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n");
|
||||
File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.True(result[1].Source.IsManaged);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var v1 = "1.1.1.1";
|
||||
var v2 = "/local/";
|
||||
var v3 = "/example.com/192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
|
||||
File.WriteAllText(f2, $"server={v3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(v1, result[0]);
|
||||
Assert.Equal(v2, result[1]);
|
||||
Assert.Equal(v3, result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var range1 = "172.28.0.10,172.28.0.50,12h";
|
||||
var range2 = "192.168.1.10,192.168.1.100,255.255.255.0,24h";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-range={range1}\ndhcp-range={range2}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(range1, result[0]);
|
||||
Assert.Equal(range2, result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
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;
|
||||
|
||||
/// <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_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_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -25,7 +25,8 @@ public class DnsmasqConfigSetServiceTests
|
||||
Assert.Empty(config.AddnHostsPaths);
|
||||
Assert.NotNull(sources);
|
||||
Assert.Empty(sources.AddnHostsPaths);
|
||||
Assert.Empty(sources.ServerLocalValues);
|
||||
Assert.Empty(sources.ServerValues);
|
||||
Assert.Empty(sources.LocalValues);
|
||||
Assert.Empty(sources.DhcpRanges);
|
||||
Assert.Null(sources.NoHosts);
|
||||
Assert.Null(sources.Port);
|
||||
@@ -59,7 +60,8 @@ public class DnsmasqConfigSetServiceTests
|
||||
Assert.Equal(cacheSize, config.CacheSize);
|
||||
Assert.Single(config.AddnHostsPaths);
|
||||
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.NotNull(sources);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Documents and guards the Do0x20 display label rule: Default and Disabled both show "Disabled" (product choice).
|
||||
/// Do not "fix" the display to show "Default" for unset without updating this test and the component comment.
|
||||
/// </summary>
|
||||
public class Do0x20EncodeDisplayLabelTests
|
||||
{
|
||||
/// <summary>Same logic as Do0x20EncodeDisplay.razor view mode: Enabled → "Enabled", else "Disabled".</summary>
|
||||
private static string GetDisplayLabel(ExplicitToggleState state) =>
|
||||
state == ExplicitToggleState.Enabled ? "Enabled" : "Disabled";
|
||||
|
||||
[Fact]
|
||||
public void Do0x20_Default_ShowsDisabled()
|
||||
{
|
||||
Assert.Equal("Disabled", GetDisplayLabel(ExplicitToggleState.Default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Do0x20_Disabled_ShowsDisabled()
|
||||
{
|
||||
Assert.Equal("Disabled", GetDisplayLabel(ExplicitToggleState.Disabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Do0x20_Enabled_ShowsEnabled()
|
||||
{
|
||||
Assert.Equal("Enabled", GetDisplayLabel(ExplicitToggleState.Enabled));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures effective-config editable options list includes supported cache flags.
|
||||
/// </summary>
|
||||
public class EffectiveConfigEditableOptionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// strip-mac and strip-subnet are supported in the effective-config UI and should be listed in cache options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EditableOptions_Include_StripMac_And_StripSubnet()
|
||||
{
|
||||
var allOptionNames = EffectiveConfigSections.GetSectionsInOrder()
|
||||
.SelectMany(t => EffectiveConfigSections.GetOptionsInSection(t.SectionId))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
Assert.Contains(DnsmasqConfKeys.StripMac, allOptionNames);
|
||||
Assert.Contains(DnsmasqConfKeys.StripSubnet, allOptionNames);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <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);
|
||||
Assert.Contains("DNSSEC", reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[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));
|
||||
var reason = EffectiveConfigFeatureRequirements.GetUnsupportedReason(DnsmasqFeature.Dnssec);
|
||||
Assert.Contains("DNSSEC", reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <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);
|
||||
Assert.Contains("DNSSEC", descriptor.CapabilityDisabledReason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wiring tests that ensure special-option semantics actually feed parser and registry behavior.
|
||||
/// </summary>
|
||||
public class EffectiveConfigSemanticsWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParserBehaviorMap_UsesSpecialSemantics_ForAllSpecialOptions()
|
||||
{
|
||||
foreach (var option in EffectiveConfigSpecialOptionSemantics.GetAllOptionNames())
|
||||
{
|
||||
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(option)!;
|
||||
Assert.Equal(semantics.ParserBehavior, EffectiveConfigParserBehaviorMap.GetBehavior(option));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics()
|
||||
{
|
||||
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new LeasequerySemanticHandler()]));
|
||||
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery);
|
||||
Assert.NotNull(factory);
|
||||
|
||||
var descriptor = factory!(
|
||||
EffectiveConfigFieldBuilder.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);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,10 @@ public class EffectiveDnsmasqConfigTests
|
||||
new(
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: Array.Empty<string>(),
|
||||
ServerLocalValues: 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>(),
|
||||
@@ -19,17 +22,110 @@ public class EffectiveDnsmasqConfigTests
|
||||
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,
|
||||
@@ -38,12 +134,47 @@ public class EffectiveDnsmasqConfigTests
|
||||
User: null,
|
||||
Group: null,
|
||||
LogFacility: logFacility,
|
||||
LogQueries: null,
|
||||
AuthTtl: null,
|
||||
EdnsPacketMax: null,
|
||||
QueryPort: null,
|
||||
PortLimit: null,
|
||||
MinPort: null,
|
||||
MaxPort: null,
|
||||
LogAsync: null,
|
||||
LocalService: null,
|
||||
DhcpLeaseMax: null,
|
||||
NegTtl: null,
|
||||
MaxTtl: null,
|
||||
MaxCacheTtl: null,
|
||||
MinCacheTtl: null,
|
||||
DhcpTtl: null
|
||||
DhcpTtl: null,
|
||||
TftpRootPath: null,
|
||||
PxePrompt: null,
|
||||
EnableDbus: null,
|
||||
EnableUbus: null,
|
||||
FastDnsRetry: null,
|
||||
DhcpScriptPath: null,
|
||||
MxTarget: null,
|
||||
DnsForwardMax: null,
|
||||
DumpfilePath: null,
|
||||
Dumpmask: null,
|
||||
AddCpeId: null,
|
||||
DnssecTimestamp: null,
|
||||
DnssecLimits: null,
|
||||
DhcpAlternatePort: null,
|
||||
DhcpDuid: null,
|
||||
DhcpLuascriptPath: null,
|
||||
DhcpScriptuser: null,
|
||||
DhcpPxeVendor: null,
|
||||
UseStaleCache: null,
|
||||
AddMac: null,
|
||||
StripMac: false,
|
||||
AddSubnet: null,
|
||||
StripSubnet: false,
|
||||
Umbrella: null,
|
||||
Do0x20EncodeState: ExplicitToggleState.Default,
|
||||
Conntrack: false
|
||||
);
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,938 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the central semantic validator (handler dispatch and generic kind validation).
|
||||
/// </summary>
|
||||
public class OptionSemanticValidatorTests
|
||||
{
|
||||
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 SrvHostSemanticHandler(),
|
||||
new NaptrRecordSemanticHandler(),
|
||||
new DnsRrSemanticHandler(),
|
||||
new DynamicHostSemanticHandler(),
|
||||
new AuthSoaSemanticHandler(),
|
||||
new AuthSecServersSemanticHandler(),
|
||||
new AuthPeerSemanticHandler(),
|
||||
new HostRecordSemanticHandler(),
|
||||
new TxtRecordSemanticHandler(),
|
||||
new DomainSemanticHandler(),
|
||||
new SynthDomainSemanticHandler(),
|
||||
new AuthZoneSemanticHandler(),
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_LeasequeryHandler_InvalidIp_ReturnsError()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Leasequery, "not-an-ip", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Leasequery, "10.0.0.0/24", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Leasequery, "", semantics));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.2.3.4", true)]
|
||||
[InlineData("::1", true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData("x.y.z", false)]
|
||||
public void ValidateMultiItem_IpAddressKind_AcceptsValid_RejectsInvalid(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.IpAddress, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem("option", value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", true, true)]
|
||||
[InlineData("x", true, true)]
|
||||
[InlineData("", false, false)]
|
||||
public void ValidateMultiItem_StringKind_RespectsAllowEmpty(string value, bool allowEmpty, bool expectValid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.String, allowEmpty: allowEmpty);
|
||||
var err = _validator.ValidateMultiItem("option", value, semantics);
|
||||
Assert.Equal(expectValid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("8.8.8.8", true)]
|
||||
[InlineData("dns.example.com", true)]
|
||||
[InlineData("/internal.lan/192.168.2.1", true)]
|
||||
[InlineData("/google.com/#", true)]
|
||||
[InlineData("//", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("http://bad", false)]
|
||||
[InlineData("/internal$lan/192.168.2.1", false)]
|
||||
[InlineData("/google.com/192.168.2.1#70000", false)]
|
||||
[InlineData("/google.com/@eth0", false)]
|
||||
public void ValidateMultiItem_Server_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Server, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("192.168.1.1", true)]
|
||||
[InlineData("::1", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("dns.example.com", false)]
|
||||
public void ValidateMultiItem_ListenAddress_UsesIpAddressSemantics(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.IpAddress, allowEmpty: false);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.ListenAddress, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.2.3.0/24,192.168.1.1", true)]
|
||||
[InlineData("2001:db8::/64,2001:4860:4860::8888", true)]
|
||||
[InlineData("1.2.3.0/33,192.168.1.1", false)]
|
||||
[InlineData("not-an-ip/24,192.168.1.1", false)]
|
||||
[InlineData("1.2.3.0/x,192.168.1.1", false)]
|
||||
[InlineData("1.2.3.0/24,", false)]
|
||||
public void ValidateMultiItem_RevServer_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.RevServer, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/example.local/192.168.1.10", true)]
|
||||
[InlineData("/#/1.2.3.4", true)]
|
||||
[InlineData("/example.local/#", true)]
|
||||
[InlineData("/example.local/", true)]
|
||||
[InlineData("example.local/192.168.1.10", false)]
|
||||
[InlineData("//192.168.1.10", true)]
|
||||
[InlineData("/example.local/not-an-ip", false)]
|
||||
public void ValidateMultiItem_Address_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Address, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/example.local/", true)]
|
||||
[InlineData("//", true)]
|
||||
[InlineData("/*.example.local/", true)]
|
||||
[InlineData("/internal$lan/", false)]
|
||||
[InlineData("/example.local/192.168.1.1", false)]
|
||||
public void ValidateMultiItem_Local_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Local, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".,20326,8,2,abcdef", true)]
|
||||
[InlineData("example.com", true)]
|
||||
[InlineData("example.com,IN", true)]
|
||||
[InlineData("example.com,IN,20326,8,2,abcdef", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("example.com,BOGUS", false)]
|
||||
[InlineData("example.com,IN,tag,8,2,abcdef", false)]
|
||||
public void ValidateMultiItem_TrustAnchor_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.TrustAnchor, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.2.3.0,6.7.8.0,255.255.255.0", true)]
|
||||
[InlineData("192.168.0.10-192.168.0.40,10.0.0.0,255.255.255.0", true)]
|
||||
[InlineData("192.168.0.10-,10.0.0.1", false)]
|
||||
[InlineData("not-an-ip,10.0.0.1", false)]
|
||||
public void ValidateMultiItem_Alias_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Alias, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/example.local/ipset1", true)]
|
||||
[InlineData("/example.local/example.org/ipset1,ipset2", true)]
|
||||
[InlineData("/internal$lan/ipset1", false)]
|
||||
[InlineData("/example.local/", false)]
|
||||
public void ValidateMultiItem_Ipset_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Ipset, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/example.local/inet#filter#set1", true)]
|
||||
[InlineData("/example.local/4#inet#filter#set1", true)]
|
||||
[InlineData("/example.local/6#inet#filter#set1", true)]
|
||||
[InlineData("/example.local/not#enough", false)]
|
||||
[InlineData("/internal$lan/inet#filter#set1", false)]
|
||||
public void ValidateMultiItem_Nftset_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Nftset, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("64.94.110.11", true)]
|
||||
[InlineData("10.0.0.0/24", true)]
|
||||
[InlineData("2001:db8::/64", true)]
|
||||
[InlineData("not-an-ip", false)]
|
||||
public void ValidateMultiItem_IgnoreAddress_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.IgnoreAddress, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("example.com", true)]
|
||||
[InlineData("/domain1/domain2/", true)]
|
||||
[InlineData("/domain1//", false)]
|
||||
[InlineData("internal$lan", false)]
|
||||
public void ValidateMultiItem_RebindDomainOk_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.RebindDomainOk, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("64.94.110.11", true)]
|
||||
[InlineData("64.94.110.11/24", true)]
|
||||
[InlineData("not-an-ip", false)]
|
||||
public void ValidateMultiItem_BogusNxdomain_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.BogusNxdomain, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0xff,example.com", true)]
|
||||
[InlineData("0xff/0xff,*", true)]
|
||||
[InlineData("0xff,*.example.com/api.example.com", true)]
|
||||
[InlineData("not-a-mark,example.com", false)]
|
||||
[InlineData("0xff,local", false)]
|
||||
public void ValidateMultiItem_ConnmarkAllowlist_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.ConnmarkAllowlist, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("192.168.1.50,192.168.1.150", true)]
|
||||
[InlineData("tag:guest,set:known,192.168.1.50,192.168.1.150,12h", true)]
|
||||
[InlineData("constructor:eth0,::,static", true)]
|
||||
[InlineData("172.28.0.10,", false)]
|
||||
[InlineData("172.28.0.10", false)]
|
||||
[InlineData("tag:guest,", false)]
|
||||
[InlineData("not-an-ip,192.168.1.150", false)]
|
||||
public void ValidateMultiItem_DhcpRange_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRange, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("00:20:e0:3b:13:af,wap,infinite", true)]
|
||||
[InlineData("lap,192.168.0.199", true)]
|
||||
[InlineData("id:clientid,set:known,192.168.1.10,host1,12h", true)]
|
||||
[InlineData("ignore", false)]
|
||||
[InlineData("00:20:e0:3b:13:af,,host1", false)]
|
||||
public void ValidateMultiItem_DhcpHost_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHost, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("3,192.168.4.4", true)]
|
||||
[InlineData("option:router,192.168.4.4", true)]
|
||||
[InlineData("vendor:PXEClient,1,0.0.0.0", true)]
|
||||
[InlineData("encap:175,190,iscsi-client0", true)]
|
||||
[InlineData("option:", false)]
|
||||
[InlineData("vendor:,", false)]
|
||||
public void ValidateMultiItem_DhcpOption_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpOption, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("set:efi-ia32,option:client-arch,6", true)]
|
||||
[InlineData("set:known,93", true)]
|
||||
[InlineData("option:client-arch,6", false)]
|
||||
[InlineData("set:,option:client-arch,6", false)]
|
||||
public void ValidateMultiItem_DhcpMatch_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpMatch, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("set:3com,01:34:23:*:*:*", true)]
|
||||
[InlineData("set:vendor,aa:bb:cc:dd:ee:ff", true)]
|
||||
[InlineData("01:34:23:*:*:*", false)]
|
||||
[InlineData("set:,01:34:23:*:*:*", false)]
|
||||
public void ValidateMultiItem_DhcpMac_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpMac, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("set:tag,hostname*", true)]
|
||||
[InlineData("set:tag,hostname", true)]
|
||||
[InlineData("set:tag,*host*", false)]
|
||||
[InlineData("hostname*", false)]
|
||||
public void ValidateMultiItem_DhcpNameMatch_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpNameMatch, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", true)]
|
||||
[InlineData("tag:guest", true)]
|
||||
[InlineData("tag:guest,tag:lab", true)]
|
||||
[InlineData("guest", false)]
|
||||
public void ValidateMultiItem_DhcpIgnoreNames_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpIgnoreNames, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tag:blocked", true)]
|
||||
[InlineData("tag:blocked,tag:!known", true)]
|
||||
[InlineData("blocked", false)]
|
||||
public void ValidateMultiItem_DhcpIgnore_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpIgnore, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("set:printers,Hewlett-Packard JetDirect", true)]
|
||||
[InlineData("printers,enterprise:32473,VendorClass", true)]
|
||||
[InlineData("set:printers,enterprise:notnum,VendorClass", false)]
|
||||
public void ValidateMultiItem_DhcpVendorclass_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpVendorclass, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("set:userclass,ExampleClient", true)]
|
||||
[InlineData("userclass,ExampleClient", true)]
|
||||
[InlineData("set:,ExampleClient", false)]
|
||||
public void ValidateMultiItem_DhcpUserclass_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpUserclass, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("set:ppp,tag:ppp*", true)]
|
||||
[InlineData("set:guest,tag:!known", true)]
|
||||
[InlineData("tag:known", false)]
|
||||
public void ValidateMultiItem_TagIf_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.TagIf, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("192.168.1.1,192.168.2.1", true)]
|
||||
[InlineData("192.168.1.1,192.168.2.1#1067,eth1", true)]
|
||||
[InlineData("192.168.1.1,eth1", true)]
|
||||
[InlineData("not-an-ip,192.168.2.1", false)]
|
||||
[InlineData("192.168.1.1,server.example.com", false)]
|
||||
public void ValidateMultiItem_DhcpRelay_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRelay, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("192.168.1.1", true)]
|
||||
[InlineData("192.168.1.1,192.168.1.2", true)]
|
||||
[InlineData("not-an-ip", false)]
|
||||
public void ValidateMultiItem_DhcpProxy_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpProxy, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("br0,eth0", true)]
|
||||
[InlineData("br0,tap*", true)]
|
||||
[InlineData("br0", false)]
|
||||
public void ValidateMultiItem_BridgeInterface_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.BridgeInterface, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pxelinux.0", true)]
|
||||
[InlineData("tag:pxe,pxelinux.0,,192.168.1.2", true)]
|
||||
[InlineData("tag:,pxelinux.0", false)]
|
||||
[InlineData("", false)]
|
||||
public void ValidateMultiItem_DhcpBoot_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpBoot, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sharednet,192.168.10.0,255.255.255.0", true)]
|
||||
[InlineData("eth0,192.168.10.1", true)]
|
||||
[InlineData("sharednet", false)]
|
||||
public void ValidateMultiItem_SharedNetwork_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.SharedNetwork, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("eth0,::10", true)]
|
||||
[InlineData("slaac", true)]
|
||||
[InlineData("ra-names,eth0", true)]
|
||||
[InlineData("bad value", false)]
|
||||
public void ValidateMultiItem_Slaac_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Slaac, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("eth0,60", true)]
|
||||
[InlineData("eth0,mtu:1280,low,60,1200", true)]
|
||||
[InlineData("eth0,high", true)]
|
||||
[InlineData("eth*,60", true)]
|
||||
[InlineData("eth*0,60", false)]
|
||||
[InlineData("*eth0,60", false)]
|
||||
[InlineData(",60", false)]
|
||||
[InlineData("eth0,mtu:", false)]
|
||||
public void ValidateMultiItem_RaParam_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.RaParam, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("x86PC,\"PXE Boot\",pxelinux", true)]
|
||||
[InlineData("tag:pxe,x86PC,\"PXE Boot\",pxelinux,192.168.1.2", true)]
|
||||
[InlineData("x86PC", false)]
|
||||
[InlineData("bad-csa,\"PXE Boot\",pxelinux", false)]
|
||||
public void ValidateMultiItem_PxeService_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.PxeService, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("vendor:PXEClient,1,0.0.0.0", true)]
|
||||
[InlineData("encap:175,190,iscsi-client0", true)]
|
||||
[InlineData("option:router,192.168.1.1", false)]
|
||||
[InlineData("vendor:,1,0.0.0.0", false)]
|
||||
public void ValidateMultiItem_DhcpOptionPxe_UsesHandler(string value, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
|
||||
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpOptionPxe, value, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSingle_UseStaleCache_UsesEngineRule()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.UseStaleCache, "", semantics));
|
||||
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.UseStaleCache, "0", semantics));
|
||||
Assert.NotNull(_validator.ValidateSingle(DnsmasqConfKeys.UseStaleCache, "x", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSingle_AddMac_UsesEngineRule()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.AddMac, "", semantics));
|
||||
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.AddMac, "base64", semantics));
|
||||
Assert.NotNull(_validator.ValidateSingle(DnsmasqConfKeys.AddMac, "bogus", semantics));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("24,96")]
|
||||
[InlineData("0/0")]
|
||||
public void ValidateSingle_AddSubnet_RemainsIntentionallyPermissive(string input)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.AddSubnet, input, semantics));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("org-id,asset-id")]
|
||||
[InlineData("device-123")]
|
||||
public void ValidateSingle_Umbrella_RemainsIntentionallyPermissive(string input)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.Umbrella, input, semantics));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", true)]
|
||||
[InlineData("0xff", true)]
|
||||
[InlineData("255", true)]
|
||||
[InlineData("0xZZ", false)]
|
||||
[InlineData("-1", false)]
|
||||
public void ValidateSingle_ConnmarkAllowlistEnable_UsesEngineRule(string input, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||
var err = _validator.ValidateSingle(DnsmasqConfKeys.ConnmarkAllowlistEnable, input, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", true)]
|
||||
[InlineData("no", true)]
|
||||
[InlineData("yes", false)]
|
||||
public void ValidateSingle_DnssecCheckUnsigned_UsesEngineRule(string input, bool valid)
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||
var err = _validator.ValidateSingle(DnsmasqConfKeys.DnssecCheckUnsigned, input, semantics);
|
||||
Assert.Equal(valid, err is null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSingle_IntKind_AcceptsIntOrNumericString()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Int);
|
||||
Assert.Null(_validator.ValidateSingle("option", 42, semantics));
|
||||
Assert.Null(_validator.ValidateSingle("option", "99", semantics));
|
||||
Assert.NotNull(_validator.ValidateSingle("option", "abc", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionValidationSemantics_PathPolicy_OnNonPathKind_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new OptionValidationSemantics(
|
||||
OptionValidationKind.Int,
|
||||
pathPolicy: PathExistencePolicy.MustExist));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_PathFile_RejectsDirectoryPath()
|
||||
{
|
||||
var validator = _validator;
|
||||
var semantics = new OptionValidationSemantics(
|
||||
OptionValidationKind.PathFile,
|
||||
allowEmpty: true,
|
||||
pathPolicy: PathExistencePolicy.MustExist);
|
||||
|
||||
var dir = Directory.CreateTempSubdirectory("dnsmasq-webui-pathfile-");
|
||||
try
|
||||
{
|
||||
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsfile, dir.FullName, semantics);
|
||||
Assert.Equal("File does not exist.", err);
|
||||
}
|
||||
finally
|
||||
{
|
||||
dir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_PathDirectory_RejectsFilePath()
|
||||
{
|
||||
var validator = _validator;
|
||||
var semantics = new OptionValidationSemantics(
|
||||
OptionValidationKind.PathDirectory,
|
||||
allowEmpty: true,
|
||||
pathPolicy: PathExistencePolicy.MustExist);
|
||||
|
||||
var filePath = Path.Combine(Path.GetTempPath(), $"dnsmasq-webui-pathdir-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(filePath, "test");
|
||||
try
|
||||
{
|
||||
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsdir, filePath, semantics);
|
||||
Assert.Equal("Directory does not exist.", err);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_PathFile_AcceptsExistingFile()
|
||||
{
|
||||
var validator = _validator;
|
||||
var semantics = new OptionValidationSemantics(
|
||||
OptionValidationKind.PathFile,
|
||||
allowEmpty: true,
|
||||
pathPolicy: PathExistencePolicy.MustExist);
|
||||
|
||||
var filePath = Path.Combine(Path.GetTempPath(), $"dnsmasq-webui-pathfile-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(filePath, "test");
|
||||
try
|
||||
{
|
||||
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsfile, filePath, semantics);
|
||||
Assert.Null(err);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_PathDirectory_AcceptsExistingDirectory()
|
||||
{
|
||||
var validator = _validator;
|
||||
var semantics = new OptionValidationSemantics(
|
||||
OptionValidationKind.PathDirectory,
|
||||
allowEmpty: true,
|
||||
pathPolicy: PathExistencePolicy.MustExist);
|
||||
|
||||
var dir = Directory.CreateTempSubdirectory("dnsmasq-webui-pathdir-");
|
||||
try
|
||||
{
|
||||
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsdir, dir.FullName, semantics);
|
||||
Assert.Null(err);
|
||||
}
|
||||
finally
|
||||
{
|
||||
dir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_DhcpCircuitid_RequiresSetTagValue()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "set:uplink,aa:bb:cc", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "set:mytag,simple-string", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "tag:uplink,id", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "set:only", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_DhcpRemoteid_RequiresSetTagValue()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRemoteid, "set:uplink,remote-id", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRemoteid, "set:tag,", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_DhcpSubscrid_RequiresSetTagValue()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpSubscrid, "set:sub,subscriber-id", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpSubscrid, "bad", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_FilterRr_AcceptsRrTypes()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "A", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "A,AAAA,TXT,MX", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "ANY", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "255", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "A,,B", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_CacheRr_AcceptsRrTypes()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "TXT", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "A,AAAA,CNAME,SRV", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "ANY", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_InterfaceNameOptions_AcceptInterfaceNameWithOptionalTrailingWildcard()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
foreach (var key in new[] { DnsmasqConfKeys.Interface, DnsmasqConfKeys.ExceptInterface, DnsmasqConfKeys.NoDhcpInterface, DnsmasqConfKeys.NoDhcpv4Interface, DnsmasqConfKeys.NoDhcpv6Interface })
|
||||
{
|
||||
Assert.Null(_validator.ValidateMultiItem(key, "eth0", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(key, "eth0*", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(key, "br-lan", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(key, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(key, "eth*0", semantics));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_AuthServer_RequiresDomain_AcceptsInterfaceOrIp()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com,eth0", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com,192.168.1.1,eth1/4", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "ns.example.com,::1,eth0/6", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, ",eth0", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com,invalid@", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_Cname_RequiresCnameAndTarget_AcceptsOptionalTtl()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "router.example.local,router", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "cname1,cname2,target", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "alias,host.example.com,60", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "only", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "bad..name,target", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_MxHost_AcceptsMxNameWithOptionalHostnameAndPreference()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "example.local", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "example.local,mail.example.local,10", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "mx.local,5", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "bad..name", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "mx.local,mail,99999", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_PtrRecord_NameAndOptionalTarget()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "1.0.168.192.in-addr.arpa,host.example.com", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "name.only", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "bad..name,target", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_InterfaceNameRecord_NameAndInterface()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "router.example.local,eth0", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "host.local,eth1/4", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "name", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "name,", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_CaaRecord_FourParts()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "example.com,0,issue,letsencrypt.org", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "a,b", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "x,y,z,w", semantics)); // 4 but invalid name possible - "x" is valid DNS name
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "x,256,tag,val", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_SrvHost_ServiceNameAndOptionalTargetPortPriorityWeight()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "_sip._udp.example.com", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "_sip._udp,sip.example.com,5060,0,0", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "_sip._udp,host,70000", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_NaptrRecord_SixOrSevenParts()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "name.example.com,0,0,s,a,x,replacement", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "n.zone,1,10,flags,svc,regex", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "a,b,c", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "bad..name,0,0,f,s,r", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_DnsRr_NameAndRrNumber_OptionalHex()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "example.com,255", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "name.example,1,01:02:03", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "x", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "x,99999", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_DynamicHost_FourFields_NameAndInterface()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "example.com,0.0.0.8,eth0", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "example.com,,,eth0", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "host.local,0.0.0.8,,eth0", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "x,y", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "x,1.2.3.4,::1,bad if", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_AuthSoa_SerialAndOptionalFields()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "1", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "1,hostmaster.example.com,3600,600,86400", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "x", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_AuthSecServers_OneOrMoreDomains()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "ns1.example.com", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "a.com,b.com", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "bad..name", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_AuthPeer_OneOrMoreIps()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "192.168.1.1", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "::1,10.0.0.1", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "not-an-ip", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_HostRecord_AtLeastOneName_OptionalIpv4Ipv6Ttl()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "laptop,192.168.0.1", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "host.example.com,192.168.0.1,::1,60", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "bad..name,1.2.3.4", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_TxtRecord_NameRequired()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "example.com", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "name.example.com,some text", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "bad..name,text", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_Domain_DomainOrHash_OptionalRangeOrInterface()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "thekelleys.org.uk", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "#", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "lan,192.168.0.0/24,local", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "local.zone,eth0", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "bad..name", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_SynthDomain_DomainAndRange_OptionalPrefix()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "zone.example.com,192.168.0.50,192.168.0.70", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "zone,192.168.0.0/24,internal-*", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "only", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "bad..name,1.2.3.4", semantics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMultiItem_AuthZone_DomainRequired_OptionalSubnets()
|
||||
{
|
||||
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "example.com", semantics));
|
||||
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "zone,192.168.0.0/24", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "", semantics));
|
||||
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "bad..name", semantics));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@using System.Reflection
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -6,26 +7,29 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="DnsmasqWebUI.styles.css" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
|
||||
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css?v=@_cacheBust" />
|
||||
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css?v=@_cacheBust" />
|
||||
<link rel="stylesheet" href="app.css?v=@_cacheBust" />
|
||||
<link rel="stylesheet" href="DnsmasqWebUI.styles.css?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png?v=@_cacheBust" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png?v=@_cacheBust" />
|
||||
<title>@_appTitle</title>
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="js/copy-to-clipboard.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@code {
|
||||
private static readonly string _cacheBust = typeof(App).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0";
|
||||
private string? _appTitle => AppOptions?.Value?.EffectiveTitle;
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs
|
||||
@inject ILoggingClient LoggingClient
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control" placeholder="e.g. Microsoft.AspNetCore."
|
||||
@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>
|
||||
<ul class="list-group list-group-flush" style="max-height: 12rem; overflow-y: auto;">
|
||||
@foreach (var prefix in _prefixes)
|
||||
@@ -35,10 +35,10 @@
|
||||
</ul>
|
||||
</div>
|
||||
<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>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@(_saving ? "Saving…" : "Save")</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">@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>
|
||||
+7
-10
@@ -1,9 +1,10 @@
|
||||
using DnsmasqWebUI.Extensions.Interop;
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs;
|
||||
|
||||
public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||
{
|
||||
@@ -53,7 +54,7 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
@@ -66,17 +67,17 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||
{
|
||||
if (!_dialogInitialized)
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("initDialog", _dialogRef, _dotNetRef);
|
||||
_dialogInitialized = true;
|
||||
}
|
||||
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("showModal", _dialogRef);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -145,10 +146,6 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
_dotNetRef?.Dispose();
|
||||
if (_jsModule != null)
|
||||
{
|
||||
try { await _jsModule.DisposeAsync(); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -1,4 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs
|
||||
@* App logs — live view via SignalR from ILogger *@
|
||||
@using DnsmasqWebUI.Models.Logs
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@@ -30,4 +30,8 @@
|
||||
</span>
|
||||
</p>
|
||||
<AppLogsFiltersModal IsVisible="_filtersModalVisible" OnClose="CloseFiltersModal" />
|
||||
<pre id="app-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
|
||||
<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>
|
||||
+35
-26
@@ -1,11 +1,11 @@
|
||||
using DnsmasqWebUI.Extensions.Interop;
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
using DnsmasqWebUI.Models.Logs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs;
|
||||
|
||||
/// <summary>
|
||||
/// App logs block: connects to LogsHub, receives AppLogsUpdate pushes, polls at interval as fallback.
|
||||
@@ -18,6 +18,8 @@ public partial class AppLogsSection : IAsyncDisposable
|
||||
[Inject] private ILoggingClient LoggingClient { get; set; } = null!;
|
||||
|
||||
private bool _justUpdated;
|
||||
private bool _logsContentReceived;
|
||||
private bool _refreshing;
|
||||
private int _intervalSeconds;
|
||||
private string _logLevel = "Information";
|
||||
private bool _logLevelDisabled;
|
||||
@@ -42,16 +44,15 @@ public partial class AppLogsSection : IAsyncDisposable
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var next = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
if (next == _intervalSeconds) return;
|
||||
_intervalSeconds = next;
|
||||
if (RefreshIntervalSeconds == _intervalSeconds) return;
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
RestartPollTimer();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
_intervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
@@ -91,6 +92,25 @@ public partial class AppLogsSection : IAsyncDisposable
|
||||
private void OpenFiltersModal() => _filtersModalVisible = true;
|
||||
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()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_logLevel)) return;
|
||||
@@ -138,18 +158,13 @@ public partial class AppLogsSection : IAsyncDisposable
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_logsJs == null) return;
|
||||
try
|
||||
{
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsync("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsync("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
|
||||
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
|
||||
catch (JSException) { /* JS error; ignore */ }
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsyncSafe("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsyncSafe("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
_logsContentReceived = true;
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||
@@ -183,13 +198,7 @@ public partial class AppLogsSection : IAsyncDisposable
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
if (_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;
|
||||
}
|
||||
await _logsJs.DisposeAsyncSafe();
|
||||
_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,44 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.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 class="ec-field-label" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></span>
|
||||
<span class="ec-field-value">@fragment(Descriptor)</span>
|
||||
@if (Descriptor.GetSource()?.IsReadOnly == true)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">readonly</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
var items = Descriptor.GetItems();
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" />
|
||||
}
|
||||
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" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
|
||||
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
|
||||
private string? _labelTooltip;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_customFragment = RenderFragmentRegistry.GetDisplayFragment(Descriptor.SectionId, Descriptor.OptionName);
|
||||
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
}
|
||||
}
|
||||
-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 ms-1">readonly</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
}
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
|
||||
<li class="ec-field">
|
||||
<span class="ec-field-label" title="@LabelTooltip"><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; }
|
||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
|
||||
private string _editLines = "";
|
||||
|
||||
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,37 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
|
||||
<li class="ec-field" title="@(Source?.GetReadOnlyTooltip())">
|
||||
<span class="ec-field-label" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><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</span>
|
||||
@if (Source?.IsReadOnly == true)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">readonly</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; }
|
||||
/// <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;
|
||||
|
||||
private async Task OnValueInput(ChangeEventArgs e)
|
||||
{
|
||||
var value = e.Value?.ToString() ?? "";
|
||||
await DisplayValueChanged.InvokeAsync(value);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
|
||||
@if (Status != null)
|
||||
{
|
||||
<div class="ec-sections small">
|
||||
@{
|
||||
var descriptors = EffectiveConfigFieldBuilder.BuildFieldDescriptors(Status);
|
||||
var sections = new[]
|
||||
{
|
||||
(EffectiveConfigFieldBuilder.SectionHosts, "Hosts"),
|
||||
(EffectiveConfigFieldBuilder.SectionResolver, "Resolver / DNS"),
|
||||
(EffectiveConfigFieldBuilder.SectionDhcp, "DHCP"),
|
||||
(EffectiveConfigFieldBuilder.SectionCache, "Cache"),
|
||||
(EffectiveConfigFieldBuilder.SectionProcess, "Process & networking")
|
||||
};
|
||||
}
|
||||
@foreach (var (sectionId, title) in sections)
|
||||
{
|
||||
var sectionDescriptors = descriptors.Where(d => d.SectionId == sectionId).ToList();
|
||||
if (sectionDescriptors.Count > 0)
|
||||
{
|
||||
<EffectiveConfigSectionPanel SectionId="@sectionId" Title="@title" Descriptors="@sectionDescriptors" IsOpen="@IsOpen(sectionId)" OnToggle="@OnSectionToggle" DisplayMode="@DisplayMode" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
/// <summary>View = read-only; Edit = editable (future). Cascaded to field displays.</summary>
|
||||
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
|
||||
private HashSet<string> _openPanels = new();
|
||||
|
||||
private bool IsOpen(string id) => _openPanels.Contains(id);
|
||||
|
||||
private void OnSectionToggle(string id)
|
||||
{
|
||||
if (_openPanels.Contains(id))
|
||||
_openPanels.Remove(id);
|
||||
else
|
||||
_openPanels.Add(id);
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
/* Effective config sections – mobile-first, touch-friendly.
|
||||
Use ::deep so styles apply to EffectiveConfigSectionPanel (child component) markup. */
|
||||
.ec-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
::deep .ec-section {
|
||||
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
|
||||
}
|
||||
|
||||
::deep .ec-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Touch-friendly header: min 44px tap target (WCAG 2.5.5), breathing room from edges */
|
||||
::deep .ec-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.625rem 1rem;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
line-height: 1.3;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
::deep .ec-section-header:hover {
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
::deep .ec-section-header:focus-visible {
|
||||
outline: 2px solid var(--bs-primary, #0d6efd);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Open state: header reads as "active" and clearly separates from body */
|
||||
::deep .ec-section-open .ec-section-header {
|
||||
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.05));
|
||||
color: var(--bs-emphasis-color, inherit);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::deep .ec-section-open .ec-section-header:hover {
|
||||
background-color: var(--bs-tertiary-bg, rgba(0, 0, 0, 0.08));
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
|
||||
::deep .ec-section-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
::deep .ec-section-chevron {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
margin-left: 0.35rem;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
::deep .ec-section-open .ec-section-chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::deep .ec-section-body {
|
||||
padding: 0.25rem 1rem 0.5rem 1rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
|
||||
margin-left: 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Long paths/values wrap on small screens; badges stay inline */
|
||||
::deep .ec-section-body ul {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
::deep .ec-section-body li {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Field row: label + value + optional badge. Same structure for custom and default displays. */
|
||||
::deep .ec-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
::deep .ec-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
::deep .ec-field-label {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
::deep .ec-field-value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Multi-value: full width below label, indented ordered list */
|
||||
::deep .ec-field-value-multi {
|
||||
flex-basis: 100%;
|
||||
padding-left: 1.25rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-list li {
|
||||
margin-bottom: 0.2rem;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-item {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-sep {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
::deep .ec-section-header {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
::deep .ec-section-body {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@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. *@
|
||||
<pre id="@Id" class="pre-block mb-0" style="max-height: 24rem;">@Placeholder</pre>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Id { get; set; } = "";
|
||||
[Parameter] public string Placeholder { get; set; } = "Connecting…";
|
||||
[Parameter] public bool Freeze { get; set; }
|
||||
|
||||
protected override bool ShouldRender() => !Freeze;
|
||||
}
|
||||
+5
-5
@@ -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 *@
|
||||
@using DnsmasqWebUI.Models.Logs
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@@ -24,17 +24,17 @@
|
||||
}
|
||||
@if (_status.LogsConfigured)
|
||||
{
|
||||
<pre id="dnsmasq-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
|
||||
<LogsPreView Id="dnsmasq-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">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)
|
||||
{
|
||||
<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
|
||||
{
|
||||
<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>
|
||||
}
|
||||
+15
-26
@@ -1,11 +1,11 @@
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
using DnsmasqWebUI.Extensions.Interop;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Logs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq.Logs;
|
||||
|
||||
/// <summary>
|
||||
/// Recent logs block: connects to LogsHub, polls at interval via RequestDnsmasqLogs,
|
||||
@@ -18,6 +18,7 @@ public partial class LogsSection : IAsyncDisposable
|
||||
private DnsmasqServiceStatus? _status;
|
||||
private bool _refreshing;
|
||||
private bool _justUpdated;
|
||||
private bool _logsContentReceived;
|
||||
private int _intervalSeconds;
|
||||
private HubConnection? _hubConnection;
|
||||
private IJSObjectReference? _logsJs;
|
||||
@@ -42,16 +43,15 @@ public partial class LogsSection : IAsyncDisposable
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_status = Status;
|
||||
var next = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
if (next == _intervalSeconds) return;
|
||||
_intervalSeconds = next;
|
||||
if (RefreshIntervalSeconds == _intervalSeconds) return;
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
RestartPollTimer();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
_intervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
_status = Status;
|
||||
|
||||
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
|
||||
@@ -124,18 +124,13 @@ public partial class LogsSection : IAsyncDisposable
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_logsJs == null) return;
|
||||
try
|
||||
{
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
|
||||
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
|
||||
catch (JSException) { /* JS error; ignore */ }
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsyncSafe("replaceLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsyncSafe("appendLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
_logsContentReceived = true;
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||
@@ -169,13 +164,7 @@ public partial class LogsSection : IAsyncDisposable
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
if (_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;
|
||||
}
|
||||
await _logsJs.DisposeAsyncSafe();
|
||||
_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 *@
|
||||
@if (_status != null && _status.StatusShowConfigured)
|
||||
{
|
||||
@@ -24,7 +24,21 @@
|
||||
{
|
||||
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
|
||||
}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
|
||||
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
}
|
||||
+11
-6
@@ -1,9 +1,8 @@
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq.Status;
|
||||
|
||||
/// <summary>
|
||||
/// Service status block: polls StatusShowCommand output at its own interval and re-renders only itself.
|
||||
@@ -20,13 +19,18 @@ public partial class StatusSection : IDisposable
|
||||
|
||||
[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; }
|
||||
|
||||
[Inject] private IStatusClient StatusClient { get; set; } = null!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_intervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(
|
||||
_ => _ = InvokeAsync(OnRefreshTick),
|
||||
@@ -37,9 +41,8 @@ public partial class StatusSection : IDisposable
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var next = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
if (next == _intervalSeconds) return;
|
||||
_intervalSeconds = next;
|
||||
if (RefreshIntervalSeconds == _intervalSeconds) return;
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
_timer?.Dispose();
|
||||
_timer = new Timer(
|
||||
_ => _ = InvokeAsync(OnRefreshTick),
|
||||
@@ -57,6 +60,8 @@ public partial class StatusSection : IDisposable
|
||||
|
||||
private static readonly TimeSpan MinUpdatingDuration = TimeSpan.FromSeconds(1);
|
||||
|
||||
private Task InvokeReloadAsync() => OnReloadRequested.InvokeAsync();
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
var started = DateTime.UtcNow;
|
||||
@@ -0,0 +1,27 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@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;
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("copyToClipboard", Text);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Clipboard not available or failed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?.PendingChanges.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.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
|
||||
<div class="ec-sections small">
|
||||
@{
|
||||
var views = EffectiveConfigViews.GetViewsForContext(Context);
|
||||
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry);
|
||||
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,233 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
|
||||
@inject IEffectiveConfigEditSession Session
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
@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.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
|
||||
ValidationSummary="@Session.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.IsEditMode" ActiveFieldKey="@Session.ActiveFieldKey" />
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.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()
|
||||
{
|
||||
_uiContext = new EffectiveConfigUiContext
|
||||
{
|
||||
RequestOptionHelpAsync = HandleOptionHelpRequested,
|
||||
RequestReadonlyPopoverAsync = HandleReadonlyBadgeClicked,
|
||||
ScheduleReadonlyPopoverCloseAsync = () =>
|
||||
{
|
||||
_readonlyPopoverControllerRef?.ScheduleCloseFromBadgeLeave();
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
|
||||
DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); },
|
||||
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; },
|
||||
RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); }
|
||||
};
|
||||
}
|
||||
|
||||
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.EnterEditMode();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ExitEditModeWithConfirmAsync()
|
||||
{
|
||||
if (Session.PendingChanges.Count > 0)
|
||||
{
|
||||
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
|
||||
try
|
||||
{
|
||||
var confirmed = await module.InvokeAsync<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
Session.ExitEditModeDiscard();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void DeactivateActiveFieldIfClicked()
|
||||
{
|
||||
if (Session.IsEditMode && Session.ActiveFieldKey != null)
|
||||
{
|
||||
Session.DeactivateField();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void ActivateField(string fieldKey)
|
||||
{
|
||||
Session.ActivateField(fieldKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
|
||||
{
|
||||
Session.TrackCommit(args);
|
||||
RunCrossOptionEvaluator();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void RunCrossOptionEvaluator()
|
||||
{
|
||||
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, Session.PendingChanges);
|
||||
Session.SetCrossOptionIssues(issues);
|
||||
}
|
||||
|
||||
private void OnRevertedFromSaveModal()
|
||||
{
|
||||
RunCrossOptionEvaluator();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OpenSaveModal()
|
||||
{
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
_showSaveModal = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void CloseSaveModal()
|
||||
{
|
||||
_showSaveModal = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ContinueEditingAfterSavedFailureAsync()
|
||||
{
|
||||
_showSaveModal = false;
|
||||
Session.ExitEditModeDiscard();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
Session.EnterEditMode();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void CloseSaveModalAndEndEdit()
|
||||
{
|
||||
_showSaveModal = false;
|
||||
Session.ExitEditModeDiscard();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
/* Effective config sections – mobile-first, touch-friendly.
|
||||
Use ::deep so styles apply to EffectiveConfigSectionPanel (child component) markup. */
|
||||
|
||||
/* Wrapper: border and grouping around all sections. Margin-top separates from config files list above. */
|
||||
.ec-container {
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--bs-secondary-bg, #f8f9fa);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Toolbar: single row, search left and actions right. Use ::deep so styles apply to EffectiveConfigToolbar (child) markup. */
|
||||
::deep .ec-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
::deep .ec-toolbar-search {
|
||||
flex: 1 1 auto;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
::deep .ec-section {
|
||||
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
|
||||
}
|
||||
|
||||
::deep .ec-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Section header: clear separation from background, touch-friendly (min 44px), distinct so search jumps are obvious */
|
||||
::deep .ec-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.625rem 1rem;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-left: 3px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--bs-body-bg, #fff);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
line-height: 1.3;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
::deep .ec-section-header:hover {
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
|
||||
border-left-color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
|
||||
::deep .ec-section-header:focus-visible {
|
||||
outline: 2px solid var(--bs-primary, #0d6efd);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Open state: header reads as "active", left border accent */
|
||||
::deep .ec-section-open .ec-section-header {
|
||||
background-color: var(--bs-body-bg, #fff);
|
||||
color: var(--bs-emphasis-color, inherit);
|
||||
font-weight: 600;
|
||||
border-left-color: var(--bs-primary, #0d6efd);
|
||||
box-shadow: 0 1px 0 var(--bs-border-color, #dee2e6);
|
||||
}
|
||||
|
||||
::deep .ec-section-open .ec-section-header:hover {
|
||||
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
|
||||
::deep .ec-section-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
::deep .ec-section-chevron {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
margin-left: 0.35rem;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
::deep .ec-section-open .ec-section-chevron {
|
||||
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 {
|
||||
padding: 0.25rem 1rem 0.5rem 1rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
|
||||
margin-left: 0;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Long paths/values wrap on small screens; badges stay inline */
|
||||
::deep .ec-section-body ul {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
::deep .ec-section-body li {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Field row: label + value + optional badge. Same structure for custom and default displays. */
|
||||
::deep .ec-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
::deep .ec-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
::deep .ec-field-label {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Clickable label: open full option help on click (hover tooltip unchanged) */
|
||||
::deep .ec-field-label-help {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
::deep .ec-field-label-help:hover {
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
|
||||
::deep .ec-field-value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: 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: wrap;
|
||||
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. */
|
||||
::deep .ec-field-value-multi {
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
padding-left: 1.25rem;
|
||||
margin-top: 0.15rem;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Row-level ok/cancel (and revert) for multi-value: right-align on the row below the list */
|
||||
::deep .ec-field .ec-field-value-multi + .status-badge-inline {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
list-style-type: decimal;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-list li {
|
||||
margin-bottom: 0.2rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Long paths/URLs wrap; allow break anywhere so nothing is clipped */
|
||||
::deep .ec-field-value-item {
|
||||
display: inline;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
::deep .ec-field-value-sep {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* StatusBadge inside field rows: alignment; spacing comes from StatusBadge .status-badge-inline */
|
||||
::deep .ec-field .status-badge,
|
||||
::deep .ec-field-value-item .status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Only when field is in edit mode: push ok/cancel/revert to end and allow them to wrap first on narrow widths */
|
||||
::deep .ec-field-value-when-editing > .status-badge-inline {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
::deep .ec-field-value-when-editing > .status-badge-inline {
|
||||
flex-basis: 100%;
|
||||
margin-left: 0;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
::deep .ec-section-header {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
::deep .ec-section-body {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -1,5 +1,6 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<CascadingValue Value="DisplayMode">
|
||||
<div class="ec-section @(IsOpen ? "ec-section-open" : "")">
|
||||
@@ -7,14 +8,18 @@
|
||||
<span class="ec-section-title fw-bold">@Title</span>
|
||||
<i class="bi @(IsOpen ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<div class="ec-section-collapse @(IsOpen ? "ec-section-collapse-open" : "")">
|
||||
<div class="ec-section-collapse-inner">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
@foreach (var f in Descriptors)
|
||||
{
|
||||
<EffectiveConfigFieldDisplay Descriptor="@f" />
|
||||
var fieldKey = $"{SectionId}:{f.OptionName}";
|
||||
<EffectiveConfigFieldDisplay Descriptor="@f" FieldKey="@fieldKey"
|
||||
IsEditMode="@IsEditMode" IsActive="@(ActiveFieldKey == fieldKey)" />
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,8 +31,10 @@
|
||||
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
|
||||
[Parameter] public bool IsOpen { get; set; }
|
||||
[Parameter] public EventCallback<string> OnToggle { get; set; }
|
||||
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable (future).</summary>
|
||||
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable.</summary>
|
||||
[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);
|
||||
}
|
||||
@@ -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 save dialog"
|
||||
@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 save dialog"
|
||||
@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?.PendingChanges.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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* Options that can be: not set, key-only (e.g. use-stale-cache), or key=value (e.g. use-stale-cache=60). Checkbox to enable; when enabled, optional value input. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var isEnabled = _editorValue != null;
|
||||
var valuePart = _editorValue is { Length: > 0 } ? _editorValue : "";
|
||||
<div class="ec-key-only-or-value-editor">
|
||||
<div class="form-check form-switch mb-0 ec-key-only-or-value-switch">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
checked="@isEnabled"
|
||||
aria-label="Enable @Descriptor.OptionName"
|
||||
title="Set @Descriptor.OptionName"
|
||||
@onchange="OnCheckedChange" />
|
||||
</div>
|
||||
@if (isEnabled)
|
||||
{
|
||||
<input type="text"
|
||||
class="form-control form-control-sm ec-key-only-or-value-input"
|
||||
placeholder="@(DnsmasqOptionPlaceholders.Get(Descriptor.OptionName) ?? "Optional value")"
|
||||
value="@valuePart"
|
||||
@oninput="OnValueInput" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
if (v is null)
|
||||
{
|
||||
@("(not set)")
|
||||
}
|
||||
else if (v.Length == 0)
|
||||
{
|
||||
@("Enabled")
|
||||
}
|
||||
else
|
||||
{
|
||||
@v
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? _editorValue;
|
||||
private bool _wasActiveEditor;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var effective = GetEffectiveDisplayValue() as string;
|
||||
if (!IsActiveEditor)
|
||||
{
|
||||
_editorValue = effective;
|
||||
}
|
||||
else if (!_wasActiveEditor)
|
||||
{
|
||||
_editorValue = effective;
|
||||
}
|
||||
|
||||
_wasActiveEditor = IsActiveEditor;
|
||||
}
|
||||
|
||||
private async Task OnCheckedChange(ChangeEventArgs e)
|
||||
{
|
||||
var isChecked = e.Value switch
|
||||
{
|
||||
bool b => b,
|
||||
string s when bool.TryParse(s, out var parsed) => parsed,
|
||||
_ => false
|
||||
};
|
||||
|
||||
_editorValue = isChecked ? "" : null;
|
||||
await NotifyValueChanged(_editorValue);
|
||||
}
|
||||
|
||||
private async Task OnValueInput(ChangeEventArgs e)
|
||||
{
|
||||
var raw = e.Value?.ToString()?.Trim();
|
||||
_editorValue = string.IsNullOrEmpty(raw) ? "" : raw;
|
||||
await NotifyValueChanged(_editorValue);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
.ec-key-only-or-value-editor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ec-key-only-or-value-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
min-height: 1.25rem;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.ec-key-only-or-value-switch .form-check-input {
|
||||
float: none;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.ec-key-only-or-value-input {
|
||||
flex: 1 1 7rem;
|
||||
min-width: 0;
|
||||
width: 7rem;
|
||||
max-width: 9rem;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* local-service: net | host. Pending value shown until save. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
<select class="form-select form-select-sm" style="max-width: 10rem;" value="@(GetEffectiveDisplayValue() as string ?? "")" @onchange="OnChange" @onfocusout="OnCommitRequested">
|
||||
<option value="">(not set)</option>
|
||||
<option value="net">net</option>
|
||||
<option value="host">host</option>
|
||||
</select>
|
||||
}
|
||||
else
|
||||
{
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
@(string.IsNullOrEmpty(v) ? "(not set)" : (v.Trim().ToLowerInvariant() is "net" or "host" ? v.Trim() : v))
|
||||
}
|
||||
|
||||
@code {
|
||||
private async Task OnChange(ChangeEventArgs e)
|
||||
{
|
||||
var v = e.Value?.ToString()?.Trim();
|
||||
var value = string.IsNullOrEmpty(v) ? null : (v == "net" || v == "host" ? v : null);
|
||||
await NotifyValueChanged(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* log-queries: optional value extra|proto|auth. View: Off / On / On (extra|proto|auth). When active editor: dropdown. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
<select class="form-select form-select-sm" style="max-width: 12rem;" value="@GetEditValue()" @onchange="OnChange" @onfocusout="OnCommitRequested">
|
||||
<option value="">Off</option>
|
||||
<option value="on">On</option>
|
||||
<option value="extra">On (extra)</option>
|
||||
<option value="proto">On (proto)</option>
|
||||
<option value="auth">On (auth)</option>
|
||||
</select>
|
||||
}
|
||||
else
|
||||
{
|
||||
@GetViewText()
|
||||
}
|
||||
|
||||
@code {
|
||||
private string GetEditValue()
|
||||
{
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
if (string.IsNullOrEmpty(v)) return "";
|
||||
var lower = v.Trim().ToLowerInvariant();
|
||||
if (lower is "extra" or "proto" or "auth") return lower;
|
||||
return "on";
|
||||
}
|
||||
|
||||
private string GetViewText()
|
||||
{
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
if (string.IsNullOrEmpty(v)) return "Off";
|
||||
var lower = v.Trim().ToLowerInvariant();
|
||||
return lower switch
|
||||
{
|
||||
"extra" => "On (extra)",
|
||||
"proto" => "On (proto)",
|
||||
"auth" => "On (auth)",
|
||||
_ => "On"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task OnChange(ChangeEventArgs e)
|
||||
{
|
||||
var sel = e.Value?.ToString()?.Trim().ToLowerInvariant();
|
||||
string? newValue = sel switch
|
||||
{
|
||||
"" or "off" => null,
|
||||
"on" => "",
|
||||
"extra" => "extra",
|
||||
"proto" => "proto",
|
||||
"auth" => "auth",
|
||||
_ => null
|
||||
};
|
||||
await NotifyValueChanged(newValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* Value-only display for the port option. Pending value shown until save. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var port = GetEffectiveDisplayValue() 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" @onfocusout="OnCommitRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@(GetEffectiveDisplayValue() is int port ? $"{port} (default DNS port)" : "(not set)")
|
||||
}
|
||||
|
||||
@code {
|
||||
private async Task OnPortInput(ChangeEventArgs e)
|
||||
{
|
||||
if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535)
|
||||
await NotifyValueChanged(port);
|
||||
else
|
||||
await NotifyValueChanged(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. Pending value shown until save. *@
|
||||
@if (_customFragment is { } fragment)
|
||||
{
|
||||
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
|
||||
var showEditableBadge = !isReadonly && !Descriptor.IsCapabilityDisabled;
|
||||
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
|
||||
var fragmentWithCallback = isActiveEditor ? _fragmentWithCallback : null;
|
||||
var effectiveValue = _effectiveValue;
|
||||
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
||||
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||
<span class="ec-field-value">
|
||||
@if (isActiveEditor)
|
||||
{
|
||||
<div class="ec-field-value-when-editing" @onclick:stopPropagation="true">
|
||||
<div class="ec-field-edit-wrapper">
|
||||
<CascadingValue Value="@true">
|
||||
<CascadingValue Value="@(EventCallback.Factory.Create<FocusEventArgs>(this, OnBlur))">
|
||||
@((fragmentWithCallback ?? fragment)(Descriptor))
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</div>
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<CascadingValue Value="@false">
|
||||
@fragment(Descriptor)
|
||||
</CascadingValue>
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<IReadOnlyList<string>>(this, HandleMultiValueChanged));
|
||||
if (customMulti is not null)
|
||||
{
|
||||
@* Custom multi editor (e.g. server): same row shell as generic multi, value slot uses custom fragment. *@
|
||||
<li class="ec-field @(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled ? "ec-field-editing" : "")">
|
||||
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||
<span class="ec-field-value">
|
||||
<CascadingValue Value="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)">
|
||||
<CascadingValue Value="@IsEditMode">
|
||||
@customMulti(Descriptor)
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</span>
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
|
||||
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)"
|
||||
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
||||
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
|
||||
<EffectiveConfigMultiValueRow Descriptor="@Descriptor" Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||
Source="@_multiValueRowSource" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var value = _effectiveValue;
|
||||
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
|
||||
var editValue = value?.ToString() ?? "";
|
||||
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
|
||||
var showReadonlyHint = IsEditMode && isReadonly;
|
||||
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
|
||||
var showEditableForDefault = !Descriptor.IsCapabilityDisabled;
|
||||
@if (showReadonlyHint)
|
||||
{
|
||||
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
||||
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
FieldKey="@FieldKey" ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange" />
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
||||
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
EditValue="@editValue"
|
||||
ShowEditableBadge="@showEditableForDefault" FieldKey="@FieldKey" IsActive="@IsActive"
|
||||
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange"
|
||||
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
||||
DisplayValueChanged="@HandleDefaultValueChanged" OnBlur="@HandleDefaultBlur" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[Parameter] public string FieldKey { get; set; } = "";
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public bool IsActive { get; set; }
|
||||
[CascadingParameter] public IEffectiveConfigEditSession? Session { get; set; }
|
||||
|
||||
private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8];
|
||||
private readonly string _readonlyBadgeAnchorId = "ec-readonly-" + Guid.NewGuid().ToString("N")[..8];
|
||||
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
|
||||
private RenderFragment<EffectiveConfigFieldDescriptor>? _fragmentWithCallback;
|
||||
private string? _labelTooltip;
|
||||
private string? _optionHelpKey;
|
||||
private object? _effectiveValue;
|
||||
private bool _hasPendingChange;
|
||||
private object? _valueAtActivation;
|
||||
private object? _draftValue;
|
||||
private bool _hasDraftValue;
|
||||
private bool _wasActive;
|
||||
private ExitAction _exitAction = ExitAction.None;
|
||||
|
||||
/// <summary>Cached for multi-value: items to pass to the row (with sources preserved for draft merge).</summary>
|
||||
private IReadOnlyList<ValueWithSource>? _multiValueItems;
|
||||
/// <summary>Cached for multi-value: true when there is an uncommitted draft list.</summary>
|
||||
private bool _multiValueHasDraft;
|
||||
/// <summary>Cached for multi-value: row-level source for badge (readonly when all items readonly and not edit mode).</summary>
|
||||
private ConfigValueSource? _multiValueRowSource;
|
||||
/// <summary>Cached for multi-value: whether row should show ok/cancel (session pending or local draft).</summary>
|
||||
private bool _multiValueHasPendingOrDraft;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
|
||||
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
|
||||
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
_optionHelpKey = Descriptor.OptionName;
|
||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||
var descriptorValue = Descriptor.IsMultiValue
|
||||
? (Descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>())
|
||||
: Descriptor.GetValue();
|
||||
_hasPendingChange = pending != null;
|
||||
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
|
||||
if (Descriptor.IsMultiValue)
|
||||
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||
if (!_wasActive && IsActive)
|
||||
{
|
||||
// Use pending.OldValue when present so "revert to original" clears the pending change (e.g. do-0x20-encode: Disabled → Enable → Disable).
|
||||
_valueAtActivation = pending != null ? pending.OldValue : descriptorValue;
|
||||
_hasDraftValue = false;
|
||||
_draftValue = null;
|
||||
}
|
||||
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
|
||||
_ = InvokeAsync(CommitIfChangedAsync);
|
||||
if (_wasActive && !IsActive)
|
||||
_exitAction = ExitAction.None;
|
||||
_wasActive = IsActive;
|
||||
}
|
||||
|
||||
private void ComputeMultiValueState(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string>? pendingList, IReadOnlyList<string>? draftList)
|
||||
{
|
||||
var descriptorValues = descriptorItems?.Select(i => i.Value).ToList() ?? new List<string>();
|
||||
_multiValueHasDraft = draftList != null && !ValuesEqual(descriptorValues, draftList);
|
||||
if (_multiValueHasDraft && draftList != null)
|
||||
{
|
||||
_multiValueItems = BuildItemsWithSource(descriptorItems, draftList);
|
||||
}
|
||||
else if (_hasPendingChange && pendingList != null)
|
||||
{
|
||||
_multiValueItems = BuildItemsWithSource(descriptorItems, pendingList);
|
||||
}
|
||||
else
|
||||
{
|
||||
_multiValueItems = descriptorItems;
|
||||
}
|
||||
var allReadonly = _multiValueItems != null && _multiValueItems.Count > 0 && _multiValueItems.All(i => i.Source?.IsReadOnly == true);
|
||||
_multiValueRowSource = allReadonly && !IsEditMode ? _multiValueItems?.FirstOrDefault()?.Source : null;
|
||||
_multiValueHasPendingOrDraft = _hasPendingChange || _multiValueHasDraft;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ValueWithSource> BuildItemsWithSource(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string> values)
|
||||
{
|
||||
var pool = descriptorItems?.ToList() ?? new List<ValueWithSource>();
|
||||
var list = new List<ValueWithSource>(values.Count);
|
||||
foreach (var s in values)
|
||||
{
|
||||
var idx = pool.FindIndex(x => string.Equals(x.Value, s, StringComparison.Ordinal));
|
||||
if (idx >= 0)
|
||||
{
|
||||
list.Add(pool[idx]);
|
||||
pool.RemoveAt(idx);
|
||||
}
|
||||
else
|
||||
list.Add(new ValueWithSource(s, null));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private void HandleValueChanged(object? value)
|
||||
{
|
||||
_draftValue = value;
|
||||
_hasDraftValue = true;
|
||||
}
|
||||
|
||||
private async Task OnBlur(FocusEventArgs _)
|
||||
{
|
||||
if (!IsActive) return;
|
||||
await CommitIfChangedAsync();
|
||||
}
|
||||
|
||||
private async Task<bool> CommitIfChangedAsync()
|
||||
{
|
||||
var newValue = _hasDraftValue ? _draftValue : _effectiveValue;
|
||||
if (IsDefaultSingleValueField)
|
||||
newValue = NormalizeDefaultSingleCommitValue(newValue);
|
||||
if (ValuesEqual(_valueAtActivation, newValue))
|
||||
{
|
||||
_draftValue = null;
|
||||
_hasDraftValue = false;
|
||||
if (_hasPendingChange && Ui?.RevertFieldAsync != null)
|
||||
await Ui.RevertFieldAsync(Descriptor.SectionId, Descriptor.OptionName);
|
||||
return true;
|
||||
}
|
||||
var validationError = Descriptor.Validate(newValue);
|
||||
if (validationError != null)
|
||||
{
|
||||
NotificationService.ShowError(validationError);
|
||||
return false;
|
||||
}
|
||||
|
||||
var source = Descriptor.GetSource();
|
||||
object? valueToCommit = newValue;
|
||||
if (newValue is IReadOnlyList<string> list)
|
||||
{
|
||||
var deduped = list.Distinct(StringComparer.Ordinal).ToList();
|
||||
valueToCommit = deduped;
|
||||
}
|
||||
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, valueToCommit, source?.FilePath);
|
||||
if (Ui != null)
|
||||
await Ui.CommitFieldAsync(args);
|
||||
_draftValue = null;
|
||||
_hasDraftValue = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsDefaultSingleValueField =>
|
||||
!Descriptor.IsMultiValue && _customFragment is null;
|
||||
|
||||
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
|
||||
|
||||
private void HandleDefaultValueChanged(string value)
|
||||
{
|
||||
_draftValue = value;
|
||||
_hasDraftValue = true;
|
||||
}
|
||||
|
||||
private async Task HandleDefaultBlur(string? value)
|
||||
{
|
||||
if (!IsActive) return;
|
||||
_draftValue = NormalizeDefaultSingleCommitValue(value);
|
||||
_hasDraftValue = true;
|
||||
if (!await CommitIfChangedAsync() && Ui != null)
|
||||
await Ui.ActivateFieldAsync(FieldKey);
|
||||
}
|
||||
|
||||
private static object? NormalizeDefaultSingleCommitValue(object? value)
|
||||
{
|
||||
if (value is string s && string.IsNullOrWhiteSpace(s))
|
||||
return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
private void HandleMultiValueChanged(IReadOnlyList<string> values)
|
||||
{
|
||||
_draftValue = values.ToList();
|
||||
_hasDraftValue = true;
|
||||
if (Descriptor.IsMultiValue)
|
||||
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleConfirmEdit()
|
||||
{
|
||||
if (!await CommitIfChangedAsync())
|
||||
return;
|
||||
_exitAction = ExitAction.Confirm;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private async Task HandleCancelEdit()
|
||||
{
|
||||
_draftValue = null;
|
||||
_hasDraftValue = false;
|
||||
_exitAction = ExitAction.Cancel;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private async Task HandleRevertEdit()
|
||||
{
|
||||
_draftValue = null;
|
||||
_hasDraftValue = false;
|
||||
Session?.RevertChange(Descriptor.SectionId, Descriptor.OptionName);
|
||||
_exitAction = ExitAction.Revert;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? AsStringList(object? value)
|
||||
{
|
||||
return value as IReadOnlyList<string>;
|
||||
}
|
||||
|
||||
private static bool ValuesEqual(object? oldValue, object? newValue)
|
||||
{
|
||||
var oldList = AsStringList(oldValue);
|
||||
var newList = AsStringList(newValue);
|
||||
if (oldList != null || newList != null)
|
||||
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
|
||||
return Equals(oldValue, newValue);
|
||||
}
|
||||
|
||||
private enum ExitAction { None, Confirm, Cancel, Revert }
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<span class="ec-field-value-item" title="@(ReadonlyClickActivatesField ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip())">
|
||||
@Value
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value"
|
||||
IsInteractive="@(IsEditMode && !ReadonlyClickActivatesField && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))"
|
||||
ShowEditableBadge="@(Source?.IsReadOnly != true)" EditableAsButton="true"
|
||||
FieldKey="@FieldKey" ReadonlyClickActivatesField="@ReadonlyClickActivatesField" ReadonlyTitleOverride="@ReadonlyTitleOverride" />
|
||||
</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
|
||||
[Parameter] public string? FieldKey { get; set; }
|
||||
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
|
||||
[Parameter] public string? ReadonlyTitleOverride { get; set; }
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||
<ol class="ec-field-value-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-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
|
||||
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||
{
|
||||
<div class="ec-multi-list-row-controls">
|
||||
<div class="ec-multi-list-input-group">
|
||||
<input class="form-control form-control-sm ec-multi-list-input"
|
||||
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
</div>
|
||||
<span class="status-badge-inline">
|
||||
<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" />
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value-item">@rowValue</span>
|
||||
|
||||
<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>
|
||||
}
|
||||
</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-multi-list-row-controls">
|
||||
<div class="ec-multi-list-input-group">
|
||||
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input"
|
||||
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
</div>
|
||||
<span class="status-badge-inline">
|
||||
<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" />
|
||||
</span>
|
||||
</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; } = "";
|
||||
[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 = "add-item";
|
||||
private static readonly TimeSpan ValidationToastDedupeWindow = TimeSpan.FromSeconds(2);
|
||||
|
||||
private int? _editingIndex;
|
||||
private bool _addingNew;
|
||||
private string _draftValue = "";
|
||||
private bool _didAutoOpenAdd;
|
||||
private bool _shouldFocusAddInput;
|
||||
private ElementReference _addNewInputRef;
|
||||
private string? _lastValidationErrorMessage;
|
||||
private DateTime _lastValidationErrorUtc;
|
||||
|
||||
private List<ValueWithSource> _displayItems = new();
|
||||
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;
|
||||
_shouldFocusAddInput = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (_shouldFocusAddInput && _addingNew)
|
||||
{
|
||||
_shouldFocusAddInput = false;
|
||||
try
|
||||
{
|
||||
await _addNewInputRef.FocusAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Focus can fail if the element is not yet in the DOM (e.g. prerender)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginEdit(int index, string current)
|
||||
{
|
||||
_addingNew = false;
|
||||
_editingIndex = index;
|
||||
_draftValue = current;
|
||||
}
|
||||
|
||||
private void BeginAdd()
|
||||
{
|
||||
_editingIndex = null;
|
||||
_addingNew = true;
|
||||
_draftValue = "";
|
||||
_shouldFocusAddInput = true;
|
||||
}
|
||||
|
||||
private void CancelInline()
|
||||
{
|
||||
_editingIndex = null;
|
||||
_addingNew = false;
|
||||
_draftValue = "";
|
||||
_lastValidationErrorMessage = null;
|
||||
}
|
||||
|
||||
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 current = _currentValues;
|
||||
var next = current.ToList();
|
||||
if (index < 0 || index >= next.Count) return;
|
||||
|
||||
var raw = _draftValue ?? "";
|
||||
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
ShowValidationError("Value cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
var validationError = Validator?.ValidateItem(normalized, current, 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 raw = _draftValue ?? "";
|
||||
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
ShowValidationError("Value cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
if (index < 0 || index >= next.Count) return;
|
||||
|
||||
next.RemoveAt(index);
|
||||
_displayItems.RemoveAt(index);
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/* Editor block: distinct from display mode (border + background) */
|
||||
.ec-multi-list-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);
|
||||
}
|
||||
|
||||
.ec-multi-list-editor .ec-field-value-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ec-multi-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.ec-multi-list-row.ec-multi-list-row-with-controls {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ec-multi-list-row-controls {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ec-multi-list-input-group {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.ec-multi-list-invalid-feedback {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ec-multi-list-row .ec-field-value-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ec-multi-list-row .status-badge-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ec-multi-list-row .form-control.ec-multi-list-input {
|
||||
max-width: none;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ec-multi-list-empty-hint {
|
||||
list-style: none;
|
||||
margin-left: -1.25rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item .form-control.ec-multi-list-input {
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item .ec-multi-list-input-group {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item .status-badge-inline {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-btn {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<li class="ec-field">
|
||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
|
||||
@if (IsActiveEditor)
|
||||
{
|
||||
<EffectiveConfigMultiValueListEditor
|
||||
SourceItems="@(Items ?? Array.Empty<ValueWithSource>())"
|
||||
OptionName="@Label"
|
||||
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
|
||||
IsEditMode="@IsEditMode"
|
||||
AllowAddManagedValue="@IsEditMode"
|
||||
FieldKey="@FieldKey"
|
||||
Behavior="@(_multiDesc?.Behavior)"
|
||||
Validator="@(_multiDesc?.Validator)"
|
||||
OnValuesChanged="@ItemsChanged" />
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
||||
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
||||
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason"
|
||||
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="ec-field-value ec-field-value-multi">
|
||||
@if (Items?.Count > 0)
|
||||
{
|
||||
<ol class="ec-field-value-list">
|
||||
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
|
||||
{
|
||||
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : "";
|
||||
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" OptionName="@Label" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" FieldKey="@FieldKey" ReadonlyClickActivatesField="true" ReadonlyTitleOverride="New values can still be added. Click to edit." /></li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">(none)</span>
|
||||
<OptionStateBadge OptionName="@Label" ShowEditableBadge="@(!(Descriptor?.IsCapabilityDisabled == true))" EditableAsButton="true"
|
||||
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public EffectiveConfigFieldDescriptor? Descriptor { 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; }
|
||||
[Parameter] public bool IsActiveEditor { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
||||
[Parameter] public bool HasPendingChange { get; set; }
|
||||
[Parameter] public string FieldKey { get; set; } = "";
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
|
||||
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><strong>@Label:</strong></OptionHelpLabel>
|
||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true && !IsCapabilityDisabled)
|
||||
{
|
||||
<span class="ec-field-value" @onclick:stopPropagation="true">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="@(DnsmasqOptionPlaceholders.Get(Label) ?? "Value")"
|
||||
value="@(_editValue ?? EditValue)"
|
||||
@oninput="OnValueInput"
|
||||
@onfocusout="OnInputBlur" />
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="true" IsCapabilityDisabled="@IsCapabilityDisabled" CapabilityDisabledReason="@CapabilityDisabledReason" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value">
|
||||
@DisplayValue
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="false" IsCapabilityDisabled="@IsCapabilityDisabled" CapabilityDisabledReason="@CapabilityDisabledReason" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
</span>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public string DisplayValue { get; set; } = "";
|
||||
[Parameter] public string EditValue { 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 set in edit mode, the readonly badge is rendered as a button with this id so the popover can anchor to it.</summary>
|
||||
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
|
||||
/// <summary>Raw value for the readonly popover override line (e.g. from descriptor); falls back to DisplayValue.</summary>
|
||||
[Parameter] public object? RawValue { get; set; }
|
||||
/// <summary>When true, show an 'editable' badge next to non-readonly fields; clicking it activates edit for this field.</summary>
|
||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
||||
/// <summary>When true, badge shows "pending" and warning style to indicate this field has an unsaved change.</summary>
|
||||
[Parameter] public bool HasPendingChange { get; set; }
|
||||
/// <summary>Field key for activation via context when editable badge is clicked.</summary>
|
||||
[Parameter] public string FieldKey { get; set; } = "";
|
||||
/// <summary>When true, add ec-field-editing class and show input when DisplayMode is Edit.</summary>
|
||||
[Parameter] public bool IsActive { get; set; }
|
||||
/// <summary>When true, option is disabled because dnsmasq build does not support it; show disabled badge and no editor.</summary>
|
||||
[Parameter] public bool IsCapabilityDisabled { get; set; }
|
||||
/// <summary>Tooltip when <see cref="IsCapabilityDisabled"/> is true.</summary>
|
||||
[Parameter] public string? CapabilityDisabledReason { 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; }
|
||||
/// <summary>When Edit and not readonly: raised when the input loses focus; argument is the current value.</summary>
|
||||
[Parameter] public EventCallback<string?> OnBlur { get; set; }
|
||||
/// <summary>When user clicks OK; parent should commit and deactivate.</summary>
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
/// <summary>When user clicks Cancel; parent should clear draft and deactivate.</summary>
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
/// <summary>When user clicks Revert; parent should revert pending, clear draft, and deactivate.</summary>
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
|
||||
private string? _editValue;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (DisplayMode != EffectiveConfigDisplayMode.Edit)
|
||||
_editValue = null;
|
||||
}
|
||||
|
||||
private async Task OnValueInput(ChangeEventArgs e)
|
||||
{
|
||||
var value = e.Value?.ToString() ?? "";
|
||||
_editValue = value;
|
||||
await DisplayValueChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private async Task OnInputBlur(FocusEventArgs _)
|
||||
{
|
||||
if (OnBlur.HasDelegate)
|
||||
await OnBlur.InvokeAsync(_editValue ?? EditValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Extensions.DependencyInjection
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.JSInterop
|
||||
@using System.Text.Json.Serialization
|
||||
@inject IHttpClientFactory HttpFactory
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="config-option-help-popover" id="@_popoverId" style="@_positionStyle" @onmouseenter="OnModalMouseEnter" @onmouseleave="OnModalMouseLeave">
|
||||
<div class="config-option-help-popover-content">
|
||||
<div class="config-option-help-popover-header">
|
||||
<span class="config-option-help-popover-title">@Title</span>
|
||||
</div>
|
||||
<div class="config-option-help-popover-body">
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted small mb-0">Loading…</p>
|
||||
}
|
||||
else if (_error != null)
|
||||
{
|
||||
<p class="text-danger small mb-0">@_error</p>
|
||||
}
|
||||
else if (_helpHtml != null)
|
||||
{
|
||||
<div class="option-help">@((MarkupString)_helpHtml)</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? HelpKey { get; set; }
|
||||
[Parameter] public string? AnchorId { get; set; }
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public string Title { get; set; } = "Option help";
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter] public EventCallback OnMouseEnterModal { get; set; }
|
||||
[Parameter] public EventCallback OnMouseLeaveModal { get; set; }
|
||||
|
||||
private string? _helpHtml;
|
||||
private string? _error;
|
||||
private bool _loading;
|
||||
private string? _lastKey;
|
||||
private string _positionStyle = "";
|
||||
private readonly string _popoverId = "config-option-help-popover-" + Guid.NewGuid().ToString("N")[..8];
|
||||
private DotNetObjectReference<ConfigOptionHelpModal>? _dotNetRef;
|
||||
private bool _scrollListenerAdded;
|
||||
private bool _mouseOverModal;
|
||||
private IJSObjectReference? _jsModule;
|
||||
private bool _moduleLoaded;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
catch (InvalidOperationException) { /* prerender */ }
|
||||
catch (JSDisconnectedException) { /* circuit disconnected */ }
|
||||
catch (JSException) { /* import failed */ }
|
||||
}
|
||||
|
||||
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = true;
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
await _jsModule.InvokeVoidAsyncSafe("addScrollCloseListener", _dotNetRef);
|
||||
}
|
||||
else if (!Visible && _scrollListenerAdded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnScrollClose()
|
||||
{
|
||||
await PositionUnderAnchorAsync();
|
||||
StateHasChanged();
|
||||
if (!_mouseOverModal)
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_scrollListenerAdded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
}
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
_jsModule = null;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (!Visible || string.IsNullOrEmpty(HelpKey))
|
||||
{
|
||||
_helpHtml = null;
|
||||
_error = null;
|
||||
_loading = false;
|
||||
_positionStyle = "";
|
||||
_mouseOverModal = false;
|
||||
return;
|
||||
}
|
||||
if (HelpKey == _lastKey && _helpHtml != null)
|
||||
{
|
||||
await PositionUnderAnchorAsync();
|
||||
return;
|
||||
}
|
||||
_lastKey = HelpKey;
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_helpHtml = null;
|
||||
_positionStyle = "visibility: hidden;"; // avoid flash before position is set
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var http = HttpFactory.CreateClient(ServiceCollectionExtensions.DnsmasqApiClientName);
|
||||
var response = await http.GetAsync($"option-help/{HelpKey}.html");
|
||||
if (response.IsSuccessStatusCode)
|
||||
_helpHtml = await response.Content.ReadAsStringAsync();
|
||||
else
|
||||
_error = "Help not available for this option.";
|
||||
}
|
||||
catch
|
||||
{
|
||||
_error = "Could not load help.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
await PositionUnderAnchorAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PositionUnderAnchorAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(AnchorId) || _jsModule == null) return;
|
||||
var rect = await _jsModule.InvokeAsyncSafe<BoundingRect?>("getAnchorRect", AnchorId);
|
||||
if (rect is { } r)
|
||||
{
|
||||
var gap = 4;
|
||||
var topPx = r.Bottom + gap;
|
||||
var leftPx = r.Left;
|
||||
_positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;";
|
||||
}
|
||||
else
|
||||
{
|
||||
_positionStyle = "visibility: visible;";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnModalMouseEnter()
|
||||
{
|
||||
_mouseOverModal = true;
|
||||
await OnMouseEnterModal.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnModalMouseLeave()
|
||||
{
|
||||
_mouseOverModal = false;
|
||||
await OnMouseLeaveModal.InvokeAsync();
|
||||
}
|
||||
|
||||
private sealed record BoundingRect(
|
||||
[property: JsonPropertyName("top")] double Top,
|
||||
[property: JsonPropertyName("left")] double Left,
|
||||
[property: JsonPropertyName("bottom")] double Bottom,
|
||||
[property: JsonPropertyName("width")] double Width,
|
||||
[property: JsonPropertyName("height")] double Height);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Popover anchored under the label (position set in code from anchor rect) */
|
||||
.config-option-help-popover {
|
||||
position: fixed;
|
||||
z-index: 1060;
|
||||
max-width: min(420px, calc(100vw - 1rem));
|
||||
max-height: min(70vh, 400px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
background: var(--bs-body-bg, #fff);
|
||||
}
|
||||
|
||||
.config-option-help-popover-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.config-option-help-popover-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.config-option-help-popover-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.config-option-help-popover-body {
|
||||
padding: 0.75rem;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Option help content: man-page fragments (dt/dd, b, p, a) */
|
||||
.config-option-help-popover-body .option-help dt {
|
||||
font-weight: 600;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.config-option-help-popover-body .option-help dt:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.config-option-help-popover-body .option-help dd {
|
||||
margin-left: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.1));
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.config-option-help-popover-body .option-help dd p {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.config-option-help-popover-body .option-help dd p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.config-option-help-popover-body .option-help b {
|
||||
font-weight: 600;
|
||||
}
|
||||
.config-option-help-popover-body .option-help a {
|
||||
color: var(--bs-link-color, #0d6efd);
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject IEffectiveConfigSaveService SaveService
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
@if (ShowModal)
|
||||
{
|
||||
<EffectiveConfigSaveModal
|
||||
PendingChanges="@PendingChanges"
|
||||
Status="@Status"
|
||||
Session="@Session"
|
||||
SaveState="@_saveState"
|
||||
SaveResult="@_saveResult"
|
||||
RestoreResult="@_restoreResult"
|
||||
OnClose="@OnClose"
|
||||
OnContinueEditing="@(EventCallback.Factory.Create(this, HandleContinueEditing))"
|
||||
OnSave="@HandleSave"
|
||||
OnRestore="@HandleRestore"
|
||||
OnReverted="@HandleReverted"
|
||||
OnCloseAndEndEdit="@HandleCloseAndEndEdit" />
|
||||
}
|
||||
|
||||
@code {
|
||||
public enum SaveUiState
|
||||
{
|
||||
Review,
|
||||
Saving,
|
||||
WriteFailed,
|
||||
ValidateFailed,
|
||||
ReloadFailed,
|
||||
RestoreInProgress,
|
||||
RestoreFailed
|
||||
}
|
||||
|
||||
[Parameter] public bool ShowModal { get; set; }
|
||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
/// <summary>Invoked when the modal reverts a change so the parent can re-run validation (e.g. cross-option evaluator).</summary>
|
||||
[Parameter] public EventCallback OnRevertedNotify { get; set; }
|
||||
[Parameter] public EventCallback OnSaveCompleted { get; set; }
|
||||
[Parameter] public EventCallback OnContinueEditingAfterSavedFailure { get; set; }
|
||||
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
|
||||
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
||||
|
||||
private SaveUiState _saveState = SaveUiState.Review;
|
||||
private EffectiveConfigSaveResult? _saveResult;
|
||||
private EffectiveConfigRestoreResult? _restoreResult;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ShowModal)
|
||||
{
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleReverted()
|
||||
{
|
||||
if (OnRevertedNotify.HasDelegate)
|
||||
await OnRevertedNotify.InvokeAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleSave()
|
||||
{
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
if (Session.HasBlockingValidationErrors())
|
||||
{
|
||||
NotificationService.ShowError("Fix validation errors before saving.");
|
||||
return;
|
||||
}
|
||||
|
||||
_saveState = SaveUiState.Saving;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
var result = await Session.ApplyAsync();
|
||||
|
||||
_saveResult = result;
|
||||
if (result.Saved && result.Restarted)
|
||||
{
|
||||
var message = result.UserMessage ?? "Config saved and dnsmasq restarted.";
|
||||
NotificationService.ShowSuccess(message);
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
await OnClose.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Saved && result.IsValidateFailed)
|
||||
_saveState = SaveUiState.ValidateFailed;
|
||||
else if (result.Saved && !result.Restarted)
|
||||
_saveState = SaveUiState.ReloadFailed;
|
||||
else if (!result.Saved)
|
||||
_saveState = SaveUiState.WriteFailed;
|
||||
else
|
||||
_saveState = SaveUiState.Review;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleRestore(string backupPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(backupPath)) return;
|
||||
|
||||
_saveState = SaveUiState.RestoreInProgress;
|
||||
_restoreResult = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
var result = await SaveService.RestoreAsync(backupPath);
|
||||
|
||||
_restoreResult = result;
|
||||
if (result.Restored && result.Restarted)
|
||||
{
|
||||
var message = result.UserMessage ?? "Backup restored and dnsmasq restarted.";
|
||||
NotificationService.ShowSuccess(message);
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
await OnCloseAndEndEdit.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
_saveState = SaveUiState.RestoreFailed;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleContinueEditing()
|
||||
{
|
||||
if (_saveState == SaveUiState.ValidateFailed || _saveState == SaveUiState.ReloadFailed)
|
||||
{
|
||||
await OnContinueEditingAfterSavedFailure.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task HandleCloseAndEndEdit()
|
||||
{
|
||||
await OnCloseAndEndEdit.InvokeAsync();
|
||||
|
||||
if (_saveState == SaveUiState.ValidateFailed || _saveState == SaveUiState.ReloadFailed)
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
|
||||
<div class="modal fade show" style="display: block;" tabindex="-1" role="dialog" aria-labelledby="ec-save-modal-title" aria-modal="true">
|
||||
<div class="modal-dialog modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="ec-save-modal-title">Save effective config changes</h5>
|
||||
<button type="button" class="btn-close" @onclick="Close" aria-label="Close" disabled="@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)"></button>
|
||||
</div>
|
||||
<div class="modal-body ec-save-modal-body">
|
||||
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||
{
|
||||
<div class="ec-save-modal-loading text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div>
|
||||
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving, validating and restarting…" : "Restoring backup and restarting…")</p>
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed && SaveResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-reload-failed">
|
||||
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||
<div class="ec-save-modal-alert-text">
|
||||
<strong>Config was saved, but validation failed.</strong> Restart was not attempted. You can restore the backup or keep editing.
|
||||
</div>
|
||||
</div>
|
||||
@if (SaveResult.BackupPath != null)
|
||||
{
|
||||
<p class="ec-save-modal-backup-path mb-2">
|
||||
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
|
||||
</p>
|
||||
}
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<p class="text-muted small mb-1"><strong>Validation output</strong></p>
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetValidationFailedOutput(SaveResult)</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
|
||||
{
|
||||
<div class="alert alert-danger mb-0">
|
||||
<strong>Save failed.</strong> @SaveResult.UserMessage
|
||||
@if (!string.IsNullOrEmpty(SaveResult.RestartStdErr))
|
||||
{
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.RestartStdErr</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-reload-failed">
|
||||
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||
<div class="ec-save-modal-alert-text">
|
||||
<strong>Config was saved, but the restart command failed.</strong> You can restore the backup or keep editing.
|
||||
</div>
|
||||
</div>
|
||||
@if (SaveResult.BackupPath != null)
|
||||
{
|
||||
<p class="ec-save-modal-backup-path mb-2">
|
||||
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
|
||||
</p>
|
||||
}
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<p class="text-muted small mb-1"><strong>Command output</strong></p>
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestartFailedOutput(SaveResult)</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed && RestoreResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-restore-failed">
|
||||
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||
<div class="ec-save-modal-alert-text">@RestoreResult.UserMessage</div>
|
||||
</div>
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<p class="text-muted small mb-1"><strong>Command output</strong></p>
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestoreFailedOutput(RestoreResult)</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (ValidationErrors.Count > 0 || ValidationWarnings.Count > 0)
|
||||
{
|
||||
<div class="ec-save-modal-validation mb-3">
|
||||
@if (ValidationErrors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger py-2 mb-2">
|
||||
<strong>Validation errors</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
@foreach (var issue in ValidationErrors)
|
||||
{
|
||||
<li>@issue.Message</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (ValidationWarnings.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning py-2 mb-2">
|
||||
<strong>Validation warnings</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
@foreach (var issue in ValidationWarnings)
|
||||
{
|
||||
<li>@issue.Message</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
|
||||
@if (ManagedFilePath != null)
|
||||
{
|
||||
<p class="ec-save-modal-target mb-3">
|
||||
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
|
||||
</p>
|
||||
}
|
||||
@if (PendingChanges.Count == 0)
|
||||
{
|
||||
<div class="ec-save-modal-empty alert alert-light border text-center text-muted py-4 mb-0">
|
||||
No pending changes. Close to continue editing.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="ec-save-modal-changes list-group list-group-flush">
|
||||
@foreach (var c in PendingChanges)
|
||||
{
|
||||
var change = c;
|
||||
var oldList = AsStringList(change.OldValue);
|
||||
var newList = AsStringList(change.NewValue);
|
||||
var isMultiValue = oldList != null || newList != null;
|
||||
<div class="list-group-item ec-save-modal-item d-flex align-items-start gap-3 py-3 px-0 border-0 border-bottom">
|
||||
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="@change.OptionName" Class="ec-save-modal-option me-2" />
|
||||
@if (!isMultiValue)
|
||||
{
|
||||
<span class="ec-save-modal-diff text-body">
|
||||
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue, change.OptionName)</span>
|
||||
<span class="text-muted mx-1" aria-hidden="true">→</span>
|
||||
<span>@FormatValue(change.NewValue, change.OptionName)</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (isMultiValue)
|
||||
{
|
||||
var oldSeq = oldList ?? Array.Empty<string>();
|
||||
var newSeq = newList ?? Array.Empty<string>();
|
||||
var added = newSeq.Except(oldSeq).ToList();
|
||||
var removed = oldSeq.Except(newSeq).ToList();
|
||||
var orderOrDuplicatesChanged = added.Count == 0 && removed.Count == 0 && !oldSeq.SequenceEqual(newSeq, StringComparer.Ordinal);
|
||||
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
|
||||
@if (orderOrDuplicatesChanged)
|
||||
{
|
||||
<div class="text-muted">Order or duplicates changed:</div>
|
||||
<div class="text-muted text-decoration-line-through">Before: @string.Join(", ", oldSeq)</div>
|
||||
<div class="text-success">After: @string.Join(", ", newSeq)</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var val in removed)
|
||||
{
|
||||
<div class="text-muted text-decoration-line-through">− @val</div>
|
||||
}
|
||||
@foreach (var val in added)
|
||||
{
|
||||
<div class="text-success">+ @val</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (change.CurrentSourceFilePath != null)
|
||||
{
|
||||
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="() => Revert(change)" title="Revert this change" aria-label="Revert @change.OptionName">
|
||||
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer ec-save-modal-footer">
|
||||
@if ((SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed) && SaveResult?.BackupPath != null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backup</button>
|
||||
<button type="button" class="btn btn-outline-warning" @onclick="ContinueEditing"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||
}
|
||||
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||
{
|
||||
<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="@(PendingChanges.Count == 0 || HasBlockingValidationErrors)"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" style="display: block;" @onclick="OnBackdropClick"></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
||||
[Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; }
|
||||
[Parameter] public EffectiveConfigSaveResult? SaveResult { get; set; }
|
||||
[Parameter] public EffectiveConfigRestoreResult? RestoreResult { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter] public EventCallback OnContinueEditing { get; set; }
|
||||
[Parameter] public EventCallback OnSave { get; set; }
|
||||
[Parameter] public EventCallback<string> OnRestore { get; set; }
|
||||
[Parameter] public EventCallback OnReverted { get; set; }
|
||||
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
|
||||
|
||||
private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress;
|
||||
|
||||
private string? ManagedFilePath => Status?.ManagedFilePath;
|
||||
|
||||
private IReadOnlyList<FieldIssue> ValidationErrors => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
|
||||
private IReadOnlyList<FieldIssue> ValidationWarnings => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Warning).ToList();
|
||||
private bool HasBlockingValidationErrors => Session.HasBlockingValidationErrors();
|
||||
|
||||
private static string GetValidationFailedOutput(EffectiveConfigSaveResult result)
|
||||
{
|
||||
var err = (result.ValidationStdErr ?? "").Trim();
|
||||
var outp = (result.ValidationStdOut ?? "").Trim();
|
||||
if (err.Length > 0 && outp.Length > 0)
|
||||
return err + "\n\n" + outp;
|
||||
if (err.Length > 0) return err;
|
||||
if (outp.Length > 0) return outp;
|
||||
var exitInfo = result.ValidationExitCode >= 0 ? $"Exit code: {result.ValidationExitCode}." : "";
|
||||
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
|
||||
}
|
||||
|
||||
private static string GetRestartFailedOutput(EffectiveConfigSaveResult result)
|
||||
{
|
||||
var err = (result.RestartStdErr ?? "").Trim();
|
||||
var outp = (result.RestartStdOut ?? "").Trim();
|
||||
if (err.Length > 0 && outp.Length > 0)
|
||||
return err + "\n\n" + outp;
|
||||
if (err.Length > 0) return err;
|
||||
if (outp.Length > 0) return outp;
|
||||
var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : "";
|
||||
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
|
||||
}
|
||||
|
||||
private static string GetRestoreFailedOutput(EffectiveConfigRestoreResult result)
|
||||
{
|
||||
var err = (result.RestartStdErr ?? "").Trim();
|
||||
if (err.Length > 0) return err;
|
||||
var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : "";
|
||||
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
|
||||
}
|
||||
|
||||
/// <summary>If the value is a list of strings (multi-value option), return it; otherwise null.</summary>
|
||||
private static IReadOnlyList<string>? AsStringList(object? v)
|
||||
{
|
||||
if (v == null) return null;
|
||||
if (v is IReadOnlyList<string> ro) return ro;
|
||||
if (v is List<string> list) return list;
|
||||
if (v is string[] arr) return arr;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatValue(object? v, string? optionName = null)
|
||||
{
|
||||
if (optionName != null && EffectiveConfigWriteSemantics.GetBehavior(optionName) == EffectiveConfigWriteBehavior.KeyOnlyOrValue)
|
||||
return FormatKeyOnlyOrValue(v);
|
||||
if (v == null) return "(not set)";
|
||||
if (v is bool b) return b ? "on" : "off";
|
||||
var list = AsStringList(v);
|
||||
if (list != null) return list.Count == 0 ? "(none)" : string.Join(", ", list);
|
||||
return v.ToString() ?? "(not set)";
|
||||
}
|
||||
|
||||
private static string FormatKeyOnlyOrValue(object? v)
|
||||
{
|
||||
if (v == null) return "(not set)";
|
||||
var s = v.ToString();
|
||||
if (string.IsNullOrEmpty(s)) return "(set)";
|
||||
return s;
|
||||
}
|
||||
|
||||
private async Task Revert(PendingEffectiveConfigChange change)
|
||||
{
|
||||
Session.RevertChange(change.SectionId, change.OptionName);
|
||||
await OnReverted.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnBackdropClick()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await Close();
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task ContinueEditing()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await OnContinueEditing.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
await OnSave.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Restore(string backupPath)
|
||||
{
|
||||
await OnRestore.InvokeAsync(backupPath);
|
||||
}
|
||||
|
||||
private async Task CloseAndEndEdit()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await OnCloseAndEndEdit.InvokeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Keep footer buttons on one line */
|
||||
.ec-save-modal-footer {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Ensure the dialog stacks above the backdrop when both are siblings inside .modal */
|
||||
.modal-dialog {
|
||||
position: relative;
|
||||
z-index: 1056;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.ec-save-modal-body {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-target {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-changes {
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-item:last-child {
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ec-save-modal-option {
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-diff {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-override {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-empty {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-loading {
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-backup-path {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-backup-code {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Command output: ensure text is always visible (avoid inherited color/visibility issues) */
|
||||
.ec-save-modal-pre {
|
||||
font-size: 0.8125rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
color: var(--bs-body-color, #212529);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
/* Alert icon in reload/restore failed states: larger, spacing, aligned with text */
|
||||
.ec-save-modal-alert {
|
||||
--ec-save-modal-alert-icon-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-alert-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.25rem;
|
||||
margin-right: var(--ec-save-modal-alert-icon-gap);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ec-save-modal-alert-text {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<ConfigOptionHelpModal HelpKey="@HelpKey" AnchorId="@AnchorId" Visible="@Visible" Title="@Title"
|
||||
OnClose="RequestClose" OnMouseEnterModal="CancelCloseTimer" OnMouseLeaveModal="ScheduleClose" />
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public string? HelpKey { get; set; }
|
||||
[Parameter] public string? AnchorId { get; set; }
|
||||
[Parameter] public string Title { get; set; } = "Option help";
|
||||
[Parameter] public EventCallback OnCloseRequested { get; set; }
|
||||
|
||||
private CancellationTokenSource? _closeCts;
|
||||
|
||||
private void CancelCloseTimer()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = null;
|
||||
}
|
||||
|
||||
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(async () =>
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
_closeCts = null;
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
});
|
||||
}, token);
|
||||
}
|
||||
|
||||
private async Task RequestClose()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Called by the section when the label loses mouse (so we schedule close; if user moves to modal, OnMouseEnterModal cancels).</summary>
|
||||
public void ScheduleCloseFromLabelLeave()
|
||||
{
|
||||
ScheduleClose();
|
||||
}
|
||||
|
||||
/// <summary>Called when opening or switching help (e.g. user clicked another label); cancels any pending 300ms close so the timer from a previous label leave does not dismiss the new help.</summary>
|
||||
public void CancelCloseFromLabelEnter()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<span id="@AnchorId" class="@EffectiveCssClass" title="@Title" @onclick="OnClick" @onmouseleave="OnMouseLeave">
|
||||
@ChildContent
|
||||
</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string AnchorId { get; set; } = "";
|
||||
[Parameter] public string? HelpKey { get; set; }
|
||||
[Parameter] public string OptionLabel { get; set; } = "";
|
||||
[Parameter] public string? CssClass { get; set; }
|
||||
|
||||
private string EffectiveCssClass =>
|
||||
CssClass ?? (!string.IsNullOrEmpty(HelpKey) ? "ec-field-label ec-field-label-help" : "ec-field-label");
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private Task OnClick()
|
||||
{
|
||||
if (Ui == null || string.IsNullOrEmpty(HelpKey)) return Task.CompletedTask;
|
||||
return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs
|
||||
{
|
||||
HelpKey = HelpKey,
|
||||
OptionLabel = OptionLabel,
|
||||
AnchorId = AnchorId
|
||||
});
|
||||
}
|
||||
|
||||
private Task OnMouseLeave()
|
||||
{
|
||||
if (Ui == null) return Task.CompletedTask;
|
||||
return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs
|
||||
{
|
||||
AnchorId = AnchorId,
|
||||
IsLabelMouseLeave = true
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
@if (IsCapabilityDisabled)
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly"
|
||||
Text="disabled"
|
||||
Title="@CapabilityDisabledReason"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
else if (Source?.IsReadOnly == true)
|
||||
{
|
||||
var readonlyTitle = !string.IsNullOrEmpty(ReadonlyTitleOverride) ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip();
|
||||
if (ReadonlyClickActivatesField && Ui != null && !string.IsNullOrEmpty(FieldKey))
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
|
||||
Id="@AnchorId" Title="@readonlyTitle"
|
||||
OnClick="OnEditableClick"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
else if (IsInteractive && Ui != null)
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
|
||||
Id="@AnchorId" Title="@readonlyTitle"
|
||||
OnClick="OnReadonlyClick" OnMouseLeave="OnReadonlyMouseLeave"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly"
|
||||
Title="@readonlyTitle"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
}
|
||||
else if (ShowEditableBadge)
|
||||
{
|
||||
@if (EditableAsButton)
|
||||
{
|
||||
@if (IsActiveEditor)
|
||||
{
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="ok" Clickable="true"
|
||||
Title="Apply this field change" AriaLabel="Apply change"
|
||||
PreventBlurOnMouseDown="true" OnClick="OnConfirmClick" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard in-progress edits for this field" AriaLabel="Cancel edit"
|
||||
PreventBlurOnMouseDown="true" OnClick="OnCancelClick" />
|
||||
@if (HasPendingChange)
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="revert" Clickable="true"
|
||||
Title="Remove this field from pending changes" AriaLabel="Revert pending change"
|
||||
PreventBlurOnMouseDown="true" OnClick="OnRevertClick" />
|
||||
}
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusBadge Kind="@(HasPendingChange ? StatusBadgeKind.Pending : StatusBadgeKind.Editable)"
|
||||
Text="@(HasPendingChange ? "pending" : "editable")"
|
||||
Clickable="true"
|
||||
Title="@(HasPendingChange ? "Pending change" : "Edit this field")"
|
||||
AriaLabel="Edit"
|
||||
OnClick="OnEditableClick"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.Editable" Text="editable"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string AnchorId { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public object? Value { get; set; }
|
||||
[Parameter] public bool IsInteractive { get; set; }
|
||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
||||
[Parameter] public bool HasPendingChange { get; set; }
|
||||
[Parameter] public bool EditableAsButton { get; set; } = true;
|
||||
[Parameter] public bool IsActiveEditor { get; set; }
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
[Parameter] public string? FieldKey { get; set; }
|
||||
/// <summary>Optional: when set, editable-badge click invokes this instead of activating the field by key (e.g. list editor per-item edit).</summary>
|
||||
[Parameter] public EventCallback OnEditableClickRequested { get; set; }
|
||||
/// <summary>When true, readonly badge click activates the field for editing instead of showing the readonly popover (e.g. for multi-value where new items can still be added).</summary>
|
||||
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
|
||||
/// <summary>Optional tooltip for readonly badge when <see cref="ReadonlyClickActivatesField"/> is true (e.g. "New values can still be added. Click to edit.").</summary>
|
||||
[Parameter] public string? ReadonlyTitleOverride { get; set; }
|
||||
/// <summary>When true, option is disabled because dnsmasq build does not support it (e.g. no DNSSEC).</summary>
|
||||
[Parameter] public bool IsCapabilityDisabled { get; set; }
|
||||
/// <summary>Tooltip shown when <see cref="IsCapabilityDisabled"/> is true (e.g. "This dnsmasq build does not support DNSSEC; option not written.").</summary>
|
||||
[Parameter] public string? CapabilityDisabledReason { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
|
||||
|
||||
private async Task OnConfirmClick()
|
||||
{
|
||||
if (OnConfirmRequested.HasDelegate)
|
||||
await OnConfirmRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnCancelClick()
|
||||
{
|
||||
if (OnCancelRequested.HasDelegate)
|
||||
await OnCancelRequested.InvokeAsync();
|
||||
else if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private async Task OnRevertClick()
|
||||
{
|
||||
if (OnRevertRequested.HasDelegate)
|
||||
await OnRevertRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnEditableClick()
|
||||
{
|
||||
if (OnEditableClickRequested.HasDelegate)
|
||||
{
|
||||
await OnEditableClickRequested.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
if (Ui != null && !string.IsNullOrEmpty(FieldKey))
|
||||
await Ui.ActivateFieldAsync(FieldKey);
|
||||
}
|
||||
|
||||
private Task OnReadonlyClick()
|
||||
{
|
||||
if (Ui == null || Source == null) return Task.CompletedTask;
|
||||
return Ui.RequestReadonlyPopoverAsync(new ReadonlyBadgeClickedEventArgs(AnchorId, Source, OptionName, Value));
|
||||
}
|
||||
|
||||
private Task OnReadonlyMouseLeave()
|
||||
{
|
||||
if (Ui == null) return Task.CompletedTask;
|
||||
return Ui.ScheduleReadonlyPopoverCloseAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
|
||||
@if (Source is { IsReadOnly: true })
|
||||
{
|
||||
<div class="ec-readonly-hint small text-muted mt-1">
|
||||
<span class="d-block">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</span>
|
||||
@if (!string.IsNullOrEmpty(_removeCommand))
|
||||
{
|
||||
<div class="mt-1">
|
||||
<CopyableCodeLine Text="@_removeCommand" CssClass="small" CodeCssClass="small text-break" CopyTitle="Copy command" />
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(_overrideLine))
|
||||
{
|
||||
<span class="d-block mt-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</span>
|
||||
<div class="mt-1">
|
||||
<CopyableCodeLine Text="@_overrideLine" CodeCssClass="small" CopyTitle="Copy line" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public object? Value { get; set; }
|
||||
[Parameter] public string? ManagedFilePath { get; set; }
|
||||
|
||||
private string _removeCommand = "";
|
||||
private string _overrideLine = "";
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Source is { IsReadOnly: true })
|
||||
{
|
||||
_removeCommand = ReadonlyConfigCommandGenerator.GetRemoveLineCommand(Source, OptionName);
|
||||
_overrideLine = ReadonlyConfigCommandGenerator.GetOverrideLine(OptionName, Value, ManagedFilePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_removeCommand = "";
|
||||
_overrideLine = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Extensions
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.JSInterop
|
||||
@using System.Text.Json.Serialization
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (Visible && Source != null)
|
||||
{
|
||||
<div class="ec-readonly-popover" id="@_popoverId" style="@_positionStyle" @onmouseenter="OnPopoverMouseEnter" @onmouseleave="OnPopoverMouseLeave">
|
||||
<div class="ec-readonly-popover-content">
|
||||
<div class="ec-readonly-popover-header">
|
||||
<span class="ec-readonly-popover-title">Read-only: @OptionName</span>
|
||||
</div>
|
||||
<div class="ec-readonly-popover-body small">
|
||||
<p class="text-muted mb-2">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</p>
|
||||
@if (!string.IsNullOrEmpty(_removeCommand))
|
||||
{
|
||||
<div class="mb-2">
|
||||
<CopyableCodeLine Text="@_removeCommand" CodeCssClass="small" CopyTitle="Copy command" />
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(_overrideLine))
|
||||
{
|
||||
<p class="text-muted mb-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</p>
|
||||
<CopyableCodeLine Text="@_overrideLine" CodeCssClass="small" CopyTitle="Copy line" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public string? AnchorId { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public object? Value { get; set; }
|
||||
[Parameter] public string? ManagedFilePath { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter] public EventCallback OnMouseEnterPopover { get; set; }
|
||||
[Parameter] public EventCallback OnMouseLeavePopover { get; set; }
|
||||
|
||||
private const string HiddenPositionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
|
||||
private const string VisiblePositionStyleFormat = "position: fixed; top: {0}px; left: {1}px; visibility: visible;";
|
||||
|
||||
private string _positionStyle = "";
|
||||
private readonly string _popoverId = "ec-readonly-popover-" + Guid.NewGuid().ToString("N")[..8];
|
||||
private DotNetObjectReference<ReadonlyEditPopover>? _dotNetRef;
|
||||
private bool _scrollListenerAdded;
|
||||
private bool _mouseOverPopover;
|
||||
private IJSObjectReference? _jsModule;
|
||||
private bool _moduleLoaded;
|
||||
private string _removeCommand = "";
|
||||
private string _overrideLine = "";
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Source is { IsReadOnly: true })
|
||||
{
|
||||
_removeCommand = ReadonlyConfigCommandGenerator.GetRemoveLineCommand(Source, OptionName);
|
||||
_overrideLine = ReadonlyConfigCommandGenerator.GetOverrideLine(OptionName, Value, ManagedFilePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_removeCommand = "";
|
||||
_overrideLine = "";
|
||||
}
|
||||
if (Visible && string.IsNullOrEmpty(_positionStyle))
|
||||
_positionStyle = HiddenPositionStyle;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
catch (JSDisconnectedException) { }
|
||||
catch (JSException) { }
|
||||
}
|
||||
|
||||
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = true;
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
await _jsModule.InvokeVoidAsyncSafe("addScrollCloseListener", _dotNetRef);
|
||||
}
|
||||
else if (!Visible && _scrollListenerAdded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
}
|
||||
|
||||
if (!Visible)
|
||||
_positionStyle = "";
|
||||
|
||||
if (Visible && _moduleLoaded && _jsModule != null)
|
||||
{
|
||||
await PositionUnderAnchorAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnScrollClose()
|
||||
{
|
||||
await PositionUnderAnchorAsync();
|
||||
StateHasChanged();
|
||||
if (!_mouseOverPopover)
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_scrollListenerAdded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
}
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
_jsModule = null;
|
||||
}
|
||||
|
||||
private async Task PositionUnderAnchorAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(AnchorId) || _jsModule == null) return;
|
||||
var rect = await _jsModule.InvokeAsyncSafe<BoundingRect?>("getAnchorRect", AnchorId);
|
||||
if (rect is { } r)
|
||||
{
|
||||
var gap = 4;
|
||||
var topPx = r.Bottom + gap;
|
||||
var leftPx = r.Left;
|
||||
_positionStyle = string.Format(VisiblePositionStyleFormat, topPx, leftPx);
|
||||
}
|
||||
else
|
||||
{
|
||||
_positionStyle = HiddenPositionStyle;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnPopoverMouseEnter()
|
||||
{
|
||||
_mouseOverPopover = true;
|
||||
await OnMouseEnterPopover.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnPopoverMouseLeave()
|
||||
{
|
||||
_mouseOverPopover = false;
|
||||
await OnMouseLeavePopover.InvokeAsync();
|
||||
}
|
||||
|
||||
private sealed record BoundingRect(
|
||||
[property: JsonPropertyName("top")] double Top,
|
||||
[property: JsonPropertyName("left")] double Left,
|
||||
[property: JsonPropertyName("bottom")] double Bottom,
|
||||
[property: JsonPropertyName("width")] double Width,
|
||||
[property: JsonPropertyName("height")] double Height);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Readonly edit popover: anchored under the badge; same look as option help popover */
|
||||
.ec-readonly-popover {
|
||||
position: fixed;
|
||||
z-index: 1060;
|
||||
max-width: min(420px, calc(100vw - 1rem));
|
||||
max-height: min(70vh, 400px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
background: var(--bs-body-bg, #fff);
|
||||
}
|
||||
|
||||
.ec-readonly-popover-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ec-readonly-popover-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ec-readonly-popover-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-readonly-popover-body {
|
||||
padding: 0.75rem;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<ReadonlyEditPopover Visible="@Visible" AnchorId="@AnchorId" Source="@Source" OptionName="@OptionName" Value="@Value" ManagedFilePath="@ManagedFilePath"
|
||||
OnClose="RequestClose" OnMouseEnterPopover="CancelCloseTimer" OnMouseLeavePopover="ScheduleClose" />
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public string? AnchorId { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public object? Value { get; set; }
|
||||
[Parameter] public string? ManagedFilePath { get; set; }
|
||||
[Parameter] public EventCallback OnCloseRequested { get; set; }
|
||||
|
||||
private CancellationTokenSource? _closeCts;
|
||||
|
||||
private void CancelCloseTimer()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = null;
|
||||
}
|
||||
|
||||
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(async () =>
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
_closeCts = null;
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
});
|
||||
}, token);
|
||||
}
|
||||
|
||||
private async Task RequestClose()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when mouse leaves readonly badge; close unless pointer enters popover quickly.
|
||||
/// </summary>
|
||||
public void ScheduleCloseFromBadgeLeave()
|
||||
{
|
||||
ScheduleClose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when readonly badge is interacted with to keep popover open.
|
||||
/// </summary>
|
||||
public void CancelCloseFromBadgeEnter()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@
|
||||
@Path
|
||||
@if (IsEditable)
|
||||
{
|
||||
<span class="badge bg-primary ms-1">editable</span>
|
||||
<StatusBadge Kind="StatusBadgeKind.Editable" Text="editable" Class="ms-1" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary ms-1"> read-only</span>
|
||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="read-only" Class="ms-1" />
|
||||
}
|
||||
</h3>
|
||||
<div class="table-responsive-cards">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@inherits LayoutComponentBase
|
||||
@namespace DnsmasqWebUI.Components.Layout
|
||||
@using DnsmasqWebUI.Components.Shared
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@implements IDisposable
|
||||
|
||||
@@ -17,6 +18,7 @@
|
||||
</div>
|
||||
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
<NotificationHost />
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
|
||||
@@ -29,6 +29,8 @@ main.main {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #052767 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
@namespace DnsmasqWebUI.Components.Layout
|
||||
@using System.Reflection
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@inject IUpdateCheckService UpdateCheckService
|
||||
@inject INotificationService NotificationService
|
||||
@implements IDisposable
|
||||
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="top-row-inner">
|
||||
@@ -34,11 +39,96 @@
|
||||
<i class="bi bi-hdd-network nav-icon" aria-hidden="true"></i> DHCP
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="dns-records">
|
||||
<i class="bi bi-diagram-3-fill nav-icon" aria-hidden="true"></i> DNS records
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="filters">
|
||||
<i class="bi bi-funnel-fill nav-icon" aria-hidden="true"></i> Filters
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="nav-footer px-3 py-2" aria-label="Links and version">
|
||||
<a class="nav-footer-link" href="https://github.com/alexhopeoconnor/dnsmasq-webui" target="_blank" rel="noopener noreferrer" title="dnsmasq-webui project on GitHub">Project</a>
|
||||
<span class="nav-footer-sep" aria-hidden="true">·</span>
|
||||
<a class="nav-footer-link" href="https://thekelleys.org.uk/dnsmasq/doc.html" target="_blank" rel="noopener noreferrer" title="Official dnsmasq documentation">Docs</a>
|
||||
<span class="nav-footer-sep" aria-hidden="true">·</span>
|
||||
<a class="nav-footer-link" href="@ReleaseUrl" target="_blank" rel="noopener noreferrer" title="View release on GitHub">v@(BaseVersion(Version))</a>
|
||||
<span class="nav-footer-version-actions" aria-hidden="true">[
|
||||
@if (UpdateCheckService.NewerVersionAvailable)
|
||||
{
|
||||
<a class="nav-footer-link nav-footer-new-release" href="@UpdateCheckService.NewerVersionUrl" target="_blank" rel="noopener noreferrer" title="New version @(UpdateCheckService.NewerVersionTag ?? "") is available" aria-label="New version @(UpdateCheckService.NewerVersionTag ?? "") is available">
|
||||
<i class="bi bi-download" aria-hidden="true"></i>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="nav-footer-link nav-footer-check-btn" @onclick="ForceCheckAsync" disabled="@UpdateCheckService.CheckInProgress" title="@GetCheckButtonTooltip()" aria-label="@GetCheckButtonTooltip()">
|
||||
<i class="bi bi-arrow-repeat nav-footer-check-icon @(UpdateCheckService.CheckInProgress ? "nav-footer-spin" : null)" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
]</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private const string ReleasesBaseUrl = "https://github.com/alexhopeoconnor/dnsmasq-webui/releases";
|
||||
private static readonly string Version = typeof(NavMenu).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "?";
|
||||
|
||||
/// <summary>Base version without build metadata (e.g. 0.0.4+abc123 -> 0.0.4) for GitHub tag URLs.</summary>
|
||||
private static string BaseVersion(string v)
|
||||
{
|
||||
if (string.IsNullOrEmpty(v) || v == "?") return v;
|
||||
var i = v.IndexOf('+');
|
||||
return i >= 0 ? v[..i] : v;
|
||||
}
|
||||
private string ReleaseUrl => Version != "?"
|
||||
? $"{ReleasesBaseUrl}/tag/v{BaseVersion(Version)}"
|
||||
: ReleasesBaseUrl;
|
||||
private string? AppTitle => AppOptions?.Value?.EffectiveTitle;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
UpdateCheckService.ResultChanged += OnUpdateCheckResultChanged;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UpdateCheckService.ResultChanged -= OnUpdateCheckResultChanged;
|
||||
}
|
||||
|
||||
private void OnUpdateCheckResultChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_ = InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private string GetCheckButtonTooltip()
|
||||
{
|
||||
if (UpdateCheckService.CheckInProgress)
|
||||
return "Checking…";
|
||||
var last = UpdateCheckService.LastCheckTime;
|
||||
if (last.HasValue)
|
||||
{
|
||||
var local = last.Value.ToLocalTime();
|
||||
var format = local.Date == DateTime.Now.Date ? "t" : "g"; // time only today, short date+time otherwise
|
||||
return $"No new update available. Checked at {local.ToString(format)}. Check again.";
|
||||
}
|
||||
return "Check for updates";
|
||||
}
|
||||
|
||||
private async Task ForceCheckAsync()
|
||||
{
|
||||
await UpdateCheckService.CheckNowAsync();
|
||||
if (UpdateCheckService.LastCheckFailed)
|
||||
NotificationService.ShowError("Update check failed: " + (UpdateCheckService.LastCheckErrorMessage ?? "Unknown error."));
|
||||
else if (UpdateCheckService.NewerVersionAvailable)
|
||||
NotificationService.ShowSuccess($"New version {UpdateCheckService.NewerVersionTag} is available.");
|
||||
else
|
||||
NotificationService.ShowInfo("You're up to date.");
|
||||
}
|
||||
|
||||
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
|
||||
}
|
||||
|
||||
@@ -131,13 +131,86 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-footer {
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8125rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nav-footer-link {
|
||||
color: rgba(255, 255, 255, 0.65) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-footer-link:hover {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.nav-footer-sep {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
margin: 0 0.35rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-footer-version-actions {
|
||||
margin-left: 0.15rem;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.nav-footer-version-actions .nav-footer-link,
|
||||
.nav-footer-version-actions .nav-footer-check-btn {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.nav-footer-new-release {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-footer-new-release:hover {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.nav-footer-check-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.nav-footer-check-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.nav-footer-check-btn:hover:not(:disabled) {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.nav-footer-check-icon {
|
||||
display: inline-block; /* required for transform animation */
|
||||
}
|
||||
|
||||
.nav-footer-spin {
|
||||
animation: nav-footer-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes nav-footer-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Toggler is inside top-row; nav-scrollable is sibling of top-row */
|
||||
.top-row:has(.navbar-toggler:checked) ~ .nav-scrollable {
|
||||
display: block;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
@@ -146,11 +219,10 @@
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
/* Never collapse the sidebar for wide screens; flex to fill remaining sidebar height */
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,87 +28,126 @@
|
||||
@if (SettingsContext == SettingsModalContext.All)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Search settings</label>
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<label class="form-label mb-0">Search settings</label>
|
||||
<div class="d-flex gap-1 ms-auto" role="group" aria-label="Expand or collapse all sections">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary settings-expand-collapse-btn" title="Expand all sections" aria-label="Expand all sections" @onclick="ExpandAllGroups">
|
||||
<i class="bi bi-arrows-expand" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary settings-expand-collapse-btn" title="Collapse all sections" aria-label="Collapse all sections" @onclick="CollapseAllGroups">
|
||||
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="search" class="form-control" placeholder="Search..." autocomplete="off"
|
||||
@bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
|
||||
</div>
|
||||
}
|
||||
@if (ShouldShowSection(SettingsModalSections.ServiceStatus))
|
||||
@foreach (var group in GetVisibleGroups())
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="status-interval" type="number" class="form-control" min="@ClientSettingsFields.ServiceStatusPollingInterval.Min" max="@ClientSettingsFields.ServiceStatusPollingInterval.Max" step="1"
|
||||
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection(SettingsModalSections.Logs))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="logs-interval" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsPollingInterval.Min" max="@ClientSettingsFields.RecentLogsPollingInterval.Max" step="1"
|
||||
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection(SettingsModalSections.AppLogs))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="app-logs-interval" type="number" class="form-control" min="@ClientSettingsFields.AppLogsPollingInterval.Min" max="@ClientSettingsFields.AppLogsPollingInterval.Max" step="1"
|
||||
@bind="_editingSettings.AppLogsPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection(SettingsModalSections.Leases))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="leases-interval" type="number" class="form-control" min="@ClientSettingsFields.LeasesPollingInterval.Min" max="@ClientSettingsFields.LeasesPollingInterval.Max" step="1"
|
||||
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection(SettingsModalSections.RecentLogsDisplay))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.RecentLogsDisplay];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<div class="mb-2">
|
||||
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
|
||||
<input id="recent-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsMaxLines.Min" max="@ClientSettingsFields.RecentLogsMaxLines.Max" step="1"
|
||||
@bind="_editingSettings.RecentLogsMaxLines" />
|
||||
var isAllSections = SettingsContext == SettingsModalContext.All;
|
||||
var isExpanded = isAllSections ? IsGroupExpanded(group) : true;
|
||||
<div class="settings-group">
|
||||
@if (isAllSections)
|
||||
{
|
||||
<button type="button" class="settings-group-header @(isExpanded ? "expanded" : "")"
|
||||
@onclick="() => ToggleGroup(group.Id)"
|
||||
aria-expanded="@isExpanded"
|
||||
aria-controls="group-@group.Id">
|
||||
<span class="settings-group-title">@group.DisplayName</span>
|
||||
<span class="settings-group-chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h6 class="settings-group-title settings-group-title-static mb-2 mt-3">@group.DisplayName</h6>
|
||||
}
|
||||
<div id="group-@group.Id" class="settings-group-content @(isExpanded ? "expanded" : "")"
|
||||
role="region" aria-label="@group.DisplayName">
|
||||
@foreach (var key in group.SectionKeys)
|
||||
{
|
||||
@if (ShouldShowSection(key))
|
||||
{
|
||||
<div class="settings-section">
|
||||
@switch (key)
|
||||
{
|
||||
case SettingsModalSections.ServiceStatus:
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
|
||||
var field = ClientSettingsFields.ServiceStatusPollingInterval;
|
||||
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="status-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
|
||||
@bind="field.Value" />
|
||||
break;
|
||||
}
|
||||
case SettingsModalSections.Logs:
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
|
||||
var field = ClientSettingsFields.RecentLogsPollingInterval;
|
||||
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="logs-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
|
||||
@bind="field.Value" />
|
||||
break;
|
||||
}
|
||||
case SettingsModalSections.AppLogs:
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
|
||||
var field = ClientSettingsFields.AppLogsPollingInterval;
|
||||
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="app-logs-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
|
||||
@bind="field.Value" />
|
||||
break;
|
||||
}
|
||||
case SettingsModalSections.Leases:
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
|
||||
var field = ClientSettingsFields.LeasesPollingInterval;
|
||||
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="leases-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
|
||||
@bind="field.Value" />
|
||||
break;
|
||||
}
|
||||
case SettingsModalSections.RecentLogsDisplay:
|
||||
{
|
||||
var maxLinesField = ClientSettingsFields.RecentLogsMaxLines;
|
||||
var autoScrollField = ClientSettingsFields.RecentLogsAutoScroll;
|
||||
<div class="mb-2">
|
||||
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
|
||||
<input id="recent-logs-max-lines" type="number" class="form-control" min="@maxLinesField.Min" max="@maxLinesField.Max" step="1"
|
||||
@bind="maxLinesField.Value" />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="autoScrollField.Value" />
|
||||
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
case SettingsModalSections.AppLogsDisplay:
|
||||
{
|
||||
var maxLinesField = ClientSettingsFields.AppLogsMaxLines;
|
||||
var autoScrollField = ClientSettingsFields.AppLogsAutoScroll;
|
||||
<div class="mb-2">
|
||||
<label for="app-logs-max-lines" class="form-label">Max lines</label>
|
||||
<input id="app-logs-max-lines" type="number" class="form-control" min="@maxLinesField.Min" max="@maxLinesField.Max" step="1"
|
||||
@bind="maxLinesField.Value" />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="autoScrollField.Value" />
|
||||
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.RecentLogsAutoScroll" />
|
||||
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection(SettingsModalSections.AppLogsDisplay))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.AppLogsDisplay];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<div class="mb-2">
|
||||
<label for="app-logs-max-lines" class="form-label">Max lines</label>
|
||||
<input id="app-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.AppLogsMaxLines.Min" max="@ClientSettingsFields.AppLogsMaxLines.Max" step="1"
|
||||
@bind="_editingSettings.AppLogsMaxLines" />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.AppLogsAutoScroll" />
|
||||
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save">Save</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"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Extensions.Interop;
|
||||
using DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
@@ -15,9 +16,9 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
private ElementReference _dialogRef;
|
||||
private IJSObjectReference? _jsModule;
|
||||
private DotNetObjectReference<SettingsModal>? _dotNetRef;
|
||||
private ClientSettings _editingSettings = new();
|
||||
private List<string> _validationErrors = new();
|
||||
private string _searchTerm = string.Empty;
|
||||
private HashSet<string> _expandedGroupIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _moduleLoaded;
|
||||
private bool _dialogInitialized;
|
||||
private bool _wasVisible;
|
||||
@@ -40,19 +41,12 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
if (justOpened)
|
||||
{
|
||||
_searchTerm = string.Empty;
|
||||
_expandedGroupIds.Clear();
|
||||
var visible = SettingsModalSections.Groups.Where(g => SettingsModalSections.GroupMatchesContextOrSearch(g, SettingsContext, _searchTerm)).ToList();
|
||||
if (visible.Count > 0)
|
||||
_expandedGroupIds.Add(visible[0].Id);
|
||||
_validationErrors.Clear();
|
||||
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_editingSettings = new ClientSettings
|
||||
{
|
||||
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
|
||||
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds,
|
||||
AppLogsPollingIntervalSeconds = _editingSettings.AppLogsPollingIntervalSeconds,
|
||||
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds,
|
||||
RecentLogsMaxLines = _editingSettings.RecentLogsMaxLines,
|
||||
RecentLogsAutoScroll = _editingSettings.RecentLogsAutoScroll,
|
||||
AppLogsMaxLines = _editingSettings.AppLogsMaxLines,
|
||||
AppLogsAutoScroll = _editingSettings.AppLogsAutoScroll
|
||||
};
|
||||
await ClientSettingsService.LoadSettingsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +56,7 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
@@ -75,15 +69,48 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
{
|
||||
if (!_dialogInitialized)
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("initDialog", _dialogRef, _dotNetRef);
|
||||
_dialogInitialized = true;
|
||||
}
|
||||
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("showModal", _dialogRef);
|
||||
}
|
||||
}
|
||||
|
||||
private void FilterSettings() => StateHasChanged();
|
||||
|
||||
private IEnumerable<SettingsModalSections.CollapsibleGroup> GetVisibleGroups() =>
|
||||
SettingsModalSections.Groups.Where(g => SettingsModalSections.GroupMatchesContextOrSearch(g, SettingsContext, _searchTerm));
|
||||
|
||||
private bool IsGroupExpanded(SettingsModalSections.CollapsibleGroup group)
|
||||
{
|
||||
var hasActiveFilter = SettingsContext == SettingsModalContext.All && !string.IsNullOrWhiteSpace(_searchTerm);
|
||||
if (hasActiveFilter)
|
||||
return true;
|
||||
return _expandedGroupIds.Contains(group.Id);
|
||||
}
|
||||
|
||||
private void ToggleGroup(string groupId)
|
||||
{
|
||||
if (_expandedGroupIds.Contains(groupId))
|
||||
_expandedGroupIds.Remove(groupId);
|
||||
else
|
||||
_expandedGroupIds.Add(groupId);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ExpandAllGroups()
|
||||
{
|
||||
foreach (var g in GetVisibleGroups())
|
||||
_expandedGroupIds.Add(g.Id);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void CollapseAllGroups()
|
||||
{
|
||||
_expandedGroupIds.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private bool ShouldShowSection(string key)
|
||||
{
|
||||
var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext);
|
||||
@@ -95,18 +122,9 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
private void RunValidation()
|
||||
{
|
||||
_validationErrors.Clear();
|
||||
var checks = new (string Section, int Value, ClientSettingsFields.FieldBounds Bounds)[]
|
||||
foreach (var (section, field) in ClientSettingsFields.ValidationChecks)
|
||||
{
|
||||
(SettingsModalSections.ServiceStatus, _editingSettings.ServiceStatusPollingIntervalSeconds, ClientSettingsFields.ServiceStatusPollingInterval),
|
||||
(SettingsModalSections.Logs, _editingSettings.RecentLogsPollingIntervalSeconds, ClientSettingsFields.RecentLogsPollingInterval),
|
||||
(SettingsModalSections.AppLogs, _editingSettings.AppLogsPollingIntervalSeconds, ClientSettingsFields.AppLogsPollingInterval),
|
||||
(SettingsModalSections.Leases, _editingSettings.LeasesPollingIntervalSeconds, ClientSettingsFields.LeasesPollingInterval),
|
||||
(SettingsModalSections.RecentLogsDisplay, _editingSettings.RecentLogsMaxLines, ClientSettingsFields.RecentLogsMaxLines),
|
||||
(SettingsModalSections.AppLogsDisplay, _editingSettings.AppLogsMaxLines, ClientSettingsFields.AppLogsMaxLines),
|
||||
};
|
||||
foreach (var (section, value, bounds) in checks)
|
||||
{
|
||||
if (ShouldShowSection(section) && bounds.Validate(value) is { } err)
|
||||
if (ShouldShowSection(section) && field.Validate() is { } err)
|
||||
_validationErrors.Add(err);
|
||||
}
|
||||
}
|
||||
@@ -120,14 +138,16 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
await ClientSettingsService.SaveSettingsAsync(_editingSettings);
|
||||
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
|
||||
await ClientSettingsService.SaveSettingsAsync(ClientSettingsFields.ToDto());
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -143,14 +163,8 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_jsModule != null)
|
||||
{
|
||||
try { await _jsModule.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync failed"); }
|
||||
_jsModule = null;
|
||||
}
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
_jsModule = null;
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,98 @@
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.settings-expand-collapse-btn {
|
||||
padding: 0.25rem 0.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-expand-collapse-btn .bi {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.settings-modal-dialog .modal-footer {
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.125);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Collapsible section groups */
|
||||
.settings-group {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-group-header:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.settings-group-header.expanded {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.settings-group-chevron {
|
||||
flex-shrink: 0;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
border-right: 2px solid #6c757d;
|
||||
border-bottom: 2px solid #6c757d;
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-group-header.expanded .settings-group-chevron {
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
|
||||
.settings-group-title-static {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-group-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease-out;
|
||||
}
|
||||
|
||||
.settings-group-content.expanded {
|
||||
max-height: 500px;
|
||||
transition: max-height 0.35s ease-in;
|
||||
}
|
||||
|
||||
.settings-group-content > * {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-section + .settings-section {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user