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 |
+35
-4
@@ -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
|
||||
@@ -20,13 +28,36 @@ FROM build AS publish
|
||||
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
ARG DNSMASQ_VERSION=latest
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
# Slow, rarely-changing steps first (stay cached when only .NET code changes)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY scripts/entrypoint.sh 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.
|
||||
|
||||
+16
-4
@@ -14,8 +14,16 @@ 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:
|
||||
@@ -44,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:
|
||||
@@ -63,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:
|
||||
@@ -82,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:
|
||||
@@ -91,7 +102,8 @@ services:
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
testnet:
|
||||
|
||||
@@ -122,6 +122,7 @@ OPTION_KEYS = [
|
||||
"dhcp-hostsfile",
|
||||
"dhcp-optsfile",
|
||||
"dhcp-hostsdir",
|
||||
"dhcp-optsdir",
|
||||
"dhcp-boot",
|
||||
"dhcp-ignore",
|
||||
"dhcp-vendorclass",
|
||||
@@ -171,6 +172,59 @@ OPTION_KEYS = [
|
||||
"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)
|
||||
|
||||
@@ -15,8 +15,10 @@ 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
|
||||
@@ -38,6 +40,9 @@ 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."
|
||||
@@ -50,7 +55,10 @@ usage() {
|
||||
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 ""
|
||||
@@ -72,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 ""
|
||||
@@ -91,6 +102,35 @@ usage() {
|
||||
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
|
||||
@@ -111,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
|
||||
@@ -123,8 +169,14 @@ 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)
|
||||
@@ -170,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
|
||||
@@ -192,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
|
||||
@@ -220,9 +284,14 @@ if [ "$PREPARE_ONLY" = true ]; then
|
||||
/*) MOUNT_EXPORT="$MOUNT_DIR" ;;
|
||||
*) MOUNT_EXPORT="./$MOUNT_DIR" ;;
|
||||
esac
|
||||
START_CMD="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_CMD="TESTDATA_MOUNT=$MOUNT_EXPORT TEST_DNSMASQ_CONF=/data/dnsmasq-test-minimal.conf docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
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
|
||||
@@ -234,13 +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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsTrue()
|
||||
public void GetLastValueFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
@@ -291,8 +291,26 @@ public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dnssec-check-unsigned\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
Assert.True(result);
|
||||
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
|
||||
{
|
||||
|
||||
@@ -79,6 +79,137 @@ public class DnsmasqConfIncludeParserNewOptionsTests
|
||||
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]
|
||||
@@ -258,16 +389,27 @@ public class DnsmasqConfIncludeParserNewOptionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_Conntrack_WhenSet_ReturnsValue()
|
||||
public void GetFlagFromConfigFiles_Conntrack_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var value = "42";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"conntrack={value}\n");
|
||||
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack);
|
||||
Assert.Equal(value, result);
|
||||
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); }
|
||||
}
|
||||
@@ -284,6 +426,19 @@ public class DnsmasqConfIncludeParserNewOptionsTests
|
||||
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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,24 @@ public class EffectiveDnsmasqConfigTests
|
||||
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,
|
||||
@@ -80,7 +98,7 @@ public class EffectiveDnsmasqConfigTests
|
||||
TftpNoFail: false,
|
||||
TftpNoBlocksize: false,
|
||||
Dnssec: false,
|
||||
DnssecCheckUnsigned: false,
|
||||
DnssecCheckUnsigned: null,
|
||||
ReadEthers: false,
|
||||
DhcpRapidCommit: false,
|
||||
Localmx: false,
|
||||
@@ -90,6 +108,24 @@ public class EffectiveDnsmasqConfigTests
|
||||
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,
|
||||
@@ -120,7 +156,25 @@ public class EffectiveDnsmasqConfigTests
|
||||
FastDnsRetry: null,
|
||||
DhcpScriptPath: null,
|
||||
MxTarget: null,
|
||||
Conntrack: null
|
||||
DnsForwardMax: null,
|
||||
DumpfilePath: null,
|
||||
Dumpmask: null,
|
||||
AddCpeId: null,
|
||||
DnssecTimestamp: null,
|
||||
DnssecLimits: null,
|
||||
DhcpAlternatePort: null,
|
||||
DhcpDuid: null,
|
||||
DhcpLuascriptPath: null,
|
||||
DhcpScriptuser: null,
|
||||
DhcpPxeVendor: null,
|
||||
UseStaleCache: null,
|
||||
AddMac: null,
|
||||
StripMac: false,
|
||||
AddSubnet: null,
|
||||
StripSubnet: false,
|
||||
Umbrella: null,
|
||||
Do0x20EncodeState: ExplicitToggleState.Default,
|
||||
Conntrack: false
|
||||
);
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -31,3 +31,7 @@
|
||||
</p>
|
||||
<AppLogsFiltersModal IsVisible="_filtersModalVisible" OnClose="CloseFiltersModal" />
|
||||
<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>
|
||||
|
||||
@@ -19,6 +19,7 @@ public partial class AppLogsSection : IAsyncDisposable
|
||||
|
||||
private bool _justUpdated;
|
||||
private bool _logsContentReceived;
|
||||
private bool _refreshing;
|
||||
private int _intervalSeconds;
|
||||
private string _logLevel = "Information";
|
||||
private bool _logLevelDisabled;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,7 +24,21 @@
|
||||
{
|
||||
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
|
||||
}
|
||||
<div class="mt-2">
|
||||
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing"><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>
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ 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!;
|
||||
@@ -55,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;
|
||||
|
||||
+27
-2
@@ -3,6 +3,7 @@
|
||||
@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
|
||||
|
||||
@@ -17,6 +18,7 @@
|
||||
}
|
||||
<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">
|
||||
@@ -26,7 +28,7 @@
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.PendingChanges" Status="@Status" Session="@Session"
|
||||
OnClose="CloseSaveModal" OnSaveCompleted="OnSaveCompleted" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
|
||||
OnClose="CloseSaveModal" OnRevertedNotify="OnRevertedFromSaveModal" OnSaveCompleted="OnSaveCompleted" OnContinueEditingAfterSavedFailure="ContinueEditingAfterSavedFailureAsync" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -68,7 +70,8 @@
|
||||
},
|
||||
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
|
||||
DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); },
|
||||
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; }
|
||||
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; },
|
||||
RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,6 +186,19 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -199,6 +215,15 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ContinueEditingAfterSavedFailureAsync()
|
||||
{
|
||||
_showSaveModal = false;
|
||||
Session.ExitEditModeDiscard();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
Session.EnterEditMode();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void CloseSaveModalAndEndEdit()
|
||||
{
|
||||
_showSaveModal = false;
|
||||
|
||||
+58
-2
@@ -38,6 +38,14 @@
|
||||
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;
|
||||
@@ -117,6 +125,22 @@
|
||||
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;
|
||||
@@ -179,11 +203,23 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Edit wrapper (checkbox/input) shrinks to content so the editable badge stays on the same line */
|
||||
/* 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 0 auto;
|
||||
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 */
|
||||
@@ -203,6 +239,11 @@
|
||||
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;
|
||||
@@ -240,6 +281,21 @@
|
||||
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;
|
||||
|
||||
+4
-2
@@ -8,8 +8,9 @@
|
||||
<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)
|
||||
{
|
||||
@@ -18,6 +19,7 @@
|
||||
IsEditMode="@IsEditMode" IsActive="@(ActiveFieldKey == fieldKey)" />
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+50
-1
@@ -22,7 +22,39 @@
|
||||
</button>
|
||||
@if (PendingChangesCount > 0)
|
||||
{
|
||||
<span class="text-muted small">@PendingChangesCount change(s)</span>
|
||||
<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
|
||||
@@ -40,6 +72,11 @@
|
||||
[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; }
|
||||
@@ -59,4 +96,16 @@
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,19 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* Reusable value-only display for boolean/flag options. When active editor: checkbox (unless readonly); View: "Enabled" / "Disabled". Pending value shown until save. *@
|
||||
@* 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;
|
||||
<input type="checkbox" class="form-check-input" checked="@isSet" @onchange="OnCheckedChange" @onfocusout="OnCommitRequested" />
|
||||
<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
|
||||
{
|
||||
|
||||
+13
-2
@@ -34,9 +34,20 @@ else
|
||||
|
||||
private async Task OnInput(ChangeEventArgs e)
|
||||
{
|
||||
int? value = null;
|
||||
if (int.TryParse(e.Value?.ToString(), out var num) && num >= Min && num <= Max)
|
||||
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;
|
||||
}
|
||||
+95
-54
@@ -5,8 +5,6 @@
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@using System.Net
|
||||
@using System.Net.Sockets
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
@@ -15,8 +13,8 @@
|
||||
{
|
||||
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
|
||||
var showEditableBadge = !isReadonly;
|
||||
var isActiveEditor = IsEditMode && IsActive;
|
||||
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())">
|
||||
@@ -24,12 +22,15 @@
|
||||
<span class="ec-field-value">
|
||||
@if (isActiveEditor)
|
||||
{
|
||||
<div class="ec-field-edit-wrapper" @onfocusout="OnBlur" @onclick:stopPropagation="true">
|
||||
<CascadingValue Value="@true">
|
||||
<CascadingValue Value="@(EventCallback.Factory.Create<FocusEventArgs>(this, OnBlur))">
|
||||
@((fragmentWithCallback ?? fragment)(Descriptor))
|
||||
<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>
|
||||
</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
|
||||
@@ -37,26 +38,50 @@
|
||||
<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" />
|
||||
}
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
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;
|
||||
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
|
||||
var showEditableForDefault = !Descriptor.IsCapabilityDisabled;
|
||||
@if (showReadonlyHint)
|
||||
{
|
||||
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
||||
@@ -68,8 +93,10 @@ 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"
|
||||
ShowEditableBadge="true" FieldKey="@FieldKey" IsActive="@IsActive"
|
||||
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>
|
||||
}
|
||||
@@ -92,6 +119,7 @@ else
|
||||
private bool _hasPendingChange;
|
||||
private object? _valueAtActivation;
|
||||
private object? _draftValue;
|
||||
private bool _hasDraftValue;
|
||||
private bool _wasActive;
|
||||
private ExitAction _exitAction = ExitAction.None;
|
||||
|
||||
@@ -121,7 +149,12 @@ else
|
||||
if (Descriptor.IsMultiValue)
|
||||
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||
if (!_wasActive && IsActive)
|
||||
_valueAtActivation = descriptorValue;
|
||||
{
|
||||
// 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)
|
||||
@@ -171,6 +204,7 @@ else
|
||||
private void HandleValueChanged(object? value)
|
||||
{
|
||||
_draftValue = value;
|
||||
_hasDraftValue = true;
|
||||
}
|
||||
|
||||
private async Task OnBlur(FocusEventArgs _)
|
||||
@@ -181,49 +215,70 @@ else
|
||||
|
||||
private async Task<bool> CommitIfChangedAsync()
|
||||
{
|
||||
var newValue = _draftValue ?? _effectiveValue;
|
||||
if (!ValuesEqual(_valueAtActivation, newValue))
|
||||
var newValue = _hasDraftValue ? _draftValue : _effectiveValue;
|
||||
if (IsDefaultSingleValueField)
|
||||
newValue = NormalizeDefaultSingleCommitValue(newValue);
|
||||
if (ValuesEqual(_valueAtActivation, newValue))
|
||||
{
|
||||
var validationError = ValidateDraftValue(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;
|
||||
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 = value;
|
||||
_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();
|
||||
@@ -241,6 +296,7 @@ else
|
||||
private async Task HandleCancelEdit()
|
||||
{
|
||||
_draftValue = null;
|
||||
_hasDraftValue = false;
|
||||
_exitAction = ExitAction.Cancel;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
@@ -249,28 +305,13 @@ else
|
||||
private async Task HandleRevertEdit()
|
||||
{
|
||||
_draftValue = null;
|
||||
_hasDraftValue = false;
|
||||
Session?.RevertChange(Descriptor.SectionId, Descriptor.OptionName);
|
||||
_exitAction = ExitAction.Revert;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private string? ValidateDraftValue(object? value)
|
||||
{
|
||||
if (string.Equals(Descriptor.OptionName, DnsmasqConfKeys.ListenAddress, StringComparison.Ordinal))
|
||||
{
|
||||
var values = AsStringList(value) ?? (value is string s ? new[] { s } : null);
|
||||
if (values == null) return null;
|
||||
foreach (var raw in values)
|
||||
{
|
||||
var v = raw.Trim();
|
||||
if (!IPAddress.TryParse(v, out var ip) || (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6))
|
||||
return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 or IPv6 address.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? AsStringList(object? value)
|
||||
{
|
||||
return value as IReadOnlyList<string>;
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
@Value
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value"
|
||||
IsInteractive="@(IsEditMode && !ReadonlyClickActivatesField && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))"
|
||||
ShowEditableBadge="@(Source?.IsReadOnly != true)" EditableAsButton="@IsEditMode"
|
||||
ShowEditableBadge="@(Source?.IsReadOnly != true)" EditableAsButton="true"
|
||||
FieldKey="@FieldKey" ReadonlyClickActivatesField="@ReadonlyClickActivatesField" ReadonlyTitleOverride="@ReadonlyTitleOverride" />
|
||||
</span>
|
||||
|
||||
|
||||
+153
-44
@@ -1,32 +1,43 @@
|
||||
@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
|
||||
|
||||
<span class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||
<ol class="ec-field-value-list">
|
||||
@if (SourceItems?.Count > 0)
|
||||
@if (_displayItems.Count > 0)
|
||||
{
|
||||
@for (var i = 0; i < SourceItems.Count; i++)
|
||||
@for (var i = 0; i < _displayItems.Count; i++)
|
||||
{
|
||||
var item = SourceItems[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" @key="anchorId">
|
||||
<li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
|
||||
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||
{
|
||||
<input class="form-control form-control-sm ec-multi-list-input"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
<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 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
|
||||
{
|
||||
@@ -46,6 +57,7 @@
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
|
||||
Title="Remove this value" AriaLabel="Remove"
|
||||
PreventBlurOnMouseDown="true"
|
||||
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
|
||||
}
|
||||
</span>
|
||||
@@ -53,7 +65,7 @@
|
||||
</li>
|
||||
}
|
||||
}
|
||||
else
|
||||
else if (!AllowAddManagedValue)
|
||||
{
|
||||
<li class="ec-multi-list-row ec-multi-list-empty-hint">
|
||||
<span class="text-muted">(none)</span>
|
||||
@@ -62,20 +74,24 @@
|
||||
|
||||
@if (AllowAddManagedValue)
|
||||
{
|
||||
<li class="ec-multi-list-row ec-multi-list-add-item" @key="AddItemKey">
|
||||
<li class="ec-multi-list-row ec-multi-list-add-item @(_addingNew ? "ec-multi-list-row-with-controls" : "")" @key="AddItemKey">
|
||||
@if (_addingNew)
|
||||
{
|
||||
<input class="form-control form-control-sm ec-multi-list-input"
|
||||
placeholder="New value"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
<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 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
|
||||
{
|
||||
@@ -86,7 +102,7 @@
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
|
||||
@@ -96,12 +112,54 @@
|
||||
[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)
|
||||
{
|
||||
@@ -115,6 +173,7 @@
|
||||
_editingIndex = null;
|
||||
_addingNew = true;
|
||||
_draftValue = "";
|
||||
_shouldFocusAddInput = true;
|
||||
}
|
||||
|
||||
private void CancelInline()
|
||||
@@ -122,46 +181,96 @@
|
||||
_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 next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
var current = _currentValues;
|
||||
var next = current.ToList();
|
||||
if (index < 0 || index >= next.Count) return;
|
||||
|
||||
next[index] = (_draftValue ?? "").Trim();
|
||||
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 value = (_draftValue ?? "").Trim();
|
||||
if (value.Length == 0) return;
|
||||
var raw = _draftValue ?? "";
|
||||
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
ShowValidationError("Value cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
var current = _currentValues;
|
||||
var validationError = Validator?.ValidateItem(normalized, current, null);
|
||||
if (validationError != null)
|
||||
{
|
||||
ShowValidationError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
next.Add(value);
|
||||
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 (SourceItems == null || index < 0 || index >= SourceItems.Count) return;
|
||||
var item = SourceItems[index];
|
||||
if (index < 0 || index >= _displayItems.Count) return;
|
||||
var item = _displayItems[index];
|
||||
if (item?.Source?.IsReadOnly == true) return;
|
||||
|
||||
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+33
-3
@@ -20,6 +20,33 @@
|
||||
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;
|
||||
}
|
||||
@@ -32,8 +59,9 @@
|
||||
}
|
||||
|
||||
.ec-multi-list-row .form-control.ec-multi-list-input {
|
||||
max-width: 28rem;
|
||||
max-width: none;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ec-multi-list-empty-hint {
|
||||
@@ -51,11 +79,13 @@
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item .form-control.ec-multi-list-input {
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
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;
|
||||
}
|
||||
|
||||
+11
-2
@@ -12,15 +12,19 @@
|
||||
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
|
||||
{
|
||||
<span class="ec-field-value ec-field-value-multi">
|
||||
<div class="ec-field-value ec-field-value-multi">
|
||||
@if (Items?.Count > 0)
|
||||
{
|
||||
<ol class="ec-field-value-list">
|
||||
@@ -34,13 +38,16 @@
|
||||
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" />
|
||||
}
|
||||
</span>
|
||||
</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; }
|
||||
@@ -56,4 +63,6 @@
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
|
||||
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
@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)
|
||||
@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" value="@(_editValue ?? DisplayValue)" @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" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
<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" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
<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; }
|
||||
@@ -38,6 +45,10 @@ else
|
||||
[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>
|
||||
@@ -68,6 +79,6 @@ else
|
||||
private async Task OnInputBlur(FocusEventArgs _)
|
||||
{
|
||||
if (OnBlur.HasDelegate)
|
||||
await OnBlur.InvokeAsync(_editValue ?? DisplayValue);
|
||||
await OnBlur.InvokeAsync(_editValue ?? EditValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
SaveResult="@_saveResult"
|
||||
RestoreResult="@_restoreResult"
|
||||
OnClose="@OnClose"
|
||||
OnContinueEditing="@(EventCallback.Factory.Create(this, HandleContinueEditing))"
|
||||
OnSave="@HandleSave"
|
||||
OnRestore="@HandleRestore"
|
||||
OnReverted="@HandleReverted"
|
||||
OnCloseAndEndEdit="@OnCloseAndEndEdit" />
|
||||
OnCloseAndEndEdit="@HandleCloseAndEndEdit" />
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -38,7 +39,10 @@
|
||||
[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!;
|
||||
|
||||
@@ -58,12 +62,19 @@
|
||||
|
||||
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;
|
||||
@@ -114,7 +125,7 @@
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
await OnClose.InvokeAsync();
|
||||
await OnCloseAndEndEdit.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
@@ -122,4 +133,23 @@
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@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">
|
||||
@@ -85,6 +86,35 @@
|
||||
}
|
||||
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)
|
||||
{
|
||||
@@ -114,24 +144,36 @@
|
||||
@if (!isMultiValue)
|
||||
{
|
||||
<span class="ec-save-modal-diff text-body">
|
||||
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue)</span>
|
||||
<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)</span>
|
||||
<span>@FormatValue(change.NewValue, change.OptionName)</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (isMultiValue)
|
||||
{
|
||||
var added = (newList ?? Array.Empty<string>()).Except(oldList ?? Array.Empty<string>()).ToList();
|
||||
var removed = (oldList ?? Array.Empty<string>()).Except(newList ?? Array.Empty<string>()).ToList();
|
||||
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">
|
||||
@foreach (var val in removed)
|
||||
@if (orderOrDuplicatesChanged)
|
||||
{
|
||||
<div class="text-muted text-decoration-line-through">− @val</div>
|
||||
<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>
|
||||
}
|
||||
@foreach (var val in added)
|
||||
else
|
||||
{
|
||||
<div class="text-success">+ @val</div>
|
||||
@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>
|
||||
}
|
||||
@@ -153,7 +195,7 @@
|
||||
@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="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</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)
|
||||
@@ -168,7 +210,7 @@
|
||||
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)"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</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>
|
||||
@@ -184,6 +226,7 @@
|
||||
[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; }
|
||||
@@ -193,6 +236,10 @@
|
||||
|
||||
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();
|
||||
@@ -235,8 +282,10 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatValue(object? v)
|
||||
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);
|
||||
@@ -244,6 +293,14 @@
|
||||
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);
|
||||
@@ -262,6 +319,12 @@
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task ContinueEditing()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await OnContinueEditing.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
await OnSave.InvokeAsync();
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
@if (Source?.IsReadOnly == true)
|
||||
@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))
|
||||
@@ -85,6 +92,10 @@ else if (ShowEditableBadge)
|
||||
[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()
|
||||
|
||||
@@ -39,6 +39,16 @@
|
||||
<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>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<PageTitle>DHCP | @AppTitle</PageTitle>
|
||||
|
||||
<h1 class="page-title">DHCP</h1>
|
||||
<h1 class="page-title">DHCP Configuration</h1>
|
||||
|
||||
@if (_status == null)
|
||||
{
|
||||
@@ -24,6 +24,10 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="page-section">
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
</section>
|
||||
|
||||
@* ---- Static hosts (dhcp-host= in managed file) ---- *@
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title">Static hosts</h2>
|
||||
@@ -323,10 +327,6 @@ else
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title">Config affecting DHCP</h2>
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
@page "/dns-records"
|
||||
@rendermode InteractiveServer
|
||||
@namespace DnsmasqWebUI.Components.Pages
|
||||
@inject IStatusClient StatusClient
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
|
||||
<PageTitle>DNS records | @AppTitle</PageTitle>
|
||||
|
||||
<h1 class="page-title">DNS Records Configuration</h1>
|
||||
|
||||
@if (_status == null)
|
||||
{
|
||||
<p class="d-flex align-items-center gap-2">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
Loading...
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="page-section">
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.DnsRecords" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
</section>
|
||||
<div class="page-chips" role="group" aria-label="Record type">
|
||||
<span class="page-chip active">All</span>
|
||||
<span class="page-chip">CNAME</span>
|
||||
<span class="page-chip">MX / SRV</span>
|
||||
<span class="page-chip">PTR</span>
|
||||
<span class="page-chip">TXT</span>
|
||||
<span class="page-chip">A / AAAA</span>
|
||||
<span class="page-chip">Other</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||
private DnsmasqServiceStatus? _status;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task RefreshStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_status = await StatusClient.GetStatusAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
_status = await StatusClient.GetStatusAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Status will stay null; UI shows loading or empty
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,40 +24,24 @@ else if (_error != null)
|
||||
else if (_status != null)
|
||||
{
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title">Service configuration</h2>
|
||||
<p class="mb-1"><strong>Config files (load order):</strong></p>
|
||||
<ul class="list-unstyled small ms-2 mb-0">
|
||||
@{
|
||||
var configFileList = _status.ConfigFiles?.Count > 0 ? _status.ConfigFiles : _configSet?.Files;
|
||||
}
|
||||
@if (configFileList != null && configFileList.Count > 0)
|
||||
{
|
||||
@foreach (var f in configFileList)
|
||||
@if (!string.IsNullOrWhiteSpace(_status.DnsmasqVersion) || !string.IsNullOrWhiteSpace(_status.DnsmasqVersionError))
|
||||
{
|
||||
<p class="text-muted small mb-1 text-end">
|
||||
@if (!string.IsNullOrWhiteSpace(_status.DnsmasqVersion))
|
||||
{
|
||||
var isMain = string.Equals(f.Path, _status.MainConfigPath, StringComparison.Ordinal);
|
||||
var isManaged = f.IsManaged;
|
||||
var exists = isMain ? _status.MainConfigPathExists : (isManaged ? _status.ManagedFilePathExists : (bool?)null);
|
||||
<li>
|
||||
@if (f.IsManaged)
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.Managed" Text="managed" Class="me-1" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.SourceMain" Text="@(f.Source.ToString().ToLowerInvariant())" Class="me-1" />
|
||||
}
|
||||
@f.Path
|
||||
@if (exists == true) { <text> ✓</text> }
|
||||
@if (exists == false) { <text> @(isManaged ? "(not created yet)" : "(missing)")</text> }
|
||||
</li>
|
||||
var versionTooltip = "Minimum required by this UI: " + _status.MinimumSupportedDnsmasqVersion + ". " + (_status.DnsmasqVersionSupported ? "Supported." : "Unsupported.");
|
||||
<a href="https://thekelleys.org.uk/dnsmasq/CHANGELOG" target="_blank" rel="noopener noreferrer" class="text-muted text-decoration-none" title="@versionTooltip">
|
||||
dnsmasq <code>@_status.DnsmasqVersion</code>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<li><strong>Main config:</strong> @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")</li>
|
||||
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
|
||||
}
|
||||
</ul>
|
||||
@if (!string.IsNullOrWhiteSpace(_status.DnsmasqVersionError))
|
||||
{
|
||||
<span class="text-warning ms-1" title="@_status.DnsmasqVersionError">@_status.DnsmasqVersionError</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
<h2 class="page-section-title">Service configuration</h2>
|
||||
<ConfigFileList Status="_status" ConfigSet="_configSet" />
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.All" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
@if (_status.ReloadCommandConfigured)
|
||||
{
|
||||
@@ -76,7 +60,7 @@ else if (_status != null)
|
||||
</div>
|
||||
}
|
||||
|
||||
<StatusSection RefreshIntervalSeconds="@ClientSettingsFields.ServiceStatusPollingInterval.Value" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
|
||||
<StatusSection RefreshIntervalSeconds="@ClientSettingsFields.ServiceStatusPollingInterval.Value" IsReloading="_reloading" OnReloadRequested="ReloadDnsmasq" ReloadCommandConfigured="_status.ReloadCommandConfigured" OnOpenSettings="OpenServiceStatusSettings" />
|
||||
<LogsSection Status="_status" RefreshIntervalSeconds="@ClientSettingsFields.RecentLogsPollingInterval.Value" LogsMaxLines="@ClientSettingsFields.RecentLogsMaxLines.Value" LogsAutoScroll="@ClientSettingsFields.RecentLogsAutoScroll.Value" OnOpenSettings="OpenRecentLogsSettings" />
|
||||
<AppLogsSection RefreshIntervalSeconds="@ClientSettingsFields.AppLogsPollingInterval.Value" LogsMaxLines="@ClientSettingsFields.AppLogsMaxLines.Value" LogsAutoScroll="@ClientSettingsFields.AppLogsAutoScroll.Value" OnOpenSettings="OpenAppLogsSettings" />
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
@page "/filters"
|
||||
@rendermode InteractiveServer
|
||||
@namespace DnsmasqWebUI.Components.Pages
|
||||
@inject IStatusClient StatusClient
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
|
||||
<PageTitle>Filters | @AppTitle</PageTitle>
|
||||
|
||||
<h1 class="page-title">Filters Configuration</h1>
|
||||
|
||||
@if (_status == null)
|
||||
{
|
||||
<p class="d-flex align-items-center gap-2">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
Loading...
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="page-section">
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Filters" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
</section>
|
||||
<div class="page-chips" role="group" aria-label="Filter policy category">
|
||||
<span class="page-chip active">All</span>
|
||||
<span class="page-chip">Blocking / sinkhole</span>
|
||||
<span class="page-chip">Split DNS</span>
|
||||
<span class="page-chip">Safety</span>
|
||||
<span class="page-chip">Response shaping</span>
|
||||
<span class="page-chip">Firewall</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||
private DnsmasqServiceStatus? _status;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task RefreshStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_status = await StatusClient.GetStatusAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
_status = await StatusClient.GetStatusAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Status will stay null; UI shows loading or empty
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
<PageTitle>Hosts | @AppTitle</PageTitle>
|
||||
|
||||
<h1 class="page-title">Hosts file</h1>
|
||||
<h1 class="page-title">Hosts Configuration</h1>
|
||||
|
||||
@if (_status == null)
|
||||
{
|
||||
@@ -18,7 +18,6 @@
|
||||
else
|
||||
{
|
||||
<section class="page-section mb-4">
|
||||
<h2 class="page-section-title">Config affecting hosts</h2>
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Hosts" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
</section>
|
||||
@if (string.IsNullOrEmpty(_status.ManagedHostsFilePath))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Logs.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -12,22 +14,39 @@ namespace DnsmasqWebUI.Controllers;
|
||||
[Route("api/[controller]")]
|
||||
public class LoggingController : ControllerBase
|
||||
{
|
||||
private const int AppLogsDownloadMaxLines = 5000;
|
||||
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IConfigurationRoot _configurationRoot;
|
||||
private readonly RuntimeOverridesOptions _overridesOptions;
|
||||
private readonly IAppLogsBuffer _appLogsBuffer;
|
||||
private readonly ILogger<LoggingController> _logger;
|
||||
|
||||
public LoggingController(
|
||||
IConfiguration configuration,
|
||||
IOptions<RuntimeOverridesOptions> overridesOptions,
|
||||
IAppLogsBuffer appLogsBuffer,
|
||||
ILogger<LoggingController> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_configurationRoot = (IConfigurationRoot)configuration;
|
||||
_overridesOptions = overridesOptions.Value;
|
||||
_appLogsBuffer = appLogsBuffer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Download recent app logs from the in-memory buffer (up to AppLogsDownloadMaxLines).</summary>
|
||||
[HttpGet("logs/download")]
|
||||
public IActionResult GetAppLogsDownload()
|
||||
{
|
||||
var lines = _appLogsBuffer.GetRecent(maxLines: AppLogsDownloadMaxLines);
|
||||
var content = string.Join(Environment.NewLine, lines);
|
||||
if (lines.Count > 0 && !content.EndsWith(Environment.NewLine))
|
||||
content += Environment.NewLine;
|
||||
_logger.LogDebug("App logs download, lines={Count}", lines.Count);
|
||||
return File(Encoding.UTF8.GetBytes(content), "text/plain", "app.log");
|
||||
}
|
||||
|
||||
/// <summary>Get the current minimum log level from configuration (Logging:LogLevel:Default).</summary>
|
||||
[HttpGet("level")]
|
||||
public ActionResult<LogLevelResponse> GetLevel()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -9,11 +10,13 @@ namespace DnsmasqWebUI.Controllers;
|
||||
public class ReloadController : ControllerBase
|
||||
{
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly IConfigSetCache _configSetCache;
|
||||
private readonly ILogger<ReloadController> _logger;
|
||||
|
||||
public ReloadController(IReloadService reloadService, ILogger<ReloadController> logger)
|
||||
public ReloadController(IReloadService reloadService, IConfigSetCache configSetCache, ILogger<ReloadController> logger)
|
||||
{
|
||||
_reloadService = reloadService;
|
||||
_configSetCache = configSetCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -24,6 +27,8 @@ public class ReloadController : ControllerBase
|
||||
{
|
||||
var result = await _reloadService.ReloadAsync(ct);
|
||||
_logger.LogInformation("Reload requested, success={Success}", result.Success);
|
||||
if (result.Success)
|
||||
_configSetCache.Invalidate();
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
@@ -17,17 +18,20 @@ public class StatusController : ControllerBase
|
||||
private readonly DnsmasqOptions _options;
|
||||
private readonly IDnsmasqConfigSetService _configSetService;
|
||||
private readonly IProcessRunner _processRunner;
|
||||
private readonly IDnsmasqVersionService _versionService;
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
|
||||
public StatusController(
|
||||
IOptions<DnsmasqOptions> options,
|
||||
IDnsmasqConfigSetService configSetService,
|
||||
IProcessRunner processRunner,
|
||||
IDnsmasqVersionService versionService,
|
||||
ILogger<StatusController> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_configSetService = configSetService;
|
||||
_processRunner = processRunner;
|
||||
_versionService = versionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -52,14 +56,16 @@ public class StatusController : ControllerBase
|
||||
if (statusResult.ExceptionMessage != null)
|
||||
statusCommandStderr = (statusCommandStderr ?? "") + (statusCommandStderr != null ? "\n" : "") + statusResult.ExceptionMessage;
|
||||
|
||||
var versionTask = _versionService.GetVersionInfoAsync(ct);
|
||||
var showTask = string.IsNullOrWhiteSpace(_options.StatusShowCommand)
|
||||
? Task.FromResult(new ProcessRunResult(null, "", "", false))
|
||||
: _processRunner.RunAsync(_options.StatusShowCommand, _options.StatusShowTimeout, ct);
|
||||
var logsTask = string.IsNullOrWhiteSpace(_options.LogsCommand)
|
||||
? Task.FromResult(new ProcessRunResult(null, "", "", false))
|
||||
: _processRunner.RunAsync(_options.LogsCommand, _options.LogsTimeout, ct);
|
||||
await Task.WhenAll(showTask, logsTask);
|
||||
await Task.WhenAll(versionTask, showTask, logsTask);
|
||||
|
||||
var versionInfo = await versionTask;
|
||||
var showResult = await showTask;
|
||||
var logsResult = await logsTask;
|
||||
var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand)
|
||||
@@ -100,7 +106,15 @@ public class StatusController : ControllerBase
|
||||
StatusShowOutput: statusShowOutput,
|
||||
LogsOutput: logsOutput,
|
||||
DhcpRangeStart: dhcpRangeStart,
|
||||
DhcpRangeEnd: dhcpRangeEnd
|
||||
DhcpRangeEnd: dhcpRangeEnd,
|
||||
DnsmasqVersion: versionInfo.InstalledVersion?.ToString(),
|
||||
MinimumSupportedDnsmasqVersion: versionInfo.MinimumVersion.ToString(),
|
||||
DnsmasqVersionSupported: versionInfo.IsSupported,
|
||||
DnsmasqVersionError: versionInfo.Error,
|
||||
DnsmasqSupportsDhcp: versionInfo.Capabilities.Dhcp,
|
||||
DnsmasqSupportsTftp: versionInfo.Capabilities.Tftp,
|
||||
DnsmasqSupportsDnssec: versionInfo.Capabilities.Dnssec,
|
||||
DnsmasqSupportsDbus: versionInfo.Capabilities.Dbus
|
||||
);
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>0.0.6</Version>
|
||||
<Version>0.0.7</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
|
||||
<DisableBuildCompression>true</DisableBuildCompression>
|
||||
|
||||
@@ -48,12 +48,13 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
delegate void RegisterService(IServiceCollection s, Type iface, Type impl);
|
||||
|
||||
static readonly (Type MarkerInterface, RegisterService Register)[] ApplicationRegistrations =
|
||||
static readonly (Type MarkerInterface, RegisterService Register, bool AllowMultipleImplementations)[] ApplicationRegistrations =
|
||||
[
|
||||
(typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)),
|
||||
(typeof(IApplicationScopedConcrete), (s, i, impl) => s.AddScoped(impl, impl)),
|
||||
(typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)),
|
||||
(typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl)),
|
||||
(typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl), false),
|
||||
(typeof(IApplicationScopedConcrete), (s, i, impl) => s.AddScoped(impl, impl), false),
|
||||
(typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl), false),
|
||||
(typeof(IApplicationMultiSingleton), (s, i, impl) => s.AddSingleton(i, impl), true),
|
||||
(typeof(IApplicationHostedService), (s, i, impl) => AddHostedServiceConcrete(s, impl), true),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
@@ -65,8 +66,8 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
foreach (var (markerInterface, register) in ApplicationRegistrations)
|
||||
ScanAndRegister(services, markerInterface, register);
|
||||
foreach (var (markerInterface, register, allowMultipleImplementations) in ApplicationRegistrations)
|
||||
ScanAndRegister(services, markerInterface, register, allowMultipleImplementations);
|
||||
AddOptionsValidatorsFromAssembly(services, Assembly.GetExecutingAssembly());
|
||||
return services;
|
||||
}
|
||||
@@ -98,7 +99,8 @@ public static class ServiceCollectionExtensions
|
||||
static void ScanAndRegister(
|
||||
IServiceCollection services,
|
||||
Type markerInterface,
|
||||
RegisterService register)
|
||||
RegisterService register,
|
||||
bool allowMultipleImplementations)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
@@ -116,12 +118,20 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
if (implementations.Count == 0)
|
||||
continue;
|
||||
if (implementations.Count > 1)
|
||||
if (!allowMultipleImplementations && implementations.Count > 1)
|
||||
throw new InvalidOperationException(
|
||||
$"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " +
|
||||
"Register one explicitly or exclude the others from the scan.");
|
||||
|
||||
register(services, iface, implementations[0]);
|
||||
if (allowMultipleImplementations)
|
||||
{
|
||||
foreach (var impl in implementations)
|
||||
register(services, iface, impl);
|
||||
}
|
||||
else
|
||||
{
|
||||
register(services, iface, implementations[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions.Hosting;
|
||||
|
||||
@@ -39,4 +42,22 @@ public static class WebApplicationExtensions
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the readiness health check at /healthz/ready (checks with tag "ready", returns JSON status).
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapReadyHealthCheck(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapHealthChecks("/healthz/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Tags.Contains("ready"),
|
||||
ResponseWriter = static async (context, report) =>
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
var status = report.Status == HealthStatus.Healthy ? "ok" : "unhealthy";
|
||||
await context.Response.WriteAsync($"{{\"status\":\"{status}\"}}", context.RequestAborted);
|
||||
}
|
||||
});
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public static class DnsmasqConfKeys
|
||||
public const string DhcpHostsfile = "dhcp-hostsfile";
|
||||
public const string DhcpOptsfile = "dhcp-optsfile";
|
||||
public const string DhcpHostsdir = "dhcp-hostsdir";
|
||||
public const string DhcpOptsdir = "dhcp-optsdir";
|
||||
|
||||
// --- Multi-value (ARG_DUP) ---
|
||||
public const string Server = "server";
|
||||
@@ -148,4 +149,63 @@ public static class DnsmasqConfKeys
|
||||
// --- Niche / platform (Linux conntrack mark for UBus/query filtering) ---
|
||||
public const string Conntrack = "conntrack";
|
||||
|
||||
public const string ConnmarkAllowlistEnable = "connmark-allowlist-enable";
|
||||
public const string NoRoundRobin = "no-round-robin";
|
||||
public const string DnssecNoTimecheck = "dnssec-no-timecheck";
|
||||
public const string DnssecDebug = "dnssec-debug";
|
||||
public const string Leasequery = "leasequery";
|
||||
public const string DhcpGenerateNames = "dhcp-generate-names";
|
||||
public const string DhcpBroadcast = "dhcp-broadcast";
|
||||
public const string DhcpSequentialIp = "dhcp-sequential-ip";
|
||||
public const string DhcpIgnoreClid = "dhcp-ignore-clid";
|
||||
public const string BootpDynamic = "bootp-dynamic";
|
||||
public const string NoPing = "no-ping";
|
||||
public const string ScriptArp = "script-arp";
|
||||
public const string ScriptOnRenewal = "script-on-renewal";
|
||||
public const string DhcpNoOverride = "dhcp-no-override";
|
||||
public const string QuietDhcp = "quiet-dhcp";
|
||||
public const string QuietDhcp6 = "quiet-dhcp6";
|
||||
public const string QuietRa = "quiet-ra";
|
||||
public const string QuietTftp = "quiet-tftp";
|
||||
|
||||
public const string DnsForwardMax = "dns-forward-max";
|
||||
public const string Dumpfile = "dumpfile";
|
||||
public const string Dumpmask = "dumpmask";
|
||||
public const string AddCpeId = "add-cpe-id";
|
||||
public const string DnssecTimestamp = "dnssec-timestamp";
|
||||
public const string DnssecLimits = "dnssec-limits";
|
||||
public const string DhcpAlternatePort = "dhcp-alternate-port";
|
||||
public const string DhcpDuid = "dhcp-duid";
|
||||
public const string DhcpLuascript = "dhcp-luascript";
|
||||
public const string DhcpScriptuser = "dhcp-scriptuser";
|
||||
public const string DhcpPxeVendor = "dhcp-pxe-vendor";
|
||||
|
||||
public const string ConnmarkAllowlist = "connmark-allowlist";
|
||||
public const string CaaRecord = "caa-record";
|
||||
public const string DnsRr = "dns-rr";
|
||||
public const string SynthDomain = "synth-domain";
|
||||
public const string AuthZone = "auth-zone";
|
||||
public const string AuthSoa = "auth-soa";
|
||||
public const string AuthSecServers = "auth-sec-servers";
|
||||
public const string AuthPeer = "auth-peer";
|
||||
public const string DhcpRelay = "dhcp-relay";
|
||||
public const string DhcpCircuitid = "dhcp-circuitid";
|
||||
public const string DhcpRemoteid = "dhcp-remoteid";
|
||||
public const string DhcpSubscrid = "dhcp-subscrid";
|
||||
public const string DhcpProxy = "dhcp-proxy";
|
||||
public const string TagIf = "tag-if";
|
||||
public const string BridgeInterface = "bridge-interface";
|
||||
public const string SharedNetwork = "shared-network";
|
||||
public const string DhcpOptionPxe = "dhcp-option-pxe";
|
||||
|
||||
public const string UseStaleCache = "use-stale-cache";
|
||||
public const string AddMac = "add-mac";
|
||||
public const string StripMac = "strip-mac";
|
||||
public const string AddSubnet = "add-subnet";
|
||||
public const string StripSubnet = "strip-subnet";
|
||||
public const string Umbrella = "umbrella";
|
||||
|
||||
public const string Do0x20Encode = "do-0x20-encode";
|
||||
public const string No0x20Encode = "no-0x20-encode";
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Net;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Shared structural parser for <c>dhcp-range</c> option values.
|
||||
/// This is intentionally conservative and captures the core structure needed by
|
||||
/// validation and simple range extraction without re-implementing all dnsmasq semantics.
|
||||
/// </summary>
|
||||
public sealed record ParsedDhcpRange(
|
||||
IReadOnlyList<string> Tags,
|
||||
string StartToken,
|
||||
string SecondToken,
|
||||
IReadOnlyList<string> RemainingTokens);
|
||||
|
||||
/// <summary>
|
||||
/// Parser for a single <c>dhcp-range</c> value.
|
||||
/// </summary>
|
||||
public static class DnsmasqDhcpRangeValueParser
|
||||
{
|
||||
private static readonly HashSet<string> ModeKeywords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"static",
|
||||
"proxy",
|
||||
"ra-only",
|
||||
"ra-stateless",
|
||||
"ra-names",
|
||||
"slaac",
|
||||
"off-link",
|
||||
};
|
||||
|
||||
public static bool TryParse(string raw, out ParsedDhcpRange? parsed, out string? error)
|
||||
{
|
||||
parsed = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
error = "Value cannot be empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var tokens = raw.Split(',').Select(t => t.Trim()).ToArray();
|
||||
if (tokens.Any(t => t.Length == 0))
|
||||
{
|
||||
error = "dhcp-range contains an empty comma-separated segment.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
var tags = new List<string>();
|
||||
while (index < tokens.Length && IsTagToken(tokens[index]))
|
||||
{
|
||||
tags.Add(tokens[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index >= tokens.Length || !IsRangeStartToken(tokens[index]))
|
||||
{
|
||||
error = "dhcp-range must include a valid start address or constructor:<interface> after any tag/set prefixes.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var startToken = tokens[index];
|
||||
index++;
|
||||
|
||||
if (index >= tokens.Length)
|
||||
{
|
||||
error = "dhcp-range must include an end address or mode after the start address.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var secondToken = tokens[index];
|
||||
if (!IsIpAddress(secondToken) && !IsModeToken(secondToken))
|
||||
{
|
||||
error = "dhcp-range second value must be an end address or a valid mode.";
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed = new ParsedDhcpRange(
|
||||
tags,
|
||||
startToken,
|
||||
secondToken,
|
||||
tokens.Skip(index + 1).ToArray());
|
||||
return true;
|
||||
}
|
||||
|
||||
public static (string? Start, string? End) GetIPv4StartEnd(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return (null, null);
|
||||
|
||||
if (!TryParse(raw, out var parsed, out _))
|
||||
return (null, null);
|
||||
|
||||
if (!IPAddress.TryParse(parsed!.StartToken, out var startIp) ||
|
||||
startIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
if (!IPAddress.TryParse(parsed.SecondToken, out var endIp) ||
|
||||
endIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
return (parsed.StartToken, null);
|
||||
}
|
||||
|
||||
return (parsed.StartToken, parsed.SecondToken);
|
||||
}
|
||||
|
||||
private static bool IsTagToken(string value) =>
|
||||
value.StartsWith("tag:", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.StartsWith("set:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsModeToken(string value) =>
|
||||
ModeKeywords.Contains(value) ||
|
||||
value.StartsWith("constructor:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsRangeStartToken(string value) =>
|
||||
IsIpAddress(value) || value.StartsWith("constructor:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsIpAddress(string value) =>
|
||||
IPAddress.TryParse(value, out _);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder values shown in effective-config editors before the user enters a value.
|
||||
/// Kept separate from validation semantics and aligned with the existing option tooltip/help metadata pattern.
|
||||
/// </summary>
|
||||
public static class DnsmasqOptionPlaceholders
|
||||
{
|
||||
private static readonly FrozenDictionary<string, string> Values = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
// --- Hosts ---
|
||||
[DnsmasqConfKeys.AddnHosts] = "/etc/dnsmasq.hosts",
|
||||
[DnsmasqConfKeys.Hostsdir] = "/etc/dnsmasq.hosts.d",
|
||||
|
||||
// --- Resolver / DNS ---
|
||||
[DnsmasqConfKeys.Server] = "IP or hostname, e.g. 8.8.8.8",
|
||||
[DnsmasqConfKeys.Local] = "/example.local/",
|
||||
[DnsmasqConfKeys.RevServer] = "CIDR,server, e.g. 1.2.3.0/24,192.168.1.1",
|
||||
[DnsmasqConfKeys.Address] = "/example.local/192.168.1.10",
|
||||
[DnsmasqConfKeys.ResolvFile] = "/etc/resolv.dnsmasq.conf",
|
||||
[DnsmasqConfKeys.FastDnsRetry] = "1000,10000",
|
||||
[DnsmasqConfKeys.RebindDomainOk] = "example.local",
|
||||
[DnsmasqConfKeys.BogusNxdomain] = "64.94.110.11",
|
||||
[DnsmasqConfKeys.IgnoreAddress] = "64.94.110.11",
|
||||
[DnsmasqConfKeys.Alias] = "192.168.0.0,10.0.0.0,255.255.255.0",
|
||||
[DnsmasqConfKeys.FilterRr] = "ANY",
|
||||
[DnsmasqConfKeys.AuthServer] = "example.local,eth0",
|
||||
[DnsmasqConfKeys.NoDhcpInterface] = "eth1",
|
||||
[DnsmasqConfKeys.NoDhcpv4Interface] = "eth1",
|
||||
[DnsmasqConfKeys.NoDhcpv6Interface] = "eth1",
|
||||
[DnsmasqConfKeys.Ipset] = "example.local/ipsetname",
|
||||
[DnsmasqConfKeys.Nftset] = "example.local#inet#filter#setname",
|
||||
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = "mask, e.g. 0xff",
|
||||
[DnsmasqConfKeys.ConnmarkAllowlist] = "0xff,example.local",
|
||||
|
||||
// --- DNS records ---
|
||||
[DnsmasqConfKeys.Domain] = "example.local,192.168.1.1",
|
||||
[DnsmasqConfKeys.Cname] = "router.example.local,router",
|
||||
[DnsmasqConfKeys.MxHost] = "example.local,mail.example.local,10",
|
||||
[DnsmasqConfKeys.MxTarget] = "mail.example.local",
|
||||
[DnsmasqConfKeys.Srv] = "_sip._tcp.example.local,sip.example.local,443,10,5",
|
||||
[DnsmasqConfKeys.PtrRecord] = "10.1.168.192.in-addr.arpa,router.example.local",
|
||||
[DnsmasqConfKeys.TxtRecord] = "example.local,\"hello world\"",
|
||||
[DnsmasqConfKeys.NaptrRecord] = "example.local,100,50,\"s\",\"SIP+D2U\",\"\",_sip._udp.example.local",
|
||||
[DnsmasqConfKeys.HostRecord] = "router.example.local,192.168.1.1",
|
||||
[DnsmasqConfKeys.DynamicHost] = "router.example.local,192.168.1.1,10m",
|
||||
[DnsmasqConfKeys.InterfaceName] = "router.example.local,eth0",
|
||||
[DnsmasqConfKeys.CaaRecord] = "example.local,0,issue,\"letsencrypt.org\"",
|
||||
[DnsmasqConfKeys.DnsRr] = "example.local,16,\"hello world\"",
|
||||
[DnsmasqConfKeys.SynthDomain] = "example.local,192.168.1.0/24,host-*",
|
||||
[DnsmasqConfKeys.AuthZone] = "example.local,192.168.1.0/24",
|
||||
[DnsmasqConfKeys.AuthSoa] = "ns1.example.local,hostmaster.example.local,1,3600,1200,604800",
|
||||
[DnsmasqConfKeys.AuthSecServers] = "example.local,ns2.example.local",
|
||||
[DnsmasqConfKeys.AuthPeer] = "192.168.1.2",
|
||||
|
||||
// --- DHCP ---
|
||||
[DnsmasqConfKeys.Leasequery] = "IP[/prefix], e.g. 10.0.0.0/24",
|
||||
[DnsmasqConfKeys.DhcpGenerateNames] = "tag name, e.g. set:known",
|
||||
[DnsmasqConfKeys.DhcpBroadcast] = "tag:legacy-clients",
|
||||
[DnsmasqConfKeys.DhcpLeasefile] = "/var/lib/misc/dnsmasq.leases",
|
||||
[DnsmasqConfKeys.DhcpRange] = "start,end[,lease], e.g. 192.168.1.50,192.168.1.150,12h",
|
||||
[DnsmasqConfKeys.DhcpHost] = "aa:bb:cc:dd:ee:ff,192.168.1.10,host1,12h",
|
||||
[DnsmasqConfKeys.DhcpOption] = "option:dns-server,192.168.1.1",
|
||||
[DnsmasqConfKeys.DhcpOptionForce] = "option:dns-server,192.168.1.1",
|
||||
[DnsmasqConfKeys.DhcpMatch] = "set:tag,option:vendor-class,Example",
|
||||
[DnsmasqConfKeys.DhcpMac] = "set:tag,aa:bb:cc:*:*:*",
|
||||
[DnsmasqConfKeys.DhcpNameMatch] = "set:tag,hostname*",
|
||||
[DnsmasqConfKeys.DhcpIgnoreNames] = "tag:ignore-names",
|
||||
[DnsmasqConfKeys.DhcpHostsfile] = "/path/to/dhcp-hosts.conf",
|
||||
[DnsmasqConfKeys.DhcpOptsfile] = "/path/to/dhcp-opts.conf",
|
||||
[DnsmasqConfKeys.DhcpHostsdir] = "/path/to/dhcp-hosts.d",
|
||||
[DnsmasqConfKeys.DhcpOptsdir] = "/path/to/dhcp-opts.d",
|
||||
[DnsmasqConfKeys.DhcpBoot] = "pxelinux.0,,192.168.1.2",
|
||||
[DnsmasqConfKeys.DhcpIgnore] = "tag:blocked",
|
||||
[DnsmasqConfKeys.DhcpVendorclass] = "set:pxe,PXEClient",
|
||||
[DnsmasqConfKeys.DhcpUserclass] = "set:userclass,ExampleClient",
|
||||
[DnsmasqConfKeys.RaParam] = "eth0,mtu:1500,high",
|
||||
[DnsmasqConfKeys.Slaac] = "eth0,::10",
|
||||
[DnsmasqConfKeys.DhcpRelay] = "192.168.1.0,192.168.2.1",
|
||||
[DnsmasqConfKeys.DhcpCircuitid] = "set:<tag>,<circuit-id>",
|
||||
[DnsmasqConfKeys.DhcpRemoteid] = "set:<tag>,<remote-id>",
|
||||
[DnsmasqConfKeys.DhcpSubscrid] = "set:<tag>,<subscriber-id>",
|
||||
[DnsmasqConfKeys.DhcpProxy] = "192.168.1.0,192.168.1.1",
|
||||
[DnsmasqConfKeys.TagIf] = "tag:guest,tag:wifi",
|
||||
[DnsmasqConfKeys.BridgeInterface] = "br0,eth0",
|
||||
[DnsmasqConfKeys.SharedNetwork] = "sharednet,192.168.10.0,255.255.255.0",
|
||||
[DnsmasqConfKeys.BootpDynamic] = "tag:bootp",
|
||||
[DnsmasqConfKeys.DhcpAlternatePort] = "1067",
|
||||
[DnsmasqConfKeys.DhcpDuid] = "00:01:00:01:2a:11:22:33:44:55:66:77:88:99",
|
||||
[DnsmasqConfKeys.DhcpLuascript] = "/etc/dnsmasq/dhcp.lua",
|
||||
[DnsmasqConfKeys.DhcpScript] = "/usr/local/bin/dhcp-script.sh",
|
||||
[DnsmasqConfKeys.DhcpScriptuser] = "dnsmasq",
|
||||
[DnsmasqConfKeys.DhcpPxeVendor] = "PXEClient",
|
||||
|
||||
// --- TFTP / PXE ---
|
||||
[DnsmasqConfKeys.TftpRoot] = "/srv/tftp",
|
||||
[DnsmasqConfKeys.PxePrompt] = "\"Boot menu\",5",
|
||||
[DnsmasqConfKeys.PxeService] = "x86PC,\"PXE Boot\",pxelinux",
|
||||
[DnsmasqConfKeys.DhcpOptionPxe] = "vendor:PXEClient,1,0.0.0.0",
|
||||
|
||||
// --- DNSSEC ---
|
||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = "no",
|
||||
[DnsmasqConfKeys.TrustAnchor] = ".,20326,8,2,e06d44b80b8f1d39...",
|
||||
[DnsmasqConfKeys.AddCpeId] = "my-cpe-id",
|
||||
[DnsmasqConfKeys.DnssecTimestamp] = "/var/lib/misc/dnsmasq.timestamp",
|
||||
[DnsmasqConfKeys.DnssecLimits] = "150,1500",
|
||||
|
||||
// --- Cache ---
|
||||
[DnsmasqConfKeys.CacheRr] = "A,AAAA,TXT",
|
||||
[DnsmasqConfKeys.Dumpfile] = "/tmp/dnsmasq.dump",
|
||||
[DnsmasqConfKeys.Dumpmask] = "0x0000ffff",
|
||||
[DnsmasqConfKeys.UseStaleCache] = "seconds, e.g. 60",
|
||||
[DnsmasqConfKeys.AddMac] = "base64 or text",
|
||||
[DnsmasqConfKeys.AddSubnet] = "IPv4/IPv6 prefix, e.g. 24,96",
|
||||
[DnsmasqConfKeys.Umbrella] = "org-id,asset-id",
|
||||
|
||||
// --- Process / networking ---
|
||||
[DnsmasqConfKeys.Interface] = "eth0",
|
||||
[DnsmasqConfKeys.ListenAddress] = "192.168.1.1",
|
||||
[DnsmasqConfKeys.ExceptInterface] = "lo",
|
||||
[DnsmasqConfKeys.LogAsync] = "queue size, e.g. 25",
|
||||
[DnsmasqConfKeys.PidFile] = "/run/dnsmasq.pid",
|
||||
[DnsmasqConfKeys.User] = "dnsmasq",
|
||||
[DnsmasqConfKeys.Group] = "dnsmasq",
|
||||
[DnsmasqConfKeys.LogFacility] = "local0",
|
||||
[DnsmasqConfKeys.EnableDbus] = "uk.org.thekelleys.dnsmasq",
|
||||
[DnsmasqConfKeys.EnableUbus] = "dnsmasq",
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
/// <summary>Returns the placeholder value for an option, or null when none is defined.</summary>
|
||||
public static string? Get(string optionName) =>
|
||||
Values.TryGetValue(optionName, out var value) ? value : null;
|
||||
}
|
||||
@@ -57,6 +57,11 @@ public static class DnsmasqOptionTooltips
|
||||
[DnsmasqConfKeys.NoDhcpv6Interface] = "No DHCPv6/RA on these interfaces. Can repeat.",
|
||||
[DnsmasqConfKeys.Ipset] = "Add resolved IPs to ipset (domain, set names). Can repeat.",
|
||||
[DnsmasqConfKeys.Nftset] = "Add resolved IPs to nftables sets. Can repeat.",
|
||||
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = "Use conntrack marks to allowlist which queries are forwarded (with connmark-allowlist).",
|
||||
[DnsmasqConfKeys.NoRoundRobin] = "Do not rotate the order of upstream servers for each query.",
|
||||
[DnsmasqConfKeys.DnsForwardMax] = "Maximum number of concurrent DNS forwards (default 150).",
|
||||
[DnsmasqConfKeys.ConnmarkAllowlist] = "Conntrack marks that allowlist which queries are forwarded (with connmark-allowlist-enable). Can repeat.",
|
||||
[DnsmasqConfKeys.Do0x20Encode] = "Use 0x20 encoding in queries to detect DNS poisoning (Enabled), disable it (Disabled), or default (not set).",
|
||||
|
||||
// --- DNS records ---
|
||||
[DnsmasqConfKeys.Domain] = "Local domain(s); optional IP. Can repeat (e.g. domain=home,192.168.1.1).",
|
||||
@@ -72,12 +77,33 @@ public static class DnsmasqOptionTooltips
|
||||
[DnsmasqConfKeys.HostRecord] = "A/AAAA host records (name, IP). Can repeat.",
|
||||
[DnsmasqConfKeys.DynamicHost] = "Dynamic host entries. Can repeat.",
|
||||
[DnsmasqConfKeys.InterfaceName] = "Name per interface (for localise-queries). Can repeat.",
|
||||
[DnsmasqConfKeys.CaaRecord] = "CAA record (domain, flags, tag, value). Can repeat.",
|
||||
[DnsmasqConfKeys.DnsRr] = "Arbitrary DNS resource record. Can repeat.",
|
||||
[DnsmasqConfKeys.SynthDomain] = "Synthesize answers for a domain. Can repeat.",
|
||||
[DnsmasqConfKeys.AuthZone] = "Authoritative zone (domain, subnet). Can repeat.",
|
||||
[DnsmasqConfKeys.AuthSoa] = "SOA record for authoritative zone. Can repeat.",
|
||||
[DnsmasqConfKeys.AuthSecServers] = "Secondary servers for zone transfer. Can repeat.",
|
||||
[DnsmasqConfKeys.AuthPeer] = "Peers for zone transfer. Can repeat.",
|
||||
|
||||
// --- DHCP ---
|
||||
[DnsmasqConfKeys.DhcpAuthoritative] = "DHCP server is authoritative; required for DHCP to work.",
|
||||
[DnsmasqConfKeys.DhcpRapidCommit] = "DHCPv4 Rapid Commit (RFC 4039); use only if single server or all commit.",
|
||||
[DnsmasqConfKeys.Leasequery] = "Respond to DHCPLEASEQUERY messages (e.g. from relay).",
|
||||
[DnsmasqConfKeys.DhcpGenerateNames] = "Generate names for DHCP clients from their hostname or MAC.",
|
||||
[DnsmasqConfKeys.DhcpBroadcast] = "Use broadcast to communicate with DHCP clients when unicast is not possible.",
|
||||
[DnsmasqConfKeys.DhcpSequentialIp] = "Allocate DHCP addresses sequentially from the range.",
|
||||
[DnsmasqConfKeys.DhcpIgnoreClid] = "Ignore client identifier (use MAC only) for DHCP.",
|
||||
[DnsmasqConfKeys.BootpDynamic] = "Allow dynamic allocation for BOOTP clients.",
|
||||
[DnsmasqConfKeys.NoPing] = "Do not ping the gateway before offering a DHCP lease.",
|
||||
[DnsmasqConfKeys.ScriptArp] = "Call dhcp-script with extra 'arp' or 'arp-old arp-new' for ARP table updates.",
|
||||
[DnsmasqConfKeys.ScriptOnRenewal] = "Call dhcp-script on lease renewal (in addition to first assignment).",
|
||||
[DnsmasqConfKeys.DhcpNoOverride] = "Do not replace existing DNS/DHCP data for a client (e.g. from /etc/hosts).",
|
||||
[DnsmasqConfKeys.DhcpScript] = "Script run on lease add/delete (add|del, MAC, IP, hostname).",
|
||||
[DnsmasqConfKeys.LeasefileRo] = "Do not create or truncate the DHCP lease file.",
|
||||
[DnsmasqConfKeys.DhcpAlternatePort] = "Use alternate port for DHCP (e.g. 67).",
|
||||
[DnsmasqConfKeys.DhcpDuid] = "DHCP unique identifier (DUID) for the server.",
|
||||
[DnsmasqConfKeys.DhcpLuascript] = "Lua script for DHCP (e.g. for custom logic).",
|
||||
[DnsmasqConfKeys.DhcpScriptuser] = "User to run dhcp-script as (requires root).",
|
||||
[DnsmasqConfKeys.DhcpLeasefile] = "Path to the DHCP lease file.",
|
||||
[DnsmasqConfKeys.DhcpLeaseMax] = "Maximum number of DHCP leases to hold.",
|
||||
[DnsmasqConfKeys.DhcpTtl] = "TTL for DHCP names in the DNS cache.",
|
||||
@@ -92,12 +118,21 @@ public static class DnsmasqOptionTooltips
|
||||
[DnsmasqConfKeys.DhcpHostsfile] = "File(s) containing dhcp-host-style lines. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpOptsfile] = "File containing dhcp-option-style lines.",
|
||||
[DnsmasqConfKeys.DhcpHostsdir] = "Directory of files read like dhcp-hostsfile (watched for changes). Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpOptsdir] = "Directory of files read like dhcp-optsfile (watched for changes). Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpBoot] = "Boot file and server for PXE. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpIgnore] = "Ignore DHCP clients (e.g. by tag). Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpVendorclass] = "Match by DHCP vendor class. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpUserclass] = "Match by DHCP user class. Can repeat.",
|
||||
[DnsmasqConfKeys.RaParam] = "Router advertisement parameters (DHCPv6/RA). Can repeat.",
|
||||
[DnsmasqConfKeys.Slaac] = "SLAAC host part for DHCPv6/RA. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpRelay] = "Relay DHCP to another server. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpCircuitid] = "Circuit ID for DHCP relay. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpRemoteid] = "Remote ID for DHCP relay. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpSubscrid] = "Subscriber ID for DHCP relay. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpProxy] = "DHCP proxy (relay) configuration. Can repeat.",
|
||||
[DnsmasqConfKeys.TagIf] = "Set tag when request from interface. Can repeat.",
|
||||
[DnsmasqConfKeys.BridgeInterface] = "Bridge interfaces for DHCP. Can repeat.",
|
||||
[DnsmasqConfKeys.SharedNetwork] = "Shared network for DHCP. Can repeat.",
|
||||
|
||||
// --- TFTP / PXE ---
|
||||
[DnsmasqConfKeys.EnableTftp] = "Enable the built-in TFTP server.",
|
||||
@@ -107,10 +142,17 @@ public static class DnsmasqOptionTooltips
|
||||
[DnsmasqConfKeys.TftpRoot] = "Root directory for TFTP files.",
|
||||
[DnsmasqConfKeys.PxePrompt] = "PXE boot prompt (e.g. timeout,0 to disable).",
|
||||
[DnsmasqConfKeys.PxeService] = "PXE service definitions. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpPxeVendor] = "PXE vendor class string (e.g. for PXE matching).",
|
||||
[DnsmasqConfKeys.DhcpOptionPxe] = "PXE option (e.g. option 43). Can repeat.",
|
||||
|
||||
// --- DNSSEC ---
|
||||
[DnsmasqConfKeys.Dnssec] = "Validate DNSSEC on replies from upstream.",
|
||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = "Treat unsigned zones as bogus.",
|
||||
[DnsmasqConfKeys.DnssecNoTimecheck] = "Do not validate DNSSEC signatures against the current time (e.g. for testing).",
|
||||
[DnsmasqConfKeys.DnssecDebug] = "Log DNSSEC validation failures at debug level.",
|
||||
[DnsmasqConfKeys.AddCpeId] = "Add CPE-ID to DHCP requests (for identification).",
|
||||
[DnsmasqConfKeys.DnssecTimestamp] = "Path to file for DNSSEC timestamp (e.g. for validation).",
|
||||
[DnsmasqConfKeys.DnssecLimits] = "DNSSEC validation limits (e.g. max signed zones).",
|
||||
[DnsmasqConfKeys.TrustAnchor] = "DNSSEC trust anchors. Can repeat.",
|
||||
|
||||
// --- Cache ---
|
||||
@@ -121,6 +163,14 @@ public static class DnsmasqOptionTooltips
|
||||
[DnsmasqConfKeys.MaxTtl] = "Maximum TTL for cached responses (cap on upstream TTL).",
|
||||
[DnsmasqConfKeys.MaxCacheTtl] = "Maximum TTL for positive cache entries.",
|
||||
[DnsmasqConfKeys.MinCacheTtl] = "Minimum TTL for positive cache entries.",
|
||||
[DnsmasqConfKeys.Dumpfile] = "Dump cache to this file (for debugging).",
|
||||
[DnsmasqConfKeys.Dumpmask] = "Mask for cache dump (which data to include).",
|
||||
[DnsmasqConfKeys.UseStaleCache] = "Use stale cache when upstream is unavailable. Off, On (key-only), or custom seconds.",
|
||||
[DnsmasqConfKeys.AddMac] = "Add MAC address to DNS queries. Off, On (key-only), or custom value.",
|
||||
[DnsmasqConfKeys.StripMac] = "Strip MAC address information from forwarded DNS queries.",
|
||||
[DnsmasqConfKeys.AddSubnet] = "Add subnet to DNS queries. Off, On (key-only), or custom value.",
|
||||
[DnsmasqConfKeys.StripSubnet] = "Strip subnet (ECS) information from forwarded DNS queries.",
|
||||
[DnsmasqConfKeys.Umbrella] = "Cisco Umbrella device identity. Off, On (key-only), or custom value.",
|
||||
|
||||
// --- Process & networking ---
|
||||
[DnsmasqConfKeys.NoPoll] = "Do not poll /etc/resolv.conf for changes.",
|
||||
@@ -143,6 +193,10 @@ public static class DnsmasqOptionTooltips
|
||||
[DnsmasqConfKeys.KeepInForeground] = "Do not daemonise; stay in foreground (e.g. under systemd).",
|
||||
[DnsmasqConfKeys.NoDaemon] = "Debug mode: no fork, no pid file, log to stderr.",
|
||||
[DnsmasqConfKeys.Conntrack] = "Linux connection-tracking mark for UBus/query filtering (platform-dependent).",
|
||||
[DnsmasqConfKeys.QuietDhcp] = "Suppress routine DHCP logging.",
|
||||
[DnsmasqConfKeys.QuietDhcp6] = "Suppress routine DHCPv6 logging.",
|
||||
[DnsmasqConfKeys.QuietRa] = "Suppress routine router advertisement logging.",
|
||||
[DnsmasqConfKeys.QuietTftp] = "Suppress routine TFTP logging.",
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
/// <summary>Returns a short tooltip for the option, or null if none is defined.</summary>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
/// <summary>Dnsmasq compile-time feature required for certain options.</summary>
|
||||
public enum DnsmasqFeature
|
||||
{
|
||||
Dhcp,
|
||||
Tftp,
|
||||
Dnssec,
|
||||
Dbus
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps effective-config option names to the dnsmasq compile-time feature they require.
|
||||
/// Used to disable option editors and block writes when the running dnsmasq binary does not support the feature.
|
||||
/// </summary>
|
||||
public static class EffectiveConfigFeatureRequirements
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, DnsmasqFeature> RequiredByOption =
|
||||
new Dictionary<string, DnsmasqFeature>(StringComparer.Ordinal)
|
||||
{
|
||||
// DNSSEC
|
||||
[DnsmasqConfKeys.Dnssec] = DnsmasqFeature.Dnssec,
|
||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = DnsmasqFeature.Dnssec,
|
||||
[DnsmasqConfKeys.ProxyDnssec] = DnsmasqFeature.Dnssec,
|
||||
[DnsmasqConfKeys.TrustAnchor] = DnsmasqFeature.Dnssec,
|
||||
[DnsmasqConfKeys.DnssecNoTimecheck] = DnsmasqFeature.Dnssec,
|
||||
[DnsmasqConfKeys.DnssecDebug] = DnsmasqFeature.Dnssec,
|
||||
[DnsmasqConfKeys.DnssecTimestamp] = DnsmasqFeature.Dnssec,
|
||||
[DnsmasqConfKeys.DnssecLimits] = DnsmasqFeature.Dnssec,
|
||||
// DHCP
|
||||
[DnsmasqConfKeys.DhcpLeasefile] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpLeaseMax] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpRange] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpHost] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpOption] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpOptionForce] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpAuthoritative] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpRapidCommit] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpGenerateNames] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpBroadcast] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpBoot] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpScript] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpHostsfile] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpOptsfile] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpHostsdir] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpOptsdir] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.Leasequery] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.LeasefileRo] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpTtl] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpMatch] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpIgnore] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpIgnoreNames] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpRelay] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpSequentialIp] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpIgnoreClid] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.BootpDynamic] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.NoPing] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.ScriptArp] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.ScriptOnRenewal] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpNoOverride] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpAlternatePort] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpDuid] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpLuascript] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpScriptuser] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpMac] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpNameMatch] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpVendorclass] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpUserclass] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.RaParam] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.Slaac] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpCircuitid] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpRemoteid] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpSubscrid] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpProxy] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpPxeVendor] = DnsmasqFeature.Dhcp,
|
||||
[DnsmasqConfKeys.DhcpOptionPxe] = DnsmasqFeature.Dhcp,
|
||||
// TFTP
|
||||
[DnsmasqConfKeys.EnableTftp] = DnsmasqFeature.Tftp,
|
||||
[DnsmasqConfKeys.TftpSecure] = DnsmasqFeature.Tftp,
|
||||
[DnsmasqConfKeys.TftpNoFail] = DnsmasqFeature.Tftp,
|
||||
[DnsmasqConfKeys.TftpNoBlocksize] = DnsmasqFeature.Tftp,
|
||||
[DnsmasqConfKeys.TftpRoot] = DnsmasqFeature.Tftp,
|
||||
[DnsmasqConfKeys.PxePrompt] = DnsmasqFeature.Tftp,
|
||||
[DnsmasqConfKeys.PxeService] = DnsmasqFeature.Tftp,
|
||||
// DBus
|
||||
[DnsmasqConfKeys.EnableDbus] = DnsmasqFeature.Dbus,
|
||||
};
|
||||
|
||||
public static DnsmasqFeature? GetRequiredFeature(string optionName) =>
|
||||
RequiredByOption.TryGetValue(optionName, out var f) ? f : null;
|
||||
|
||||
public static bool IsSupported(DnsmasqFeature feature, DnsmasqServiceStatus? status)
|
||||
{
|
||||
if (status == null) return true;
|
||||
return feature switch
|
||||
{
|
||||
DnsmasqFeature.Dhcp => status.DnsmasqSupportsDhcp,
|
||||
DnsmasqFeature.Tftp => status.DnsmasqSupportsTftp,
|
||||
DnsmasqFeature.Dnssec => status.DnsmasqSupportsDnssec,
|
||||
DnsmasqFeature.Dbus => status.DnsmasqSupportsDbus,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>User-facing tooltip when an option is disabled because the running dnsmasq was built without the required feature.</summary>
|
||||
public static string GetUnsupportedReason(DnsmasqFeature feature) => feature switch
|
||||
{
|
||||
DnsmasqFeature.Dhcp => "Your dnsmasq was built without DHCP support, so this option can't be used. Install or build dnsmasq with DHCP enabled to change it.",
|
||||
DnsmasqFeature.Tftp => "Your dnsmasq was built without TFTP support, so this option can't be used. Install or build dnsmasq with TFTP enabled to change it.",
|
||||
DnsmasqFeature.Dnssec => "Your dnsmasq was built without DNSSEC support, so this option can't be used. Install or build dnsmasq with DNSSEC enabled to change it.",
|
||||
DnsmasqFeature.Dbus => "Your dnsmasq was built without DBus support, so this option can't be used. Install or build dnsmasq with DBus enabled to change it.",
|
||||
_ => "This option isn't available with your current dnsmasq build."
|
||||
};
|
||||
|
||||
/// <summary>Returns (isDisabled, reason) for the option given current status. Used to set descriptor capability flags.</summary>
|
||||
public static (bool IsDisabled, string? Reason) GetCapabilityDisabled(string optionName, DnsmasqServiceStatus? status)
|
||||
{
|
||||
var feature = GetRequiredFeature(optionName);
|
||||
if (feature == null || status == null) return (false, null);
|
||||
if (IsSupported(feature.Value, status)) return (false, null);
|
||||
return (true, GetUnsupportedReason(feature.Value));
|
||||
}
|
||||
|
||||
/// <summary>True when the option is supported by the given compile-time capabilities (for save-path guard).</summary>
|
||||
public static bool IsSupportedByCapabilities(string optionName, DnsmasqCompileCapabilities capabilities)
|
||||
{
|
||||
var feature = GetRequiredFeature(optionName);
|
||||
if (feature == null) return true;
|
||||
return feature.Value switch
|
||||
{
|
||||
DnsmasqFeature.Dhcp => capabilities.Dhcp,
|
||||
DnsmasqFeature.Tftp => capabilities.Tftp,
|
||||
DnsmasqFeature.Dnssec => capabilities.Dnssec,
|
||||
DnsmasqFeature.Dbus => capabilities.Dbus,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,12 @@ public static class EffectiveConfigFieldBuilder
|
||||
return Array.Empty<EffectiveConfigFieldDescriptor>();
|
||||
|
||||
var list = new List<EffectiveConfigFieldDescriptor>();
|
||||
AddAllDescriptors(list, status, registry);
|
||||
return list;
|
||||
}
|
||||
|
||||
private static void AddAllDescriptors(List<EffectiveConfigFieldDescriptor> list, DnsmasqServiceStatus? status, IEffectiveConfigRenderFragmentRegistry? registry)
|
||||
{
|
||||
// Hosts
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AddnHosts, status, null, null, s => ToValueWithSourceList(s?.AddnHostsPaths, Sources(s)?.AddnHostsPaths));
|
||||
@@ -87,6 +92,11 @@ public static class EffectiveConfigFieldBuilder
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.FastDnsRetry, status, s => Config(s)?.FastDnsRetry, s => Sources(s)?.FastDnsRetry, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Ipset, status, null, null, Items(ec => ec?.IpsetValues, src => src?.IpsetValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Nftset, status, null, null, Items(ec => ec?.NftsetValues, src => src?.NftsetValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ConnmarkAllowlistEnable, status, s => Config(s)?.ConnmarkAllowlistEnable, s => Sources(s)?.ConnmarkAllowlistEnable, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoRoundRobin, status, s => Config(s)?.NoRoundRobin, s => Sources(s)?.NoRoundRobin, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DnsForwardMax, status, s => Config(s)?.DnsForwardMax, s => Sources(s)?.DnsForwardMax, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ConnmarkAllowlist, status, null, null, Items(ec => ec?.ConnmarkAllowlistValues, src => src?.ConnmarkAllowlistValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Do0x20Encode, status, s => Config(s)?.Do0x20EncodeState, s => Sources(s)?.Do0x20Encode, null);
|
||||
|
||||
// DNS records
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Domain, status, null, null, Items(ec => ec?.DomainValues, src => src?.DomainValues));
|
||||
@@ -99,6 +109,13 @@ public static class EffectiveConfigFieldBuilder
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.HostRecord, status, null, null, Items(ec => ec?.HostRecordValues, src => src?.HostRecordValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DynamicHost, status, null, null, Items(ec => ec?.DynamicHostValues, src => src?.DynamicHostValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.InterfaceName, status, null, null, Items(ec => ec?.InterfaceNameValues, src => src?.InterfaceNameValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.CaaRecord, status, null, null, Items(ec => ec?.CaaRecordValues, src => src?.CaaRecordValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DnsRr, status, null, null, Items(ec => ec?.DnsRrValues, src => src?.DnsRrValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.SynthDomain, status, null, null, Items(ec => ec?.SynthDomainValues, src => src?.SynthDomainValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AuthZone, status, null, null, Items(ec => ec?.AuthZoneValues, src => src?.AuthZoneValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AuthSoa, status, null, null, Items(ec => ec?.AuthSoaValues, src => src?.AuthSoaValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AuthSecServers, status, null, null, Items(ec => ec?.AuthSecServersValues, src => src?.AuthSecServersValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AuthPeer, status, null, null, Items(ec => ec?.AuthPeerValues, src => src?.AuthPeerValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.MxTarget, status, s => Config(s)?.MxTarget, s => Sources(s)?.MxTarget, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Localmx, status, s => Config(s)?.Localmx, s => Sources(s)?.Localmx, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Selfmx, status, s => Config(s)?.Selfmx, s => Sources(s)?.Selfmx, null);
|
||||
@@ -107,7 +124,21 @@ public static class EffectiveConfigFieldBuilder
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpRapidCommit, status, s => Config(s)?.DhcpRapidCommit, s => Sources(s)?.DhcpRapidCommit, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Leasequery, status, null, null, Items(ec => ec?.LeasequeryValues, src => src?.LeasequeryValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpGenerateNames, status, s => Config(s)?.DhcpGenerateNames, s => Sources(s)?.DhcpGenerateNames, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpBroadcast, status, s => Config(s)?.DhcpBroadcast, s => Sources(s)?.DhcpBroadcast, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpSequentialIp, status, s => Config(s)?.DhcpSequentialIp, s => Sources(s)?.DhcpSequentialIp, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpIgnoreClid, status, s => Config(s)?.DhcpIgnoreClid, s => Sources(s)?.DhcpIgnoreClid, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.BootpDynamic, status, s => Config(s)?.BootpDynamic, s => Sources(s)?.BootpDynamic, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoPing, status, s => Config(s)?.NoPing, s => Sources(s)?.NoPing, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ScriptArp, status, s => Config(s)?.ScriptArp, s => Sources(s)?.ScriptArp, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ScriptOnRenewal, status, s => Config(s)?.ScriptOnRenewal, s => Sources(s)?.ScriptOnRenewal, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpNoOverride, status, s => Config(s)?.DhcpNoOverride, s => Sources(s)?.DhcpNoOverride, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpScript, status, s => Config(s)?.DhcpScriptPath, s => Sources(s)?.DhcpScriptPath, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpAlternatePort, status, s => Config(s)?.DhcpAlternatePort, s => Sources(s)?.DhcpAlternatePort, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpDuid, status, s => Config(s)?.DhcpDuid, s => Sources(s)?.DhcpDuid, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpLuascript, status, s => Config(s)?.DhcpLuascriptPath, s => Sources(s)?.DhcpLuascriptPath, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpScriptuser, status, s => Config(s)?.DhcpScriptuser, s => Sources(s)?.DhcpScriptuser, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpLeasefile, status, s => Config(s)?.DhcpLeaseFilePath, s => Sources(s)?.DhcpLeaseFilePath, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpLeaseMax, status, s => Config(s)?.DhcpLeaseMax, s => Sources(s)?.DhcpLeaseMax, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpTtl, status, s => Config(s)?.DhcpTtl, s => Sources(s)?.DhcpTtl, null);
|
||||
@@ -122,6 +153,15 @@ public static class EffectiveConfigFieldBuilder
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpHostsfile, status, null, null, Items(ec => ec?.DhcpHostsfilePaths, src => src?.DhcpHostsfilePaths));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpOptsfile, status, null, null, Items(ec => ec?.DhcpOptsfilePaths, src => src?.DhcpOptsfilePaths));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpHostsdir, status, null, null, Items(ec => ec?.DhcpHostsdirPaths, src => src?.DhcpHostsdirPaths));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpOptsdir, status, null, null, Items(ec => ec?.DhcpOptsdirPaths, src => src?.DhcpOptsdirPaths));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpRelay, status, null, null, Items(ec => ec?.DhcpRelayValues, src => src?.DhcpRelayValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpCircuitid, status, null, null, Items(ec => ec?.DhcpCircuitidValues, src => src?.DhcpCircuitidValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpRemoteid, status, null, null, Items(ec => ec?.DhcpRemoteidValues, src => src?.DhcpRemoteidValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpSubscrid, status, null, null, Items(ec => ec?.DhcpSubscridValues, src => src?.DhcpSubscridValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpProxy, status, null, null, Items(ec => ec?.DhcpProxyValues, src => src?.DhcpProxyValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.TagIf, status, null, null, Items(ec => ec?.TagIfValues, src => src?.TagIfValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.BridgeInterface, status, null, null, Items(ec => ec?.BridgeInterfaceValues, src => src?.BridgeInterfaceValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.SharedNetwork, status, null, null, Items(ec => ec?.SharedNetworkValues, src => src?.SharedNetworkValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpBoot, status, null, null, Items(ec => ec?.DhcpBootValues, src => src?.DhcpBootValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpIgnore, status, null, null, Items(ec => ec?.DhcpIgnoreValues, src => src?.DhcpIgnoreValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpVendorclass, status, null, null, Items(ec => ec?.DhcpVendorclassValues, src => src?.DhcpVendorclassValues));
|
||||
@@ -137,11 +177,18 @@ public static class EffectiveConfigFieldBuilder
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.TftpRoot, status, s => Config(s)?.TftpRootPath, s => Sources(s)?.TftpRootPath, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.PxePrompt, status, s => Config(s)?.PxePrompt, s => Sources(s)?.PxePrompt, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.PxeService, status, null, null, Items(ec => ec?.PxeServiceValues, src => src?.PxeServiceValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpPxeVendor, status, s => Config(s)?.DhcpPxeVendor, s => Sources(s)?.DhcpPxeVendor, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpOptionPxe, status, null, null, Items(ec => ec?.DhcpOptionPxeValues, src => src?.DhcpOptionPxeValues));
|
||||
|
||||
// DNSSEC
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Dnssec, status, s => Config(s)?.Dnssec, s => Sources(s)?.Dnssec, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DnssecCheckUnsigned, status, s => Config(s)?.DnssecCheckUnsigned, s => Sources(s)?.DnssecCheckUnsigned, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DnssecNoTimecheck, status, s => Config(s)?.DnssecNoTimecheck, s => Sources(s)?.DnssecNoTimecheck, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DnssecDebug, status, s => Config(s)?.DnssecDebug, s => Sources(s)?.DnssecDebug, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ProxyDnssec, status, s => Config(s)?.ProxyDnssec, s => Sources(s)?.ProxyDnssec, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AddCpeId, status, s => Config(s)?.AddCpeId, s => Sources(s)?.AddCpeId, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DnssecTimestamp, status, s => Config(s)?.DnssecTimestamp, s => Sources(s)?.DnssecTimestamp, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DnssecLimits, status, s => Config(s)?.DnssecLimits, s => Sources(s)?.DnssecLimits, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.TrustAnchor, status, null, null, Items(ec => ec?.TrustAnchorValues, src => src?.TrustAnchorValues));
|
||||
|
||||
// Cache
|
||||
@@ -149,6 +196,14 @@ public static class EffectiveConfigFieldBuilder
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.CacheSize, status, s => Config(s)?.CacheSize, s => Sources(s)?.CacheSize, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LocalTtl, status, s => Config(s)?.LocalTtl, s => Sources(s)?.LocalTtl, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoNegcache, status, s => Config(s)?.NoNegcache, s => Sources(s)?.NoNegcache, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Dumpfile, status, s => Config(s)?.DumpfilePath, s => Sources(s)?.DumpfilePath, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Dumpmask, status, s => Config(s)?.Dumpmask, s => Sources(s)?.Dumpmask, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.UseStaleCache, status, s => Config(s)?.UseStaleCache, s => Sources(s)?.UseStaleCache, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AddMac, status, s => Config(s)?.AddMac, s => Sources(s)?.AddMac, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.StripMac, status, s => Config(s)?.StripMac, s => Sources(s)?.StripMac, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AddSubnet, status, s => Config(s)?.AddSubnet, s => Sources(s)?.AddSubnet, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.StripSubnet, status, s => Config(s)?.StripSubnet, s => Sources(s)?.StripSubnet, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Umbrella, status, s => Config(s)?.Umbrella, s => Sources(s)?.Umbrella, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NegTtl, status, s => Config(s)?.NegTtl, s => Sources(s)?.NegTtl, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.MaxTtl, status, s => Config(s)?.MaxTtl, s => Sources(s)?.MaxTtl, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.MaxCacheTtl, status, s => Config(s)?.MaxCacheTtl, s => Sources(s)?.MaxCacheTtl, null);
|
||||
@@ -179,8 +234,10 @@ public static class EffectiveConfigFieldBuilder
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.KeepInForeground, status, s => Config(s)?.KeepInForeground, s => Sources(s)?.KeepInForeground, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoDaemon, status, s => Config(s)?.NoDaemon, s => Sources(s)?.NoDaemon, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Conntrack, status, s => Config(s)?.Conntrack, s => Sources(s)?.Conntrack, null);
|
||||
|
||||
return list;
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.QuietDhcp, status, s => Config(s)?.QuietDhcp, s => Sources(s)?.QuietDhcp, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.QuietDhcp6, status, s => Config(s)?.QuietDhcp6, s => Sources(s)?.QuietDhcp6, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.QuietRa, status, s => Config(s)?.QuietRa, s => Sources(s)?.QuietRa, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.QuietTftp, status, s => Config(s)?.QuietTftp, s => Sources(s)?.QuietTftp, null);
|
||||
}
|
||||
|
||||
/// <summary>Creates a single-value descriptor; uses registry factory when registered (e.g. integer), otherwise a plain descriptor. Used by <see cref="EffectiveConfigFieldDescriptorListExtensions.AddSingleDescriptor"/>.</summary>
|
||||
@@ -194,9 +251,11 @@ public static class EffectiveConfigFieldBuilder
|
||||
{
|
||||
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
|
||||
var factory = registry?.GetDescriptorFactory(sectionId, optionName);
|
||||
if (factory != null)
|
||||
return (EffectiveConfigFieldDescriptor)factory(sectionId, optionName, status, getValue, getSource, getItems);
|
||||
return Single(optionName, status, getValue, getSource, getItems);
|
||||
var descriptor = factory != null
|
||||
? (EffectiveConfigFieldDescriptor)factory(sectionId, optionName, status, getValue, getSource, getItems)
|
||||
: Single(optionName, status, getValue, getSource, getItems);
|
||||
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(optionName, status);
|
||||
return descriptor with { IsCapabilityDisabled = isDisabled, CapabilityDisabledReason = reason };
|
||||
}
|
||||
|
||||
private static EffectiveConfigFieldDescriptor Single(string optionName, DnsmasqServiceStatus? status,
|
||||
|
||||
+8
-3
@@ -22,7 +22,7 @@ public static class EffectiveConfigFieldDescriptorListExtensions
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
|
||||
{
|
||||
if (EffectiveConfigOptionKindMap.GetKind(optionName) == EffectiveConfigFieldKind.Multi)
|
||||
list.AddMultiDescriptor(optionName, status, getItems);
|
||||
list.AddMultiDescriptor(registry, optionName, status, getItems);
|
||||
else
|
||||
list.AddSingleDescriptor(registry, optionName, status, getValue, getSource, getItems);
|
||||
}
|
||||
@@ -43,15 +43,20 @@ public static class EffectiveConfigFieldDescriptorListExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a multi-value field descriptor (e.g. server, address, dhcp-range).
|
||||
/// Adds a multi-value field descriptor. Every multi option must be registered in the registry; uses the registry's multi descriptor factory.
|
||||
/// </summary>
|
||||
public static void AddMultiDescriptor(
|
||||
this List<EffectiveConfigFieldDescriptor> list,
|
||||
IEffectiveConfigRenderFragmentRegistry? registry,
|
||||
string optionName,
|
||||
DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
|
||||
{
|
||||
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
|
||||
list.Add(new EffectiveConfigFieldDescriptor(sectionId, optionName, true, status, null, null, getItems));
|
||||
var factory = registry?.GetMultiDescriptorFactory(sectionId, optionName)
|
||||
?? throw new InvalidOperationException($"No multi descriptor factory registered for option '{optionName}' (section '{sectionId}'). Register all multi options via RegisterMultiDescriptor.");
|
||||
var descriptor = factory(sectionId, optionName, status, getItems);
|
||||
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(optionName, status);
|
||||
list.Add(descriptor with { IsCapabilityDisabled = isDisabled, CapabilityDisabledReason = reason });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ public static class EffectiveConfigOptionKindMap
|
||||
[DnsmasqConfKeys.DhcpHostsfile] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpOptsfile] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpHostsdir] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpOptsdir] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpBoot] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpIgnore] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpVendorclass] = EffectiveConfigFieldKind.Multi,
|
||||
@@ -151,6 +152,59 @@ public static class EffectiveConfigOptionKindMap
|
||||
[DnsmasqConfKeys.KeepInForeground] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.NoDaemon] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Conntrack] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.NoRoundRobin] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DnssecNoTimecheck] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DnssecDebug] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Leasequery] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpGenerateNames] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpBroadcast] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpSequentialIp] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpIgnoreClid] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.BootpDynamic] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.NoPing] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.ScriptArp] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.ScriptOnRenewal] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpNoOverride] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.QuietDhcp] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.QuietDhcp6] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.QuietRa] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.QuietTftp] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DnsForwardMax] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Dumpfile] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Dumpmask] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.AddCpeId] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DnssecTimestamp] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DnssecLimits] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpAlternatePort] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpDuid] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpLuascript] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpScriptuser] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpPxeVendor] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.ConnmarkAllowlist] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.CaaRecord] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DnsRr] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.SynthDomain] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.AuthZone] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.AuthSoa] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.AuthSecServers] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.AuthPeer] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpRelay] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpCircuitid] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpRemoteid] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpSubscrid] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpProxy] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.TagIf] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.BridgeInterface] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.SharedNetwork] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpOptionPxe] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.UseStaleCache] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.AddMac] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.StripMac] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.AddSubnet] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.StripSubnet] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Umbrella] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Do0x20Encode] = EffectiveConfigFieldKind.Single,
|
||||
};
|
||||
|
||||
/// <summary>Returns Single or Multi for the given option name; defaults to Single if unknown.</summary>
|
||||
@@ -162,6 +216,8 @@ public static class EffectiveConfigOptionKindMap
|
||||
/// Maps dnsmasq option names to parser behavior (LastWins / Flag / Multi). Used by ConfigSetCache so it
|
||||
/// dispatches to GetLastValue, GetFlag, or GetMultiValue from one place. Special cases (NoHosts value,
|
||||
/// AddnHosts, DhcpLeaseFilePath) remain explicit in the cache.
|
||||
/// For special options, behavior is sourced from <see cref="EffectiveConfigSpecialOptionSemantics"/>
|
||||
/// to keep parse/write/validation semantics centralized.
|
||||
/// </summary>
|
||||
public static class EffectiveConfigParserBehaviorMap
|
||||
{
|
||||
@@ -238,6 +294,7 @@ public static class EffectiveConfigParserBehaviorMap
|
||||
[DnsmasqConfKeys.DhcpHostsfile] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpOptsfile] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpHostsdir] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpOptsdir] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpBoot] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpIgnore] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpVendorclass] = EffectiveConfigParserBehavior.Multi,
|
||||
@@ -252,7 +309,7 @@ public static class EffectiveConfigParserBehaviorMap
|
||||
[DnsmasqConfKeys.PxePrompt] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.PxeService] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Dnssec] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.ProxyDnssec] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.TrustAnchor] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.CacheRr] = EffectiveConfigParserBehavior.Multi,
|
||||
@@ -286,10 +343,66 @@ public static class EffectiveConfigParserBehaviorMap
|
||||
[DnsmasqConfKeys.LogDhcp] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.KeepInForeground] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.NoDaemon] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.Conntrack] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.Conntrack] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.NoRoundRobin] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DnssecNoTimecheck] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DnssecDebug] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.Leasequery] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpGenerateNames] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpBroadcast] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpSequentialIp] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DhcpIgnoreClid] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.BootpDynamic] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.NoPing] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.ScriptArp] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.ScriptOnRenewal] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DhcpNoOverride] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.QuietDhcp] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.QuietDhcp6] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.QuietRa] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.QuietTftp] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DnsForwardMax] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.Dumpfile] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.Dumpmask] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.AddCpeId] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DnssecTimestamp] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DnssecLimits] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpAlternatePort] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpDuid] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpLuascript] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpScriptuser] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpPxeVendor] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.ConnmarkAllowlist] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.CaaRecord] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DnsRr] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.SynthDomain] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.AuthZone] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.AuthSoa] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.AuthSecServers] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.AuthPeer] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpRelay] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpCircuitid] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpRemoteid] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpSubscrid] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpProxy] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.TagIf] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.BridgeInterface] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.SharedNetwork] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpOptionPxe] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.UseStaleCache] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.AddMac] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.StripMac] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.AddSubnet] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.StripSubnet] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.Umbrella] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.Do0x20Encode] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.No0x20Encode] = EffectiveConfigParserBehavior.Flag,
|
||||
};
|
||||
|
||||
/// <summary>Returns LastWins, Flag, or Multi for the given option name; defaults to LastWins if unknown.</summary>
|
||||
public static EffectiveConfigParserBehavior GetBehavior(string optionName) =>
|
||||
BehaviorByOptionName.TryGetValue(optionName, out var b) ? b : EffectiveConfigParserBehavior.LastWins;
|
||||
EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName)?.ParserBehavior
|
||||
?? (BehaviorByOptionName.TryGetValue(optionName, out var b) ? b : EffectiveConfigParserBehavior.LastWins);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@ public static class EffectiveConfigSections
|
||||
DnsmasqConfKeys.FastDnsRetry,
|
||||
DnsmasqConfKeys.Ipset,
|
||||
DnsmasqConfKeys.Nftset,
|
||||
DnsmasqConfKeys.ConnmarkAllowlistEnable,
|
||||
DnsmasqConfKeys.NoRoundRobin,
|
||||
DnsmasqConfKeys.DnsForwardMax,
|
||||
DnsmasqConfKeys.ConnmarkAllowlist,
|
||||
DnsmasqConfKeys.Do0x20Encode,
|
||||
]),
|
||||
new SectionDef(SectionDnsRecords, "DNS records", [
|
||||
DnsmasqConfKeys.Domain,
|
||||
@@ -79,6 +84,13 @@ public static class EffectiveConfigSections
|
||||
DnsmasqConfKeys.HostRecord,
|
||||
DnsmasqConfKeys.DynamicHost,
|
||||
DnsmasqConfKeys.InterfaceName,
|
||||
DnsmasqConfKeys.CaaRecord,
|
||||
DnsmasqConfKeys.DnsRr,
|
||||
DnsmasqConfKeys.SynthDomain,
|
||||
DnsmasqConfKeys.AuthZone,
|
||||
DnsmasqConfKeys.AuthSoa,
|
||||
DnsmasqConfKeys.AuthSecServers,
|
||||
DnsmasqConfKeys.AuthPeer,
|
||||
DnsmasqConfKeys.MxTarget,
|
||||
DnsmasqConfKeys.Localmx,
|
||||
DnsmasqConfKeys.Selfmx,
|
||||
@@ -87,7 +99,21 @@ public static class EffectiveConfigSections
|
||||
DnsmasqConfKeys.DhcpAuthoritative,
|
||||
DnsmasqConfKeys.DhcpRapidCommit,
|
||||
DnsmasqConfKeys.LeasefileRo,
|
||||
DnsmasqConfKeys.Leasequery,
|
||||
DnsmasqConfKeys.DhcpGenerateNames,
|
||||
DnsmasqConfKeys.DhcpBroadcast,
|
||||
DnsmasqConfKeys.DhcpSequentialIp,
|
||||
DnsmasqConfKeys.DhcpIgnoreClid,
|
||||
DnsmasqConfKeys.BootpDynamic,
|
||||
DnsmasqConfKeys.NoPing,
|
||||
DnsmasqConfKeys.ScriptArp,
|
||||
DnsmasqConfKeys.ScriptOnRenewal,
|
||||
DnsmasqConfKeys.DhcpNoOverride,
|
||||
DnsmasqConfKeys.DhcpScript,
|
||||
DnsmasqConfKeys.DhcpAlternatePort,
|
||||
DnsmasqConfKeys.DhcpDuid,
|
||||
DnsmasqConfKeys.DhcpLuascript,
|
||||
DnsmasqConfKeys.DhcpScriptuser,
|
||||
DnsmasqConfKeys.DhcpLeasefile,
|
||||
DnsmasqConfKeys.DhcpLeaseMax,
|
||||
DnsmasqConfKeys.DhcpTtl,
|
||||
@@ -102,10 +128,19 @@ public static class EffectiveConfigSections
|
||||
DnsmasqConfKeys.DhcpHostsfile,
|
||||
DnsmasqConfKeys.DhcpOptsfile,
|
||||
DnsmasqConfKeys.DhcpHostsdir,
|
||||
DnsmasqConfKeys.DhcpOptsdir,
|
||||
DnsmasqConfKeys.DhcpBoot,
|
||||
DnsmasqConfKeys.DhcpIgnore,
|
||||
DnsmasqConfKeys.DhcpVendorclass,
|
||||
DnsmasqConfKeys.DhcpUserclass,
|
||||
DnsmasqConfKeys.DhcpRelay,
|
||||
DnsmasqConfKeys.DhcpCircuitid,
|
||||
DnsmasqConfKeys.DhcpRemoteid,
|
||||
DnsmasqConfKeys.DhcpSubscrid,
|
||||
DnsmasqConfKeys.DhcpProxy,
|
||||
DnsmasqConfKeys.TagIf,
|
||||
DnsmasqConfKeys.BridgeInterface,
|
||||
DnsmasqConfKeys.SharedNetwork,
|
||||
DnsmasqConfKeys.RaParam,
|
||||
DnsmasqConfKeys.Slaac,
|
||||
]),
|
||||
@@ -117,11 +152,18 @@ public static class EffectiveConfigSections
|
||||
DnsmasqConfKeys.TftpRoot,
|
||||
DnsmasqConfKeys.PxePrompt,
|
||||
DnsmasqConfKeys.PxeService,
|
||||
DnsmasqConfKeys.DhcpPxeVendor,
|
||||
DnsmasqConfKeys.DhcpOptionPxe,
|
||||
]),
|
||||
new SectionDef(SectionDnssec, "DNSSEC", [
|
||||
DnsmasqConfKeys.Dnssec,
|
||||
DnsmasqConfKeys.DnssecCheckUnsigned,
|
||||
DnsmasqConfKeys.DnssecNoTimecheck,
|
||||
DnsmasqConfKeys.DnssecDebug,
|
||||
DnsmasqConfKeys.ProxyDnssec,
|
||||
DnsmasqConfKeys.AddCpeId,
|
||||
DnsmasqConfKeys.DnssecTimestamp,
|
||||
DnsmasqConfKeys.DnssecLimits,
|
||||
DnsmasqConfKeys.TrustAnchor,
|
||||
]),
|
||||
new SectionDef(SectionCache, "Cache", [
|
||||
@@ -133,6 +175,14 @@ public static class EffectiveConfigSections
|
||||
DnsmasqConfKeys.MaxTtl,
|
||||
DnsmasqConfKeys.MaxCacheTtl,
|
||||
DnsmasqConfKeys.MinCacheTtl,
|
||||
DnsmasqConfKeys.Dumpfile,
|
||||
DnsmasqConfKeys.Dumpmask,
|
||||
DnsmasqConfKeys.UseStaleCache,
|
||||
DnsmasqConfKeys.AddMac,
|
||||
DnsmasqConfKeys.StripMac,
|
||||
DnsmasqConfKeys.AddSubnet,
|
||||
DnsmasqConfKeys.StripSubnet,
|
||||
DnsmasqConfKeys.Umbrella,
|
||||
]),
|
||||
new SectionDef(SectionProcess, "Process & networking", [
|
||||
DnsmasqConfKeys.NoPoll,
|
||||
@@ -159,6 +209,10 @@ public static class EffectiveConfigSections
|
||||
DnsmasqConfKeys.KeepInForeground,
|
||||
DnsmasqConfKeys.NoDaemon,
|
||||
DnsmasqConfKeys.Conntrack,
|
||||
DnsmasqConfKeys.QuietDhcp,
|
||||
DnsmasqConfKeys.QuietDhcp6,
|
||||
DnsmasqConfKeys.QuietRa,
|
||||
DnsmasqConfKeys.QuietTftp,
|
||||
]),
|
||||
];
|
||||
|
||||
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for options with special parse/write/validation semantics.
|
||||
/// Prevents drift between parse, write, hint, and validation for UseStaleCache, AddMac, AddSubnet, Umbrella, Do0x20Encode.
|
||||
/// Inverse-pair key names are not stored here; see <see cref="EffectiveConfigSpecialOptionSemantics.GetInversePairKeys"/>.
|
||||
/// </summary>
|
||||
public sealed record OptionSemantics(
|
||||
string OptionName,
|
||||
EffectiveConfigParserBehavior ParserBehavior,
|
||||
EffectiveConfigWriteBehavior WriteBehavior,
|
||||
OptionValidationSemantics Validation
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Lookup for special-option semantics. Used by EffectiveConfigWriteSemantics and registry for validators.
|
||||
/// Inverse-pair options (e.g. Do0x20Encode) have their (enabled key, disabled key) in a separate table.
|
||||
/// </summary>
|
||||
public static class EffectiveConfigSpecialOptionSemantics
|
||||
{
|
||||
private static readonly OptionValidationSemantics KeyOnlyOrValue = new(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
|
||||
private static readonly OptionValidationSemantics InversePair = new(OptionValidationKind.InversePair);
|
||||
private static readonly OptionValidationSemantics ComplexMulti = new(OptionValidationKind.Complex, allowEmpty: true);
|
||||
private static readonly OptionValidationSemantics IpAddressMulti = new(
|
||||
OptionValidationKind.IpAddress,
|
||||
allowEmpty: false);
|
||||
private static readonly OptionValidationSemantics PathFileMulti = new(
|
||||
OptionValidationKind.PathFile,
|
||||
allowEmpty: true,
|
||||
pathPolicy: PathExistencePolicy.MustExist);
|
||||
private static readonly OptionValidationSemantics PathDirectoryMulti = new(
|
||||
OptionValidationKind.PathDirectory,
|
||||
allowEmpty: true,
|
||||
pathPolicy: PathExistencePolicy.MustExist);
|
||||
private static readonly OptionValidationSemantics PathFileSingleMustExist = new(
|
||||
OptionValidationKind.PathFile,
|
||||
allowEmpty: true,
|
||||
pathPolicy: PathExistencePolicy.MustExist);
|
||||
private static readonly OptionValidationSemantics PathDirectorySingleMustExist = new(
|
||||
OptionValidationKind.PathDirectory,
|
||||
allowEmpty: true,
|
||||
pathPolicy: PathExistencePolicy.MustExist);
|
||||
private static readonly OptionValidationSemantics PathFileSingleParentMustExist = new(
|
||||
OptionValidationKind.PathFile,
|
||||
allowEmpty: true,
|
||||
pathPolicy: PathExistencePolicy.ParentMustExist);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, OptionSemantics> ByOptionName =
|
||||
new Dictionary<string, OptionSemantics>(StringComparer.Ordinal)
|
||||
{
|
||||
[DnsmasqConfKeys.UseStaleCache] = new OptionSemantics(
|
||||
DnsmasqConfKeys.UseStaleCache,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.AddMac] = new OptionSemantics(
|
||||
DnsmasqConfKeys.AddMac,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.AddSubnet] = new OptionSemantics(
|
||||
DnsmasqConfKeys.AddSubnet,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.Umbrella] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Umbrella,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.Do0x20Encode] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Do0x20Encode,
|
||||
EffectiveConfigParserBehavior.Flag,
|
||||
EffectiveConfigWriteBehavior.InversePair,
|
||||
InversePair),
|
||||
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = new OptionSemantics(
|
||||
DnsmasqConfKeys.ConnmarkAllowlistEnable,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DnssecCheckUnsigned,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.Leasequery] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Leasequery,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Server] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Server,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Local] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Local,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.RevServer] = new OptionSemantics(
|
||||
DnsmasqConfKeys.RevServer,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Address] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Address,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.RebindDomainOk] = new OptionSemantics(
|
||||
DnsmasqConfKeys.RebindDomainOk,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.BogusNxdomain] = new OptionSemantics(
|
||||
DnsmasqConfKeys.BogusNxdomain,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.IgnoreAddress] = new OptionSemantics(
|
||||
DnsmasqConfKeys.IgnoreAddress,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Alias] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Alias,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Ipset] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Ipset,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Nftset] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Nftset,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.ListenAddress] = new OptionSemantics(
|
||||
DnsmasqConfKeys.ListenAddress,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
IpAddressMulti),
|
||||
[DnsmasqConfKeys.DhcpGenerateNames] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpGenerateNames,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.DhcpBroadcast] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpBroadcast,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.BootpDynamic] = new OptionSemantics(
|
||||
DnsmasqConfKeys.BootpDynamic,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.KeyOnlyOrValue,
|
||||
KeyOnlyOrValue),
|
||||
[DnsmasqConfKeys.AddnHosts] = new OptionSemantics(
|
||||
DnsmasqConfKeys.AddnHosts,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
PathFileMulti),
|
||||
[DnsmasqConfKeys.ResolvFile] = new OptionSemantics(
|
||||
DnsmasqConfKeys.ResolvFile,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
PathFileMulti),
|
||||
[DnsmasqConfKeys.Hostsdir] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Hostsdir,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.SingleValue,
|
||||
PathDirectorySingleMustExist),
|
||||
[DnsmasqConfKeys.DhcpLeasefile] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpLeasefile,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.SingleValue,
|
||||
PathFileSingleParentMustExist),
|
||||
[DnsmasqConfKeys.TftpRoot] = new OptionSemantics(
|
||||
DnsmasqConfKeys.TftpRoot,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.SingleValue,
|
||||
PathDirectorySingleMustExist),
|
||||
[DnsmasqConfKeys.PidFile] = new OptionSemantics(
|
||||
DnsmasqConfKeys.PidFile,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.SingleValue,
|
||||
PathFileSingleParentMustExist),
|
||||
[DnsmasqConfKeys.Dumpfile] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Dumpfile,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.SingleValue,
|
||||
PathFileSingleParentMustExist),
|
||||
[DnsmasqConfKeys.DnssecTimestamp] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DnssecTimestamp,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.SingleValue,
|
||||
PathFileSingleParentMustExist),
|
||||
[DnsmasqConfKeys.DhcpScript] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpScript,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.SingleValue,
|
||||
PathFileSingleMustExist),
|
||||
[DnsmasqConfKeys.DhcpLuascript] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpLuascript,
|
||||
EffectiveConfigParserBehavior.LastWins,
|
||||
EffectiveConfigWriteBehavior.SingleValue,
|
||||
PathFileSingleMustExist),
|
||||
[DnsmasqConfKeys.DhcpRange] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpRange,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpHost] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpHost,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpOption] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpOption,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpOptionForce] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpOptionForce,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpMatch] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpMatch,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpMac] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpMac,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpIgnoreNames] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpIgnoreNames,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpNameMatch] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpNameMatch,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpIgnore] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpIgnore,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpVendorclass] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpVendorclass,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpUserclass] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpUserclass,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpHostsfile] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpHostsfile,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
PathFileMulti),
|
||||
[DnsmasqConfKeys.DhcpOptsfile] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpOptsfile,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
PathFileMulti),
|
||||
[DnsmasqConfKeys.DhcpHostsdir] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpHostsdir,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
PathDirectoryMulti),
|
||||
[DnsmasqConfKeys.DhcpOptsdir] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpOptsdir,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
PathDirectoryMulti),
|
||||
[DnsmasqConfKeys.DhcpRelay] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpRelay,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpProxy] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpProxy,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.RaParam] = new OptionSemantics(
|
||||
DnsmasqConfKeys.RaParam,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.TagIf] = new OptionSemantics(
|
||||
DnsmasqConfKeys.TagIf,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.BridgeInterface] = new OptionSemantics(
|
||||
DnsmasqConfKeys.BridgeInterface,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.SharedNetwork] = new OptionSemantics(
|
||||
DnsmasqConfKeys.SharedNetwork,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpBoot] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpBoot,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpOptionPxe] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpOptionPxe,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.PxeService] = new OptionSemantics(
|
||||
DnsmasqConfKeys.PxeService,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Slaac] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Slaac,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.TrustAnchor] = new OptionSemantics(
|
||||
DnsmasqConfKeys.TrustAnchor,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.ConnmarkAllowlist] = new OptionSemantics(
|
||||
DnsmasqConfKeys.ConnmarkAllowlist,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpCircuitid] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpCircuitid,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpRemoteid] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpRemoteid,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DhcpSubscrid] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DhcpSubscrid,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.FilterRr] = new OptionSemantics(
|
||||
DnsmasqConfKeys.FilterRr,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.CacheRr] = new OptionSemantics(
|
||||
DnsmasqConfKeys.CacheRr,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Interface] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Interface,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.ExceptInterface] = new OptionSemantics(
|
||||
DnsmasqConfKeys.ExceptInterface,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.NoDhcpInterface] = new OptionSemantics(
|
||||
DnsmasqConfKeys.NoDhcpInterface,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.NoDhcpv4Interface] = new OptionSemantics(
|
||||
DnsmasqConfKeys.NoDhcpv4Interface,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.NoDhcpv6Interface] = new OptionSemantics(
|
||||
DnsmasqConfKeys.NoDhcpv6Interface,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.AuthServer] = new OptionSemantics(
|
||||
DnsmasqConfKeys.AuthServer,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Cname] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Cname,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.MxHost] = new OptionSemantics(
|
||||
DnsmasqConfKeys.MxHost,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.PtrRecord] = new OptionSemantics(
|
||||
DnsmasqConfKeys.PtrRecord,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.InterfaceName] = new OptionSemantics(
|
||||
DnsmasqConfKeys.InterfaceName,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.CaaRecord] = new OptionSemantics(
|
||||
DnsmasqConfKeys.CaaRecord,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Srv] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Srv,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.NaptrRecord] = new OptionSemantics(
|
||||
DnsmasqConfKeys.NaptrRecord,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.Domain] = new OptionSemantics(
|
||||
DnsmasqConfKeys.Domain,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.TxtRecord] = new OptionSemantics(
|
||||
DnsmasqConfKeys.TxtRecord,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.HostRecord] = new OptionSemantics(
|
||||
DnsmasqConfKeys.HostRecord,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DynamicHost] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DynamicHost,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.DnsRr] = new OptionSemantics(
|
||||
DnsmasqConfKeys.DnsRr,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.SynthDomain] = new OptionSemantics(
|
||||
DnsmasqConfKeys.SynthDomain,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.AuthZone] = new OptionSemantics(
|
||||
DnsmasqConfKeys.AuthZone,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.AuthSoa] = new OptionSemantics(
|
||||
DnsmasqConfKeys.AuthSoa,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.AuthSecServers] = new OptionSemantics(
|
||||
DnsmasqConfKeys.AuthSecServers,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
[DnsmasqConfKeys.AuthPeer] = new OptionSemantics(
|
||||
DnsmasqConfKeys.AuthPeer,
|
||||
EffectiveConfigParserBehavior.Multi,
|
||||
EffectiveConfigWriteBehavior.MultiValue,
|
||||
ComplexMulti),
|
||||
};
|
||||
|
||||
/// <summary>Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, (string KeyA, string KeyB)> InversePairKeysByOptionName =
|
||||
new Dictionary<string, (string, string)>(StringComparer.Ordinal)
|
||||
{
|
||||
[DnsmasqConfKeys.Do0x20Encode] = (DnsmasqConfKeys.Do0x20Encode, DnsmasqConfKeys.No0x20Encode),
|
||||
};
|
||||
|
||||
/// <summary>Returns semantics for a special option; null if not a special option.</summary>
|
||||
public static OptionSemantics? TryGetSemantics(string optionName) =>
|
||||
ByOptionName.TryGetValue(optionName, out var s) ? s : null;
|
||||
|
||||
/// <summary>Returns write behavior from semantics for special options; otherwise null (caller uses general map).</summary>
|
||||
public static EffectiveConfigWriteBehavior? GetWriteBehavior(string optionName) =>
|
||||
TryGetSemantics(optionName)?.WriteBehavior;
|
||||
|
||||
/// <summary>Returns the pair of config keys (enabled, disabled) for an InversePair option; null otherwise.</summary>
|
||||
public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
|
||||
InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null;
|
||||
|
||||
/// <summary>Returns all option names that have special semantics. Used by wiring tests to avoid hardcoded lists.</summary>
|
||||
public static IReadOnlyCollection<string> GetAllOptionNames() => ByOptionName.Keys.ToList();
|
||||
}
|
||||
@@ -20,6 +20,8 @@ public static class EffectiveConfigViews
|
||||
EffectiveConfigContext.All => GetAllSections(),
|
||||
EffectiveConfigContext.Hosts => GetHostsView(),
|
||||
EffectiveConfigContext.Dhcp => GetDhcpView(),
|
||||
EffectiveConfigContext.DnsRecords => GetDnsRecordsView(),
|
||||
EffectiveConfigContext.Filters => GetFiltersView(),
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
@@ -38,6 +40,7 @@ public static class EffectiveConfigViews
|
||||
DnsmasqConfKeys.AddnHosts,
|
||||
DnsmasqConfKeys.Hostsdir,
|
||||
DnsmasqConfKeys.ReadEthers,
|
||||
DnsmasqConfKeys.ExpandHosts,
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -47,6 +50,38 @@ public static class EffectiveConfigViews
|
||||
new(EffectiveConfigSections.SectionTftpPxe, "TFTP / PXE", null),
|
||||
];
|
||||
|
||||
private static IReadOnlyList<EffectiveConfigSectionView> GetDnsRecordsView() =>
|
||||
[
|
||||
new(EffectiveConfigSections.SectionDnsRecords, "DNS records", null),
|
||||
];
|
||||
|
||||
private static IReadOnlyList<EffectiveConfigSectionView> GetFiltersView() =>
|
||||
[
|
||||
new(EffectiveConfigSections.SectionResolver, "Resolver / DNS", [
|
||||
DnsmasqConfKeys.DomainNeeded,
|
||||
DnsmasqConfKeys.Local,
|
||||
DnsmasqConfKeys.Server,
|
||||
DnsmasqConfKeys.RevServer,
|
||||
DnsmasqConfKeys.Address,
|
||||
DnsmasqConfKeys.BogusPriv,
|
||||
DnsmasqConfKeys.BogusNxdomain,
|
||||
DnsmasqConfKeys.IgnoreAddress,
|
||||
DnsmasqConfKeys.Alias,
|
||||
DnsmasqConfKeys.Filterwin2k,
|
||||
DnsmasqConfKeys.FilterA,
|
||||
DnsmasqConfKeys.FilterAaaa,
|
||||
DnsmasqConfKeys.FilterRr,
|
||||
DnsmasqConfKeys.StopDnsRebind,
|
||||
DnsmasqConfKeys.RebindLocalhostOk,
|
||||
DnsmasqConfKeys.RebindDomainOk,
|
||||
DnsmasqConfKeys.Ipset,
|
||||
DnsmasqConfKeys.Nftset,
|
||||
DnsmasqConfKeys.ConnmarkAllowlistEnable,
|
||||
DnsmasqConfKeys.ConnmarkAllowlist,
|
||||
DnsmasqConfKeys.NoRoundRobin,
|
||||
]),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// For each section in the view, returns the context-visible descriptors for that section.
|
||||
/// Does not apply search; component applies search to each section's list and hides sections with no matches.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
/// <summary>
|
||||
/// How to serialize an option when writing the managed config file.
|
||||
/// This models write intent, not parse behavior.
|
||||
/// </summary>
|
||||
public enum EffectiveConfigWriteBehavior
|
||||
{
|
||||
Flag,
|
||||
SingleValue,
|
||||
MultiValue,
|
||||
KeyOnlyOrValue,
|
||||
MultiKeyOnlyOrValue,
|
||||
InversePair,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of truth for write semantics used by managed-config writing and readonly hints.
|
||||
/// Special options come from <see cref="EffectiveConfigSpecialOptionSemantics"/>; all others
|
||||
/// derive from parser behavior (Flag/Multi/LastWins).
|
||||
/// </summary>
|
||||
public static class EffectiveConfigWriteSemantics
|
||||
{
|
||||
/// <summary>Returns write behavior for an option.</summary>
|
||||
public static EffectiveConfigWriteBehavior GetBehavior(string optionName)
|
||||
{
|
||||
if (EffectiveConfigSpecialOptionSemantics.GetWriteBehavior(optionName) is { } special)
|
||||
return special;
|
||||
return EffectiveConfigParserBehaviorMap.GetBehavior(optionName) switch
|
||||
{
|
||||
EffectiveConfigParserBehavior.Flag => EffectiveConfigWriteBehavior.Flag,
|
||||
EffectiveConfigParserBehavior.Multi => EffectiveConfigWriteBehavior.MultiValue,
|
||||
_ => EffectiveConfigWriteBehavior.SingleValue,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Returns inverse-pair keys (enabled, disabled) for InversePair options; null otherwise.</summary>
|
||||
public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) =>
|
||||
EffectiveConfigSpecialOptionSemantics.GetInversePairKeys(optionName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Shared validation kinds for effective-config options.
|
||||
/// Describes the generic value shape; option-specific rules live in the semantic validator or handlers.
|
||||
/// </summary>
|
||||
public enum OptionValidationKind
|
||||
{
|
||||
Flag,
|
||||
Int,
|
||||
String,
|
||||
PathFile,
|
||||
PathDirectory,
|
||||
PathFileOrDirectory,
|
||||
IpAddress,
|
||||
HostOrIp,
|
||||
KeyOnlyOrValue,
|
||||
InversePair,
|
||||
Complex
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a path value must exist (file/dir) or only its parent, for validation.
|
||||
/// </summary>
|
||||
public enum PathExistencePolicy
|
||||
{
|
||||
None,
|
||||
MustExist,
|
||||
ParentMustExist
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reusable validation metadata for an option: validation kind, path policy, severity, and empty handling.
|
||||
/// </summary>
|
||||
public sealed record OptionValidationSemantics
|
||||
{
|
||||
public OptionValidationKind Kind { get; }
|
||||
public FieldIssueSeverity Severity { get; }
|
||||
public bool AllowEmpty { get; }
|
||||
public PathExistencePolicy? PathPolicy { get; }
|
||||
|
||||
public OptionValidationSemantics(
|
||||
OptionValidationKind kind,
|
||||
FieldIssueSeverity severity = FieldIssueSeverity.Error,
|
||||
bool allowEmpty = true,
|
||||
PathExistencePolicy? pathPolicy = null)
|
||||
{
|
||||
if (pathPolicy is not null && kind is not (
|
||||
OptionValidationKind.PathFile or
|
||||
OptionValidationKind.PathDirectory or
|
||||
OptionValidationKind.PathFileOrDirectory))
|
||||
{
|
||||
throw new ArgumentException("PathPolicy can only be used with path validation kinds.", nameof(pathPolicy));
|
||||
}
|
||||
|
||||
Kind = kind;
|
||||
Severity = severity;
|
||||
AllowEmpty = allowEmpty;
|
||||
PathPolicy = pathPolicy;
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,31 @@ namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
/// <summary>
|
||||
/// Generates copyable commands or hints for readonly effective-config values:
|
||||
/// remove or edit the line in the source file, or override by adding to managed config.
|
||||
/// Uses EffectiveConfigWriteSemantics so inverse-pair and key-only-or-value options are handled correctly.
|
||||
/// </summary>
|
||||
public static class ReadonlyConfigCommandGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Suggested command to remove the option line from the readonly file (sed). User can copy and run with appropriate privileges.
|
||||
/// Escapes the option name for a basic sed pattern (^option=\?.*$ or ^option$ for flags).
|
||||
/// For InversePair options, removes both keys (e.g. do-0x20-encode and no-0x20-encode).
|
||||
/// </summary>
|
||||
public static string GetRemoveLineCommand(ConfigValueSource source, string optionName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(source.FilePath)) return "";
|
||||
var pathEscaped = source.FilePath.Replace("|", "\\|");
|
||||
var writeBehavior = EffectiveConfigWriteSemantics.GetBehavior(optionName);
|
||||
if (writeBehavior == EffectiveConfigWriteBehavior.InversePair)
|
||||
{
|
||||
var pair = EffectiveConfigWriteSemantics.GetInversePairKeys(optionName);
|
||||
if (pair is null) return "";
|
||||
var escapedA = EscapeForSedPattern(pair.Value.KeyA);
|
||||
var escapedB = EscapeForSedPattern(pair.Value.KeyB);
|
||||
return $"sed -i -e '|^{escapedA}$|d' -e '|^{escapedB}$|d' {pathEscaped}";
|
||||
}
|
||||
var escaped = EscapeForSedPattern(optionName);
|
||||
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionName);
|
||||
var pattern = behavior == EffectiveConfigParserBehavior.Flag
|
||||
var pattern = writeBehavior == EffectiveConfigWriteBehavior.Flag
|
||||
? $"^{escaped}$"
|
||||
: $"^{escaped}=.*$";
|
||||
var pathEscaped = source.FilePath.Replace("|", "\\|");
|
||||
return $"sed -i '|{pattern}|d' {pathEscaped}";
|
||||
}
|
||||
|
||||
@@ -34,12 +43,31 @@ public static class ReadonlyConfigCommandGenerator
|
||||
|
||||
/// <summary>
|
||||
/// The line to add to managed config to override (e.g. "port=53" or "expand-hosts"). Empty if managed path not set.
|
||||
/// Consults EffectiveConfigWriteSemantics: InversePair uses ExplicitToggleState (Enabled→KeyA, Disabled→KeyB, Default→remove);
|
||||
/// KeyOnlyOrValue uses key-only or key=value; Flag uses key-only when true; other options use key=value fallback.
|
||||
/// </summary>
|
||||
public static string GetOverrideLine(string optionName, object? value, string? managedFilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(managedFilePath)) return "";
|
||||
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionName);
|
||||
if (behavior == EffectiveConfigParserBehavior.Flag)
|
||||
var writeBehavior = EffectiveConfigWriteSemantics.GetBehavior(optionName);
|
||||
if (writeBehavior == EffectiveConfigWriteBehavior.InversePair)
|
||||
{
|
||||
var pair = EffectiveConfigWriteSemantics.GetInversePairKeys(optionName);
|
||||
if (pair is null || value is not ExplicitToggleState s) return "";
|
||||
return s switch
|
||||
{
|
||||
ExplicitToggleState.Enabled => pair.Value.KeyA,
|
||||
ExplicitToggleState.Disabled => pair.Value.KeyB,
|
||||
_ => "" // Default means remove/unset
|
||||
};
|
||||
}
|
||||
if (writeBehavior == EffectiveConfigWriteBehavior.KeyOnlyOrValue)
|
||||
{
|
||||
if (value is null) return "";
|
||||
var str = value.ToString() ?? "";
|
||||
return str.Length == 0 ? optionName : $"{optionName}={str}";
|
||||
}
|
||||
if (writeBehavior == EffectiveConfigWriteBehavior.Flag)
|
||||
return value is bool b && b ? optionName : "";
|
||||
var v = ValueToConfString(value);
|
||||
return string.IsNullOrEmpty(v) ? optionName : $"{optionName}={v}";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq;
|
||||
|
||||
/// <summary>
|
||||
/// Parses "Compile time options: ..." line from dnsmasq --version output
|
||||
/// to determine DHCP, TFTP, DNSSEC, DBus support.
|
||||
/// </summary>
|
||||
public static class DnsmasqCompileOptionsParser
|
||||
{
|
||||
private static readonly Regex CompileLine = new(
|
||||
@"Compile\s+time\s+options\s*:\s*(?<opts>.+)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);
|
||||
|
||||
public static DnsmasqCompileCapabilities Parse(string? stdout, string? stderr)
|
||||
{
|
||||
var text = $"{stdout}\n{stderr}";
|
||||
var m = CompileLine.Match(text);
|
||||
if (!m.Success)
|
||||
return new DnsmasqCompileCapabilities(false, false, false, false, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var tokens = m.Groups["opts"].Value
|
||||
.Split(new[] { ' ', '\t', ',' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
bool Has(string s) => tokens.Contains(s);
|
||||
|
||||
return new DnsmasqCompileCapabilities(
|
||||
Dhcp: Has("DHCP") || Has("DHCPv4") || Has("DHCPv6"),
|
||||
Tftp: Has("TFTP"),
|
||||
Dnssec: Has("DNSSEC"),
|
||||
Dbus: Has("DBus"),
|
||||
RawTokens: tokens
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq;
|
||||
|
||||
/// <summary>Parses dnsmasq version from command output (e.g. "dnsmasq --version" stdout/stderr).</summary>
|
||||
public static class DnsmasqVersionParser
|
||||
{
|
||||
private static readonly Regex Rx = new(@"\b(\d+)\.(\d+)(?:\.(\d+))?\b", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Finds the first X.Y or X.Y.Z token in combined stdout and stderr; returns null if none found.</summary>
|
||||
public static Version? TryParse(string? stdout, string? stderr)
|
||||
{
|
||||
var text = $"{stdout}\n{stderr}";
|
||||
var m = Rx.Match(text);
|
||||
if (!m.Success) return null;
|
||||
var major = int.Parse(m.Groups[1].Value);
|
||||
var minor = int.Parse(m.Groups[2].Value);
|
||||
var patch = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0;
|
||||
return new Version(major, minor, patch);
|
||||
}
|
||||
}
|
||||
@@ -32,17 +32,8 @@ internal sealed class ProcessHandle : IProcessHandle
|
||||
_logger = logger;
|
||||
|
||||
process.EnableRaisingEvents = true;
|
||||
process.Exited += (_, _) =>
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_channelCompleted)
|
||||
{
|
||||
_channelCompleted = true;
|
||||
_channel.Writer.Complete();
|
||||
}
|
||||
}
|
||||
};
|
||||
// Do not complete the channel in Exited: OutputDataReceived/ErrorDataReceived can fire after Exited
|
||||
// (see dotnet/runtime#18789). Complete only when both streams have closed (e.Data == null).
|
||||
|
||||
process.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
@@ -52,7 +43,10 @@ internal sealed class ProcessHandle : IProcessHandle
|
||||
_channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdOut, e.Data, DateTime.UtcNow));
|
||||
}
|
||||
else
|
||||
{
|
||||
_stdoutClosed.TrySetResult();
|
||||
TryCompleteChannelWhenBothStreamsClosed();
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, e) =>
|
||||
@@ -63,10 +57,35 @@ internal sealed class ProcessHandle : IProcessHandle
|
||||
_channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdErr, e.Data, DateTime.UtcNow));
|
||||
}
|
||||
else
|
||||
{
|
||||
_stderrClosed.TrySetResult();
|
||||
TryCompleteChannelWhenBothStreamsClosed();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void TryCompleteChannelWhenBothStreamsClosed()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_channelCompleted) return;
|
||||
if (!_stdoutClosed.Task.IsCompleted || !_stderrClosed.Task.IsCompleted) return;
|
||||
_channelCompleted = true;
|
||||
_channel.Writer.Complete();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Complete the channel after waiting for streams; used when stream-close wait timed out or caller is shutting down.</summary>
|
||||
private void CompleteChannelIfNeeded()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_channelCompleted) return;
|
||||
_channelCompleted = true;
|
||||
_channel.Writer.Complete();
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ProcessOutputLine> ReadOutputAsync([EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
await foreach (var line in _channel.Reader.ReadAllAsync(ct))
|
||||
@@ -100,24 +119,18 @@ internal sealed class ProcessHandle : IProcessHandle
|
||||
var timeoutSeconds = timeout?.TotalSeconds ?? 0;
|
||||
_logger?.LogWarning("Command timed out after {Timeout}s", timeoutSeconds);
|
||||
try { _process.Kill(); } catch { /* best effort */ }
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_channelCompleted)
|
||||
{
|
||||
_channelCompleted = true;
|
||||
_channel.Writer.Complete();
|
||||
}
|
||||
}
|
||||
try { await drainTask; } catch (OperationCanceledException) { }
|
||||
await WaitForStreamsClosedAsync();
|
||||
CompleteChannelIfNeeded();
|
||||
try { await drainTask; } catch (OperationCanceledException) { }
|
||||
var err = _stderr.ToString();
|
||||
if (!string.IsNullOrEmpty(err)) err += "\n";
|
||||
err += $"Command timed out after {timeoutSeconds} seconds.";
|
||||
return new ProcessRunResult(null, _stdout.ToString(), err, true);
|
||||
}
|
||||
|
||||
try { await drainTask; } catch (OperationCanceledException) { }
|
||||
await WaitForStreamsClosedAsync();
|
||||
CompleteChannelIfNeeded();
|
||||
try { await drainTask; } catch (OperationCanceledException) { }
|
||||
var exitCode = _process.HasExited ? _process.ExitCode : -1;
|
||||
_logger?.LogDebug("Command completed, exit code={ExitCode}", exitCode);
|
||||
|
||||
|
||||
@@ -293,6 +293,24 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var dhcpHostsfilePaths = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpHostsfile);
|
||||
var dhcpOptsfilePaths = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpOptsfile);
|
||||
var dhcpHostsdirPaths = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpHostsdir);
|
||||
var dhcpOptsdirPaths = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpOptsdir);
|
||||
var connmarkAllowlistValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ConnmarkAllowlist);
|
||||
var caaRecordValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.CaaRecord);
|
||||
var dnsRrValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnsRr);
|
||||
var synthDomainValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.SynthDomain);
|
||||
var authZoneValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AuthZone);
|
||||
var authSoaValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AuthSoa);
|
||||
var authSecServersValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AuthSecServers);
|
||||
var authPeerValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AuthPeer);
|
||||
var dhcpRelayValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpRelay);
|
||||
var dhcpCircuitidValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpCircuitid);
|
||||
var dhcpRemoteidValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpRemoteid);
|
||||
var dhcpSubscridValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpSubscrid);
|
||||
var dhcpProxyValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpProxy);
|
||||
var tagIfValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.TagIf);
|
||||
var bridgeInterfaceValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.BridgeInterface);
|
||||
var sharedNetworkValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.SharedNetwork);
|
||||
var dhcpOptionPxeValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpOptionPxe);
|
||||
|
||||
var expandHosts = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ExpandHosts);
|
||||
var bogusPriv = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.BogusPriv);
|
||||
@@ -318,7 +336,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var enableTftp = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.EnableTftp);
|
||||
var tftpSecure = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.TftpSecure);
|
||||
var dnssec = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Dnssec);
|
||||
var dnssecCheckUnsigned = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
var (dnssecCheckUnsignedVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
var dnssecCheckUnsigned = dnssecCheckUnsignedVal == null ? null : (string.IsNullOrWhiteSpace(dnssecCheckUnsignedVal) ? "" : dnssecCheckUnsignedVal.Trim());
|
||||
var readEthers = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ReadEthers);
|
||||
var dhcpRapidCommit = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpRapidCommit);
|
||||
var tftpNoFail = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.TftpNoFail);
|
||||
@@ -330,6 +349,28 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var keepInForeground = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.KeepInForeground);
|
||||
var noDaemon = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.NoDaemon);
|
||||
var proxyDnssec = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ProxyDnssec);
|
||||
var (connmarkAllowlistEnableVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ConnmarkAllowlistEnable);
|
||||
var connmarkAllowlistEnable = connmarkAllowlistEnableVal == null ? null : (string.IsNullOrWhiteSpace(connmarkAllowlistEnableVal) ? "" : connmarkAllowlistEnableVal.Trim());
|
||||
var noRoundRobin = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.NoRoundRobin);
|
||||
var dnssecNoTimecheck = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnssecNoTimecheck);
|
||||
var dnssecDebug = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnssecDebug);
|
||||
var leasequeryValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Leasequery);
|
||||
var (dhcpGenerateNamesVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpGenerateNames);
|
||||
var dhcpGenerateNames = dhcpGenerateNamesVal == null ? null : (string.IsNullOrWhiteSpace(dhcpGenerateNamesVal) ? "" : dhcpGenerateNamesVal.Trim());
|
||||
var (dhcpBroadcastVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpBroadcast);
|
||||
var dhcpBroadcast = dhcpBroadcastVal == null ? null : (string.IsNullOrWhiteSpace(dhcpBroadcastVal) ? "" : dhcpBroadcastVal.Trim());
|
||||
var dhcpSequentialIp = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpSequentialIp);
|
||||
var dhcpIgnoreClid = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpIgnoreClid);
|
||||
var (bootpDynamicVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.BootpDynamic);
|
||||
var bootpDynamic = bootpDynamicVal == null ? null : (string.IsNullOrWhiteSpace(bootpDynamicVal) ? "" : bootpDynamicVal.Trim());
|
||||
var noPing = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.NoPing);
|
||||
var scriptArp = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ScriptArp);
|
||||
var scriptOnRenewal = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.ScriptOnRenewal);
|
||||
var dhcpNoOverride = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpNoOverride);
|
||||
var quietDhcp = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.QuietDhcp);
|
||||
var quietDhcp6 = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.QuietDhcp6);
|
||||
var quietRa = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.QuietRa);
|
||||
var quietTftp = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.QuietTftp);
|
||||
|
||||
var dhcpLeaseFilePath = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths, pathToLines);
|
||||
|
||||
@@ -391,8 +432,43 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var dhcpScriptPath = string.IsNullOrWhiteSpace(dhcpScriptVal) ? null : DnsmasqConfIncludeParser.ResolvePath(dhcpScriptVal?.Trim(), dhcpScriptDir);
|
||||
var (mxTargetVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.MxTarget);
|
||||
var mxTarget = string.IsNullOrWhiteSpace(mxTargetVal) ? null : mxTargetVal.Trim();
|
||||
var (conntrackVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Conntrack);
|
||||
var conntrack = string.IsNullOrWhiteSpace(conntrackVal) ? null : conntrackVal.Trim();
|
||||
var (dnsForwardMaxVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnsForwardMax);
|
||||
var dnsForwardMax = TryParseInt(dnsForwardMaxVal);
|
||||
var (dumpfileVal, dumpfileDir) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Dumpfile);
|
||||
var dumpfilePath = string.IsNullOrWhiteSpace(dumpfileVal) ? null : DnsmasqConfIncludeParser.ResolvePath(dumpfileVal.Trim(), dumpfileDir);
|
||||
var (dumpmaskVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Dumpmask);
|
||||
var dumpmask = string.IsNullOrWhiteSpace(dumpmaskVal) ? null : dumpmaskVal.Trim();
|
||||
var (addCpeIdVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AddCpeId);
|
||||
var addCpeId = string.IsNullOrWhiteSpace(addCpeIdVal) ? null : addCpeIdVal.Trim();
|
||||
var (dnssecTimestampVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnssecTimestamp);
|
||||
var dnssecTimestamp = string.IsNullOrWhiteSpace(dnssecTimestampVal) ? null : dnssecTimestampVal.Trim();
|
||||
var (dnssecLimitsVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DnssecLimits);
|
||||
var dnssecLimits = string.IsNullOrWhiteSpace(dnssecLimitsVal) ? null : dnssecLimitsVal.Trim();
|
||||
var (dhcpAlternatePortVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpAlternatePort);
|
||||
var dhcpAlternatePort = string.IsNullOrWhiteSpace(dhcpAlternatePortVal) ? null : dhcpAlternatePortVal.Trim();
|
||||
var (dhcpDuidVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpDuid);
|
||||
var dhcpDuid = string.IsNullOrWhiteSpace(dhcpDuidVal) ? null : dhcpDuidVal.Trim();
|
||||
var (dhcpLuascriptVal, dhcpLuascriptDir) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpLuascript);
|
||||
var dhcpLuascriptPath = string.IsNullOrWhiteSpace(dhcpLuascriptVal) ? null : DnsmasqConfIncludeParser.ResolvePath(dhcpLuascriptVal?.Trim(), dhcpLuascriptDir);
|
||||
var (dhcpScriptuserVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpScriptuser);
|
||||
var dhcpScriptuser = string.IsNullOrWhiteSpace(dhcpScriptuserVal) ? null : dhcpScriptuserVal.Trim();
|
||||
var (dhcpPxeVendorVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.DhcpPxeVendor);
|
||||
var dhcpPxeVendor = string.IsNullOrWhiteSpace(dhcpPxeVendorVal) ? null : dhcpPxeVendorVal.Trim();
|
||||
var (useStaleCacheVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.UseStaleCache);
|
||||
var useStaleCache = useStaleCacheVal == null ? null : (string.IsNullOrWhiteSpace(useStaleCacheVal) ? "" : useStaleCacheVal.Trim());
|
||||
var (addMacVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AddMac);
|
||||
var addMac = addMacVal == null ? null : (string.IsNullOrWhiteSpace(addMacVal) ? "" : addMacVal.Trim());
|
||||
var stripMac = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.StripMac);
|
||||
var (addSubnetVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.AddSubnet);
|
||||
var addSubnet = addSubnetVal == null ? null : (string.IsNullOrWhiteSpace(addSubnetVal) ? "" : addSubnetVal.Trim());
|
||||
var stripSubnet = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.StripSubnet);
|
||||
var (umbrellaVal, _) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Umbrella);
|
||||
var umbrella = umbrellaVal == null ? null : (string.IsNullOrWhiteSpace(umbrellaVal) ? "" : umbrellaVal.Trim());
|
||||
var do0x20 = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Do0x20Encode);
|
||||
var no0x20 = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.No0x20Encode);
|
||||
// dnsmasq docs: no-0x20-encode overrides do-0x20-encode
|
||||
var do0x20EncodeState = no0x20 ? ExplicitToggleState.Disabled : (do0x20 ? ExplicitToggleState.Enabled : ExplicitToggleState.Default);
|
||||
var conntrack = (bool)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Conntrack);
|
||||
|
||||
return new EffectiveDnsmasqConfig(
|
||||
noHosts, addnHosts, hostsdirPath,
|
||||
@@ -401,15 +477,19 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
rebindDomainOk, bogusNxdomain, ignoreAddress, alias, filterRr, cacheRr, authServer, noDhcpInterface, noDhcpv4Interface, noDhcpv6Interface,
|
||||
domainValues, cnameValues, mxHostValues, srvValues, ptrRecordValues, txtRecordValues, naptrRecordValues, hostRecordValues, dynamicHostValues, interfaceNameValues,
|
||||
dhcpOptionForceLines, ipsetValues, nftsetValues, dhcpMacValues, dhcpNameMatchValues, dhcpIgnoreNamesValues,
|
||||
dhcpHostsfilePaths, dhcpOptsfilePaths, dhcpHostsdirPaths,
|
||||
dhcpHostsfilePaths, dhcpOptsfilePaths, dhcpHostsdirPaths, dhcpOptsdirPaths,
|
||||
connmarkAllowlistValues, caaRecordValues, dnsRrValues, synthDomainValues, authZoneValues, authSoaValues, authSecServersValues, authPeerValues,
|
||||
dhcpRelayValues, dhcpCircuitidValues, dhcpRemoteidValues, dhcpSubscridValues, dhcpProxyValues, tagIfValues, bridgeInterfaceValues, sharedNetworkValues, dhcpOptionPxeValues,
|
||||
expandHosts, bogusPriv, strictOrder, allServers, noResolv, domainNeeded, noPoll, bindInterfaces, bindDynamic, noNegcache,
|
||||
dnsLoopDetect, stopDnsRebind, rebindLocalhostOk, clearOnReload, filterwin2k, filterA, filterAaaa, localiseQueries, logDebug, dhcpAuthoritative, leasefileRo,
|
||||
enableTftp, tftpSecure, tftpNoFail, tftpNoBlocksize, dnssec, dnssecCheckUnsigned, readEthers, dhcpRapidCommit, localmx, selfmx, enableRa, logDhcp,
|
||||
keepInForeground, noDaemon, proxyDnssec,
|
||||
connmarkAllowlistEnable, noRoundRobin, dnssecNoTimecheck, dnssecDebug, leasequeryValues, dhcpGenerateNames, dhcpBroadcast, dhcpSequentialIp, dhcpIgnoreClid, bootpDynamic, noPing, scriptArp, scriptOnRenewal, dhcpNoOverride, quietDhcp, quietDhcp6, quietRa, quietTftp,
|
||||
dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, logQueries,
|
||||
authTtl, ednsPacketMax, queryPort, portLimit, minPort, maxPort, logAsync, localService, dhcpLeaseMax,
|
||||
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl, tftpRootPath, pxePrompt, enableDbus, enableUbus, fastDnsRetry,
|
||||
dhcpScriptPath, mxTarget, conntrack
|
||||
dhcpScriptPath, mxTarget, dnsForwardMax, dumpfilePath, dumpmask, addCpeId, dnssecTimestamp, dnssecLimits, dhcpAlternatePort, dhcpDuid, dhcpLuascriptPath, dhcpScriptuser, dhcpPxeVendor,
|
||||
useStaleCache, addMac, stripMac, addSubnet, stripSubnet, umbrella, do0x20EncodeState, conntrack
|
||||
);
|
||||
}
|
||||
|
||||
@@ -467,6 +547,24 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var dhcpHostsfileWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHostsfile, managedFilePath);
|
||||
var dhcpOptsfileWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOptsfile, managedFilePath);
|
||||
var dhcpHostsdirWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHostsdir, managedFilePath);
|
||||
var dhcpOptsdirWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOptsdir, managedFilePath);
|
||||
var connmarkAllowlistWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ConnmarkAllowlist, managedFilePath);
|
||||
var caaRecordWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.CaaRecord, managedFilePath);
|
||||
var dnsRrWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnsRr, managedFilePath);
|
||||
var synthDomainWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.SynthDomain, managedFilePath);
|
||||
var authZoneWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AuthZone, managedFilePath);
|
||||
var authSoaWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AuthSoa, managedFilePath);
|
||||
var authSecServersWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AuthSecServers, managedFilePath);
|
||||
var authPeerWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AuthPeer, managedFilePath);
|
||||
var dhcpRelayWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpRelay, managedFilePath);
|
||||
var dhcpCircuitidWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpCircuitid, managedFilePath);
|
||||
var dhcpRemoteidWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpRemoteid, managedFilePath);
|
||||
var dhcpSubscridWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpSubscrid, managedFilePath);
|
||||
var dhcpProxyWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpProxy, managedFilePath);
|
||||
var tagIfWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.TagIf, managedFilePath);
|
||||
var bridgeInterfaceWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.BridgeInterface, managedFilePath);
|
||||
var sharedNetworkWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.SharedNetwork, managedFilePath);
|
||||
var dhcpOptionPxeWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOptionPxe, managedFilePath);
|
||||
|
||||
var (_, expandHostsSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ExpandHosts, managedFilePath);
|
||||
var (_, bogusPrivSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.BogusPriv, managedFilePath);
|
||||
@@ -492,7 +590,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var (_, enableTftpSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.EnableTftp, managedFilePath);
|
||||
var (_, tftpSecureSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.TftpSecure, managedFilePath);
|
||||
var (_, dnssecSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Dnssec, managedFilePath);
|
||||
var (_, dnssecCheckUnsignedSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned, managedFilePath);
|
||||
var (_, dnssecCheckUnsignedSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned, managedFilePath);
|
||||
var (_, readEthersSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ReadEthers, managedFilePath);
|
||||
var (_, dhcpRapidCommitSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpRapidCommit, managedFilePath);
|
||||
var (_, tftpNoFailSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.TftpNoFail, managedFilePath);
|
||||
@@ -504,6 +602,24 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var (_, keepInForegroundSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.KeepInForeground, managedFilePath);
|
||||
var (_, noDaemonSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.NoDaemon, managedFilePath);
|
||||
var (_, proxyDnssecSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ProxyDnssec, managedFilePath);
|
||||
var (_, connmarkAllowlistEnableSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ConnmarkAllowlistEnable, managedFilePath);
|
||||
var (_, noRoundRobinSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.NoRoundRobin, managedFilePath);
|
||||
var (_, dnssecNoTimecheckSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecNoTimecheck, managedFilePath);
|
||||
var (_, dnssecDebugSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecDebug, managedFilePath);
|
||||
var leasequeryWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Leasequery, managedFilePath);
|
||||
var (_, dhcpGenerateNamesSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpGenerateNames, managedFilePath);
|
||||
var (_, dhcpBroadcastSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpBroadcast, managedFilePath);
|
||||
var (_, dhcpSequentialIpSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpSequentialIp, managedFilePath);
|
||||
var (_, dhcpIgnoreClidSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpIgnoreClid, managedFilePath);
|
||||
var (_, bootpDynamicSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.BootpDynamic, managedFilePath);
|
||||
var (_, noPingSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.NoPing, managedFilePath);
|
||||
var (_, scriptArpSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ScriptArp, managedFilePath);
|
||||
var (_, scriptOnRenewalSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.ScriptOnRenewal, managedFilePath);
|
||||
var (_, dhcpNoOverrideSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpNoOverride, managedFilePath);
|
||||
var (_, quietDhcpSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.QuietDhcp, managedFilePath);
|
||||
var (_, quietDhcp6Source) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.QuietDhcp6, managedFilePath);
|
||||
var (_, quietRaSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.QuietRa, managedFilePath);
|
||||
var (_, quietTftpSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.QuietTftp, managedFilePath);
|
||||
|
||||
var (_, dhcpLeaseFilePathSource) = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFilesWithSource(paths, pathToLines, managedFilePath);
|
||||
var (_, cacheSizeSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.CacheSize, managedFilePath);
|
||||
@@ -535,7 +651,28 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var (_, fastDnsRetrySource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.FastDnsRetry, managedFilePath);
|
||||
var (_, dhcpScriptPathSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpScript, managedFilePath);
|
||||
var (_, mxTargetSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.MxTarget, managedFilePath);
|
||||
var (_, conntrackSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Conntrack, managedFilePath);
|
||||
var (_, dnsForwardMaxSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnsForwardMax, managedFilePath);
|
||||
var (_, dumpfilePathSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Dumpfile, managedFilePath);
|
||||
var (_, dumpmaskSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Dumpmask, managedFilePath);
|
||||
var (_, addCpeIdSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AddCpeId, managedFilePath);
|
||||
var (_, dnssecTimestampSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecTimestamp, managedFilePath);
|
||||
var (_, dnssecLimitsSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecLimits, managedFilePath);
|
||||
var (_, dhcpAlternatePortSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpAlternatePort, managedFilePath);
|
||||
var (_, dhcpDuidSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpDuid, managedFilePath);
|
||||
var (_, dhcpLuascriptPathSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpLuascript, managedFilePath);
|
||||
var (_, dhcpScriptuserSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpScriptuser, managedFilePath);
|
||||
var (_, dhcpPxeVendorSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpPxeVendor, managedFilePath);
|
||||
var (_, useStaleCacheSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.UseStaleCache, managedFilePath);
|
||||
var (_, addMacSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AddMac, managedFilePath);
|
||||
var (_, stripMacSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.StripMac, managedFilePath);
|
||||
var (_, addSubnetSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.AddSubnet, managedFilePath);
|
||||
var (_, stripSubnetSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.StripSubnet, managedFilePath);
|
||||
var (_, umbrellaSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Umbrella, managedFilePath);
|
||||
var (do0x20Set, do0x20EncodeSourceA) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Do0x20Encode, managedFilePath);
|
||||
var (no0x20Set, no0x20EncodeSourceB) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.No0x20Encode, managedFilePath);
|
||||
// no-0x20-encode overrides do-0x20-encode; source reflects the effective directive
|
||||
var do0x20EncodeSource = no0x20Set ? no0x20EncodeSourceB : (do0x20Set ? do0x20EncodeSourceA : null);
|
||||
var (_, conntrackSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Conntrack, managedFilePath);
|
||||
|
||||
return new EffectiveConfigSources(
|
||||
noHostsSource, addnHostsWithSource.Select(t => new PathWithSource(t.Path, t.Source)).ToList(), hostsdirPathSource,
|
||||
@@ -588,6 +725,24 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
dhcpHostsfileWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpOptsfileWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpHostsdirWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpOptsdirWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
connmarkAllowlistWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
caaRecordWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dnsRrWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
synthDomainWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
authZoneWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
authSoaWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
authSecServersWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
authPeerWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpRelayWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpCircuitidWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpRemoteidWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpSubscridWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpProxyWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
tagIfWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
bridgeInterfaceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
sharedNetworkWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpOptionPxeWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
expandHostsSource,
|
||||
bogusPrivSource,
|
||||
strictOrderSource,
|
||||
@@ -624,6 +779,24 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
keepInForegroundSource,
|
||||
noDaemonSource,
|
||||
proxyDnssecSource,
|
||||
connmarkAllowlistEnableSource,
|
||||
noRoundRobinSource,
|
||||
dnssecNoTimecheckSource,
|
||||
dnssecDebugSource,
|
||||
leasequeryWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpGenerateNamesSource,
|
||||
dhcpBroadcastSource,
|
||||
dhcpSequentialIpSource,
|
||||
dhcpIgnoreClidSource,
|
||||
bootpDynamicSource,
|
||||
noPingSource,
|
||||
scriptArpSource,
|
||||
scriptOnRenewalSource,
|
||||
dhcpNoOverrideSource,
|
||||
quietDhcpSource,
|
||||
quietDhcp6Source,
|
||||
quietRaSource,
|
||||
quietTftpSource,
|
||||
dhcpLeaseFilePathSource,
|
||||
cacheSizeSource,
|
||||
portSource,
|
||||
@@ -654,6 +827,24 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
fastDnsRetrySource,
|
||||
dhcpScriptPathSource,
|
||||
mxTargetSource,
|
||||
dnsForwardMaxSource,
|
||||
dumpfilePathSource,
|
||||
dumpmaskSource,
|
||||
addCpeIdSource,
|
||||
dnssecTimestampSource,
|
||||
dnssecLimitsSource,
|
||||
dhcpAlternatePortSource,
|
||||
dhcpDuidSource,
|
||||
dhcpLuascriptPathSource,
|
||||
dhcpScriptuserSource,
|
||||
dhcpPxeVendorSource,
|
||||
useStaleCacheSource,
|
||||
addMacSource,
|
||||
stripMacSource,
|
||||
addSubnetSource,
|
||||
stripSubnetSource,
|
||||
umbrellaSource,
|
||||
do0x20EncodeSource,
|
||||
conntrackSource
|
||||
);
|
||||
}
|
||||
@@ -707,18 +898,24 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
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>(),
|
||||
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: false,
|
||||
EnableTftp: false, TftpSecure: false, TftpNoFail: false, TftpNoBlocksize: false, Dnssec: false, DnssecCheckUnsigned: null,
|
||||
ReadEthers: false, DhcpRapidCommit: false, Localmx: false, Selfmx: false, EnableRa: false, LogDhcp: false,
|
||||
KeepInForeground: false, NoDaemon: false, ProxyDnssec: false,
|
||||
ConnmarkAllowlistEnable: null, NoRoundRobin: false, DnssecNoTimecheck: false, DnssecDebug: false, LeasequeryValues: Array.Empty<string>(), DhcpGenerateNames: null, DhcpBroadcast: null, DhcpSequentialIp: false, DhcpIgnoreClid: false, BootpDynamic: null, NoPing: false, ScriptArp: false, ScriptOnRenewal: false, DhcpNoOverride: false, QuietDhcp: false, QuietDhcp6: false, QuietRa: false, QuietTftp: false,
|
||||
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
|
||||
LogFacility: null, LogQueries: null, AuthTtl: null, EdnsPacketMax: null, QueryPort: null, PortLimit: null, MinPort: null, MaxPort: null, LogAsync: null, LocalService: null,
|
||||
DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null,
|
||||
TftpRootPath: null, PxePrompt: null, EnableDbus: null, EnableUbus: null, FastDnsRetry: null,
|
||||
DhcpScriptPath: null, MxTarget: null, Conntrack: null
|
||||
DhcpScriptPath: null, MxTarget: null, DnsForwardMax: null, DumpfilePath: null, Dumpmask: null, AddCpeId: null, DnssecTimestamp: null, DnssecLimits: null, DhcpAlternatePort: null, DhcpDuid: null, DhcpLuascriptPath: null, DhcpScriptuser: null, DhcpPxeVendor: null,
|
||||
UseStaleCache: null, AddMac: null, StripMac: false, AddSubnet: null, StripSubnet: false, Umbrella: null, Do0x20EncodeState: ExplicitToggleState.Default, Conntrack: false
|
||||
);
|
||||
|
||||
private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() =>
|
||||
@@ -742,18 +939,24 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
HostRecordValues: Array.Empty<ValueWithSource>(), DynamicHostValues: Array.Empty<ValueWithSource>(), InterfaceNameValues: Array.Empty<ValueWithSource>(),
|
||||
DhcpOptionForceLines: Array.Empty<ValueWithSource>(), IpsetValues: Array.Empty<ValueWithSource>(), NftsetValues: Array.Empty<ValueWithSource>(),
|
||||
DhcpMacValues: Array.Empty<ValueWithSource>(), DhcpNameMatchValues: Array.Empty<ValueWithSource>(), DhcpIgnoreNamesValues: Array.Empty<ValueWithSource>(),
|
||||
DhcpHostsfilePaths: Array.Empty<ValueWithSource>(), DhcpOptsfilePaths: Array.Empty<ValueWithSource>(), DhcpHostsdirPaths: Array.Empty<ValueWithSource>(),
|
||||
DhcpHostsfilePaths: Array.Empty<ValueWithSource>(), DhcpOptsfilePaths: Array.Empty<ValueWithSource>(), DhcpHostsdirPaths: Array.Empty<ValueWithSource>(), DhcpOptsdirPaths: Array.Empty<ValueWithSource>(),
|
||||
ConnmarkAllowlistValues: Array.Empty<ValueWithSource>(), CaaRecordValues: Array.Empty<ValueWithSource>(), DnsRrValues: Array.Empty<ValueWithSource>(), SynthDomainValues: Array.Empty<ValueWithSource>(),
|
||||
AuthZoneValues: Array.Empty<ValueWithSource>(), AuthSoaValues: Array.Empty<ValueWithSource>(), AuthSecServersValues: Array.Empty<ValueWithSource>(), AuthPeerValues: Array.Empty<ValueWithSource>(),
|
||||
DhcpRelayValues: Array.Empty<ValueWithSource>(), DhcpCircuitidValues: Array.Empty<ValueWithSource>(), DhcpRemoteidValues: Array.Empty<ValueWithSource>(), DhcpSubscridValues: Array.Empty<ValueWithSource>(), DhcpProxyValues: Array.Empty<ValueWithSource>(),
|
||||
TagIfValues: Array.Empty<ValueWithSource>(), BridgeInterfaceValues: Array.Empty<ValueWithSource>(), SharedNetworkValues: Array.Empty<ValueWithSource>(), DhcpOptionPxeValues: Array.Empty<ValueWithSource>(),
|
||||
ExpandHosts: null, BogusPriv: null, StrictOrder: null, AllServers: null, NoResolv: null, DomainNeeded: null, NoPoll: null,
|
||||
BindInterfaces: null, BindDynamic: null, NoNegcache: null, DnsLoopDetect: null, StopDnsRebind: null, RebindLocalhostOk: null, ClearOnReload: null,
|
||||
Filterwin2k: null, FilterA: null, FilterAaaa: null, LocaliseQueries: null, LogDebug: null, DhcpAuthoritative: null, LeasefileRo: null,
|
||||
EnableTftp: null, TftpSecure: null, TftpNoFail: null, TftpNoBlocksize: null, Dnssec: null, DnssecCheckUnsigned: null,
|
||||
ReadEthers: null, DhcpRapidCommit: null, Localmx: null, Selfmx: null, EnableRa: null, LogDhcp: null,
|
||||
KeepInForeground: null, NoDaemon: null, ProxyDnssec: null,
|
||||
ConnmarkAllowlistEnable: null, NoRoundRobin: null, DnssecNoTimecheck: null, DnssecDebug: null, LeasequeryValues: Array.Empty<ValueWithSource>(), DhcpGenerateNames: null, DhcpBroadcast: null, DhcpSequentialIp: null, DhcpIgnoreClid: null, BootpDynamic: null, NoPing: null, ScriptArp: null, ScriptOnRenewal: null, DhcpNoOverride: null, QuietDhcp: null, QuietDhcp6: null, QuietRa: null, QuietTftp: null,
|
||||
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
|
||||
LogFacility: null, LogQueries: null, AuthTtl: null, EdnsPacketMax: null, QueryPort: null, PortLimit: null, MinPort: null, MaxPort: null, LogAsync: null, LocalService: null,
|
||||
DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null,
|
||||
TftpRootPath: null, PxePrompt: null, EnableDbus: null, EnableUbus: null, FastDnsRetry: null,
|
||||
DhcpScriptPath: null, MxTarget: null, Conntrack: null
|
||||
DhcpScriptPath: null, MxTarget: null, DnsForwardMax: null, DumpfilePath: null, Dumpmask: null, AddCpeId: null, DnssecTimestamp: null, DnssecLimits: null, DhcpAlternatePort: null, DhcpDuid: null, DhcpLuascriptPath: null, DhcpScriptuser: null, DhcpPxeVendor: null,
|
||||
UseStaleCache: null, AddMac: null, StripMac: null, AddSubnet: null, StripSubnet: null, Umbrella: null, Do0x20Encode: null, Conntrack: null
|
||||
);
|
||||
|
||||
private static int? TryParseInt(string? value)
|
||||
|
||||
@@ -231,8 +231,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
var maxLineNumber = list.Count > 0 ? list.Max(l => l.LineNumber) : 0;
|
||||
foreach (var c in changes)
|
||||
{
|
||||
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(c.OptionName);
|
||||
var isFlag = behavior == EffectiveConfigParserBehavior.Flag;
|
||||
var writeBehavior = EffectiveConfigWriteSemantics.GetBehavior(c.OptionName);
|
||||
var confKey = c.OptionName;
|
||||
|
||||
bool MatchesOption(DnsmasqConfLine line)
|
||||
@@ -242,7 +241,56 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
return kv != null && string.Equals(kv.Value.key, confKey, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (behavior == EffectiveConfigParserBehavior.Multi && TryGetMultiValues(c.NewValue, out var values))
|
||||
if (writeBehavior == EffectiveConfigWriteBehavior.KeyOnlyOrValue)
|
||||
{
|
||||
RemoveAllMatchingLines(list, MatchesOption);
|
||||
var value = c.NewValue as string;
|
||||
if (value is null)
|
||||
continue;
|
||||
var lineText = value.Length == 0 ? confKey : $"{confKey}={value.Trim()}";
|
||||
list.Add(new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText });
|
||||
maxLineNumber++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (writeBehavior == EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue && TryGetMultiValues(c.NewValue, out var multiKeyOnlyValues))
|
||||
{
|
||||
IReadOnlyList<string> readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty<string>();
|
||||
var valuesToWrite = FilterManagedOnly(multiKeyOnlyValues, readonlyValues);
|
||||
RemoveAllMatchingLines(list, MatchesOption);
|
||||
foreach (var v in valuesToWrite)
|
||||
{
|
||||
var raw = string.IsNullOrEmpty(v) ? confKey : $"{confKey}={v.Trim()}";
|
||||
list.Add(new OtherLine { LineNumber = ++maxLineNumber, RawLine = raw });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (writeBehavior == EffectiveConfigWriteBehavior.InversePair)
|
||||
{
|
||||
var pair = EffectiveConfigWriteSemantics.GetInversePairKeys(confKey);
|
||||
if (pair is null)
|
||||
continue;
|
||||
var (pairKeyA, pairKeyB) = pair.Value;
|
||||
bool MatchesPair(DnsmasqConfLine line)
|
||||
{
|
||||
var raw = DnsmasqConfFileLineParser.ToLine(line);
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(raw);
|
||||
return kv != null && (string.Equals(kv.Value.key, pairKeyA, StringComparison.Ordinal) || string.Equals(kv.Value.key, pairKeyB, StringComparison.Ordinal));
|
||||
}
|
||||
RemoveAllMatchingLines(list, MatchesPair);
|
||||
if (c.NewValue is ExplicitToggleState state && state != ExplicitToggleState.Default)
|
||||
{
|
||||
var lineKey = state == ExplicitToggleState.Enabled ? pairKeyA : pairKeyB;
|
||||
list.Add(new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineKey });
|
||||
maxLineNumber++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var isFlag = writeBehavior == EffectiveConfigWriteBehavior.Flag;
|
||||
|
||||
if (writeBehavior == EffectiveConfigWriteBehavior.MultiValue && TryGetMultiValues(c.NewValue, out var values))
|
||||
{
|
||||
IReadOnlyList<string> readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty<string>();
|
||||
var valuesToWrite = FilterManagedOnly(values, readonlyValues);
|
||||
@@ -271,13 +319,23 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
if (idx >= 0) list.RemoveAt(idx);
|
||||
continue;
|
||||
}
|
||||
if (!isFlag)
|
||||
{
|
||||
var v = ToConfValue(c.NewValue);
|
||||
if (string.IsNullOrWhiteSpace(v))
|
||||
{
|
||||
// Non-flag option with no value: remove existing line if any; never write key-only (malformed).
|
||||
if (idx >= 0) list.RemoveAt(idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
string rawLine;
|
||||
if (isFlag)
|
||||
rawLine = confKey;
|
||||
else
|
||||
{
|
||||
var v = ToConfValue(c.NewValue);
|
||||
rawLine = string.IsNullOrEmpty(v) ? confKey : confKey + "=" + v;
|
||||
var v = ToConfValue(c.NewValue).Trim();
|
||||
rawLine = confKey + "=" + v;
|
||||
}
|
||||
var newLine = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = rawLine };
|
||||
maxLineNumber++;
|
||||
@@ -289,6 +347,15 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
await WriteManagedConfigAsync(list, ct);
|
||||
}
|
||||
|
||||
private static void RemoveAllMatchingLines(List<DnsmasqConfLine> list, Predicate<DnsmasqConfLine> match)
|
||||
{
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (match(list[i]))
|
||||
list.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps only values that should be written to managed config by subtracting values provided by
|
||||
/// non-managed files (multiset subtraction, order preserved).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||
@@ -56,22 +57,7 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
|
||||
|
||||
/// <summary>Parses dhcp-range value to (startIp, endIp). Format is typically start,end,mask,lease or tag:...,start,end,...; finds first two IPv4-looking tokens.</summary>
|
||||
internal static (string? Start, string? End) ParseDhcpRangeStartEnd(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return (null, null);
|
||||
var parts = raw.Split(',');
|
||||
string? start = null;
|
||||
string? end = null;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
var t = p.Trim();
|
||||
if (string.IsNullOrEmpty(t)) continue;
|
||||
if (!System.Net.IPAddress.TryParse(t, out var ip) || ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
continue;
|
||||
if (start == null) { start = t; continue; }
|
||||
if (end == null) { end = t; break; }
|
||||
}
|
||||
return (start, end);
|
||||
}
|
||||
=> DnsmasqDhcpRangeValueParser.GetIPv4StartEnd(raw);
|
||||
|
||||
private ConfigSetSnapshot GetSnapshot() =>
|
||||
_cache.GetSnapshotAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||
|
||||
/// <summary>Probes installed dnsmasq version and compares it to the configured minimum.</summary>
|
||||
public interface IDnsmasqVersionService : IApplicationScopedService
|
||||
{
|
||||
/// <summary>Runs the version command, parses output, and returns version info including support status.</summary>
|
||||
Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version;
|
||||
|
||||
/// <summary>At startup, verifies dnsmasq version meets minimum when <see cref="Models.Config.DnsmasqOptions.EnforceMinimumVersion"/> is true.</summary>
|
||||
public sealed class DnsmasqVersionEnforcementHostedService : IApplicationHostedService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IOptions<Models.Config.DnsmasqOptions> _options;
|
||||
|
||||
public DnsmasqVersionEnforcementHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<Models.Config.DnsmasqOptions> options)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
if (!opts.EnforceMinimumVersion) return;
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var versionService = scope.ServiceProvider.GetRequiredService<IDnsmasqVersionService>();
|
||||
var info = await versionService.GetVersionInfoAsync(ct);
|
||||
|
||||
if (!info.ProbeSucceeded)
|
||||
throw new InvalidOperationException($"dnsmasq version probe failed: {info.Error}");
|
||||
|
||||
if (!info.IsSupported)
|
||||
throw new InvalidOperationException(
|
||||
$"Installed dnsmasq version {info.InstalledVersion} is below minimum {info.MinimumVersion}.");
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version;
|
||||
|
||||
/// <summary>Readiness check for /healthz/ready: dnsmasq version meets minimum and, when StatusCommand is configured, dnsmasq is running.</summary>
|
||||
public sealed class DnsmasqVersionHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IDnsmasqVersionService _versionService;
|
||||
private readonly IProcessRunner _processRunner;
|
||||
private readonly DnsmasqOptions _options;
|
||||
|
||||
public DnsmasqVersionHealthCheck(
|
||||
IDnsmasqVersionService versionService,
|
||||
IProcessRunner processRunner,
|
||||
IOptions<DnsmasqOptions> options)
|
||||
{
|
||||
_versionService = versionService;
|
||||
_processRunner = processRunner;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
|
||||
{
|
||||
var info = await _versionService.GetVersionInfoAsync(ct);
|
||||
|
||||
if (!info.ProbeSucceeded)
|
||||
return HealthCheckResult.Unhealthy("dnsmasq version probe failed", data: new Dictionary<string, object> { ["error"] = info.Error ?? "" });
|
||||
|
||||
if (!info.IsSupported)
|
||||
return HealthCheckResult.Unhealthy(
|
||||
$"dnsmasq version {info.InstalledVersion} is below minimum {info.MinimumVersion}",
|
||||
data: new Dictionary<string, object>
|
||||
{
|
||||
["installed"] = info.InstalledVersion?.ToString() ?? "",
|
||||
["minimum"] = info.MinimumVersion.ToString()
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.StatusCommand))
|
||||
{
|
||||
var statusResult = await _processRunner.RunAsync(_options.StatusCommand, _options.StatusTimeout, ct);
|
||||
var active = statusResult.ExitCode == 0 && !statusResult.TimedOut && statusResult.ExceptionMessage == null;
|
||||
if (!active)
|
||||
return HealthCheckResult.Unhealthy(
|
||||
"dnsmasq is not running",
|
||||
data: new Dictionary<string, object> { ["error"] = statusResult.ExceptionMessage ?? (statusResult.TimedOut ? "status command timed out" : "status command failed") });
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy($"dnsmasq {info.InstalledVersion} (minimum {info.MinimumVersion})");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version;
|
||||
|
||||
public sealed class DnsmasqVersionService : IDnsmasqVersionService
|
||||
{
|
||||
private readonly DnsmasqOptions _options;
|
||||
private readonly IProcessRunner _processRunner;
|
||||
|
||||
public DnsmasqVersionService(
|
||||
IOptions<DnsmasqOptions> options,
|
||||
IProcessRunner processRunner)
|
||||
{
|
||||
_options = options.Value;
|
||||
_processRunner = processRunner;
|
||||
}
|
||||
|
||||
public async Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default)
|
||||
{
|
||||
var command = _options.VersionCommand?.Trim();
|
||||
var minimumVersion = System.Version.TryParse(_options.MinimumVersion, out var minVer)
|
||||
? minVer
|
||||
: new System.Version(2, 91);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
var emptyCaps = new DnsmasqCompileCapabilities(false, false, false, false, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||
return new DnsmasqVersionInfo(
|
||||
InstalledVersion: null,
|
||||
MinimumVersion: minimumVersion,
|
||||
ProbeSucceeded: false,
|
||||
IsSupported: false,
|
||||
ProbeCommand: "",
|
||||
Error: "Version command is not configured.",
|
||||
Capabilities: emptyCaps);
|
||||
}
|
||||
|
||||
var result = await _processRunner.RunAsync(command, _options.VersionTimeout, ct);
|
||||
|
||||
var error = result.TimedOut
|
||||
? "Version command timed out."
|
||||
: !string.IsNullOrWhiteSpace(result.ExceptionMessage)
|
||||
? result.ExceptionMessage
|
||||
: null;
|
||||
|
||||
var installed = DnsmasqVersionParser.TryParse(result.Stdout, result.Stderr);
|
||||
if (installed == null && error == null)
|
||||
error = "Could not parse version from command output.";
|
||||
|
||||
var probeSucceeded = installed != null;
|
||||
var isSupported = probeSucceeded && installed!.CompareTo(minimumVersion) >= 0;
|
||||
var capabilities = DnsmasqCompileOptionsParser.Parse(result.Stdout, result.Stderr);
|
||||
|
||||
return new DnsmasqVersionInfo(
|
||||
InstalledVersion: installed,
|
||||
MinimumVersion: minimumVersion,
|
||||
ProbeSucceeded: probeSucceeded,
|
||||
IsSupported: isSupported,
|
||||
ProbeCommand: command,
|
||||
Error: error,
|
||||
Capabilities: capabilities);
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -4,7 +4,7 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped orchestrator for effective-config edit lifecycle: edit mode, active field, pending changes, and apply.
|
||||
/// Scoped orchestrator for effective-config edit lifecycle: edit mode, active field, pending changes, validation, and apply.
|
||||
/// Components read state from here and call methods; the owning section re-renders after state changes.
|
||||
/// </summary>
|
||||
public interface IEffectiveConfigEditSession : IApplicationScopedService
|
||||
@@ -13,6 +13,9 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService
|
||||
string? ActiveFieldKey { get; }
|
||||
IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; }
|
||||
|
||||
/// <summary>Per-field validation issues (errors block save; warnings can be confirmed).</summary>
|
||||
IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues { get; }
|
||||
|
||||
void EnterEditMode();
|
||||
void ExitEditModeDiscard();
|
||||
void ActivateField(string fieldKey);
|
||||
@@ -21,5 +24,16 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService
|
||||
void TrackCommit(EffectiveConfigEditCommittedArgs args);
|
||||
void RevertChange(string sectionId, string optionName);
|
||||
|
||||
void SetFieldIssues(string fieldKey, IReadOnlyList<FieldIssue> issues);
|
||||
void ClearFieldIssues(string fieldKey);
|
||||
|
||||
/// <summary>Replaces all cross-option validation issues (e.g. no-resolv vs server). Merged with per-field issues for display and save guard.</summary>
|
||||
void SetCrossOptionIssues(IReadOnlyList<FieldIssue> issues);
|
||||
|
||||
/// <summary>True if any field has one or more issues with <see cref="FieldIssueSeverity.Error"/>.</summary>
|
||||
bool HasBlockingValidationErrors();
|
||||
/// <summary>All issues across fields for summary display (e.g. toolbar count or save guard message).</summary>
|
||||
IReadOnlyList<FieldIssue> GetValidationSummary();
|
||||
|
||||
Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
+10
@@ -27,4 +27,14 @@ public interface IEffectiveConfigRenderFragmentRegistry : IApplicationSingleton
|
||||
/// Returns a factory that creates the correct descriptor type for this field (e.g. <see cref="EffectiveIntegerConfigFieldDescriptor"/>), or null to use the default.
|
||||
/// </summary>
|
||||
EffectiveConfigDescriptorFactory? GetDescriptorFactory(string sectionId, string optionName);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fragment that renders the custom multi-value component for this field, or null to use the generic list editor.
|
||||
/// </summary>
|
||||
RenderFragment<EffectiveConfigFieldDescriptor>? BuildMultiFieldComponentFragment(string sectionId, string optionName, EventCallback<IReadOnlyList<string>> onValuesChanged);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a factory that creates a multi-value descriptor (e.g. <see cref="EffectiveMultiValueConfigFieldDescriptor"/>), or null to use the plain descriptor.
|
||||
/// </summary>
|
||||
EffectiveConfigMultiDescriptorFactory? GetMultiDescriptorFactory(string sectionId, string optionName);
|
||||
}
|
||||
|
||||
+56
@@ -7,6 +7,8 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
||||
{
|
||||
private readonly IEffectiveConfigSaveService _saveService;
|
||||
private readonly List<PendingEffectiveConfigChange> _pending = new();
|
||||
private readonly Dictionary<string, List<FieldIssue>> _fieldIssues = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<FieldIssue> _crossOptionIssues = new();
|
||||
|
||||
public EffectiveConfigEditSession(IEffectiveConfigSaveService saveService)
|
||||
{
|
||||
@@ -16,17 +18,37 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
||||
public bool IsEditMode { get; private set; }
|
||||
public string? ActiveFieldKey { get; private set; }
|
||||
public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges => _pending;
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues
|
||||
{
|
||||
get
|
||||
{
|
||||
var copy = new Dictionary<string, List<FieldIssue>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kv in _fieldIssues)
|
||||
copy[kv.Key] = new List<FieldIssue>(kv.Value);
|
||||
foreach (var issue in _crossOptionIssues)
|
||||
{
|
||||
if (!copy.TryGetValue(issue.FieldKey, out var list))
|
||||
copy[issue.FieldKey] = list = new List<FieldIssue>();
|
||||
list.Add(issue);
|
||||
}
|
||||
return copy.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<FieldIssue>)kv.Value, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public void EnterEditMode()
|
||||
{
|
||||
IsEditMode = true;
|
||||
ActiveFieldKey = null;
|
||||
_pending.Clear();
|
||||
_fieldIssues.Clear();
|
||||
_crossOptionIssues.Clear();
|
||||
}
|
||||
|
||||
public void ExitEditModeDiscard()
|
||||
{
|
||||
_pending.Clear();
|
||||
_fieldIssues.Clear();
|
||||
_crossOptionIssues.Clear();
|
||||
ActiveFieldKey = null;
|
||||
IsEditMode = false;
|
||||
}
|
||||
@@ -42,6 +64,39 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
||||
ActiveFieldKey = null;
|
||||
}
|
||||
|
||||
public void SetFieldIssues(string fieldKey, IReadOnlyList<FieldIssue> issues)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fieldKey)) return;
|
||||
_fieldIssues[fieldKey] = issues.ToList();
|
||||
}
|
||||
|
||||
public void ClearFieldIssues(string fieldKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fieldKey)) return;
|
||||
_fieldIssues.Remove(fieldKey);
|
||||
}
|
||||
|
||||
public void SetCrossOptionIssues(IReadOnlyList<FieldIssue> issues)
|
||||
{
|
||||
_crossOptionIssues.Clear();
|
||||
if (issues != null)
|
||||
_crossOptionIssues.AddRange(issues);
|
||||
}
|
||||
|
||||
public bool HasBlockingValidationErrors()
|
||||
{
|
||||
if (_fieldIssues.Values.Any(list => list.Any(i => i.Severity == FieldIssueSeverity.Error)))
|
||||
return true;
|
||||
return _crossOptionIssues.Any(i => i.Severity == FieldIssueSeverity.Error);
|
||||
}
|
||||
|
||||
public IReadOnlyList<FieldIssue> GetValidationSummary()
|
||||
{
|
||||
var list = new List<FieldIssue>(_fieldIssues.Values.SelectMany(x => x));
|
||||
list.AddRange(_crossOptionIssues);
|
||||
return list;
|
||||
}
|
||||
|
||||
public void TrackCommit(EffectiveConfigEditCommittedArgs args)
|
||||
{
|
||||
var existing = _pending.FirstOrDefault(c =>
|
||||
@@ -60,6 +115,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
||||
c.SectionId == sectionId && c.OptionName == optionName);
|
||||
if (existing != null)
|
||||
_pending.Remove(existing);
|
||||
ClearFieldIssues($"{sectionId}:{optionName}");
|
||||
}
|
||||
|
||||
public async Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default)
|
||||
|
||||
+365
-47
@@ -5,6 +5,7 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||
using DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
|
||||
@@ -14,21 +15,47 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
/// </summary>
|
||||
public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragmentRegistry
|
||||
{
|
||||
private static readonly IMultiValueEditBehavior DefaultMultiBehavior = new DefaultMultiValueEditBehavior();
|
||||
|
||||
private readonly IOptionSemanticValidator _semanticValidator;
|
||||
private readonly Dictionary<(string SectionId, string OptionName), Type> _displayComponents = new();
|
||||
private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigDescriptorFactory> _descriptorFactories = new();
|
||||
private readonly Dictionary<(string SectionId, string OptionName), Type> _multiDisplayComponents = new();
|
||||
private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigMultiDescriptorFactory> _multiDescriptorFactories = new();
|
||||
|
||||
public EffectiveConfigRenderFragmentRegistry()
|
||||
public EffectiveConfigRenderFragmentRegistry(IOptionSemanticValidator semanticValidator)
|
||||
{
|
||||
// Port: single-field display (e.g. "53 (default DNS port)").
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay));
|
||||
_semanticValidator = semanticValidator;
|
||||
RegisterAll();
|
||||
}
|
||||
|
||||
// Integer single-value options: component type + factory that creates EffectiveIntegerConfigFieldDescriptor.
|
||||
private void RegisterAll()
|
||||
{
|
||||
RegisterCustomSingles();
|
||||
RegisterIntegerDescriptors();
|
||||
RegisterFlagDisplays();
|
||||
RegisterKeyOnlyOrValueDisplays();
|
||||
RegisterPathValidatedSingles();
|
||||
RegisterMultiValueDescriptors();
|
||||
}
|
||||
|
||||
private void RegisterCustomSingles()
|
||||
{
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay));
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay));
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay));
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay));
|
||||
}
|
||||
|
||||
private void RegisterIntegerDescriptors()
|
||||
{
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AuthTtl, unit: "seconds");
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.EdnsPacketMax, max: 65535);
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.QueryPort, max: 65535, defaultValue: 0);
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.PortLimit, max: 65535);
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.MinPort, min: 1, max: 65535);
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.MaxPort, min: 1, max: 65535);
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DnsForwardMax, min: 1);
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheSize, min: 0);
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.LocalTtl, unit: "seconds");
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.NegTtl, unit: "seconds");
|
||||
@@ -37,51 +64,286 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.MinCacheTtl, unit: "seconds");
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, min: 0);
|
||||
RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpTtl);
|
||||
}
|
||||
|
||||
// Boolean flags: shared display ("Enabled" / "Disabled") for all flag options.
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ExpandHosts);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusPriv);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StrictOrder);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AllServers);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoResolv);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DomainNeeded);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.NoNegcache);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoPoll);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindInterfaces);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindDynamic);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDebug);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DnsLoopDetect);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StopDnsRebind);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RebindLocalhostOk);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ClearOnReload);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Filterwin2k);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterA);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterAaaa);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LocaliseQueries);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.ReadEthers);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRapidCommit);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.LeasefileRo);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.EnableTftp);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpSecure);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoFail);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoBlocksize);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Selfmx);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.Dnssec);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.EnableRa);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDhcp);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.KeepInForeground);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDaemon);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.ProxyDnssec);
|
||||
private void RegisterFlagDisplays()
|
||||
{
|
||||
RegisterFlags(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ReadEthers);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionResolver,
|
||||
DnsmasqConfKeys.ExpandHosts,
|
||||
DnsmasqConfKeys.BogusPriv,
|
||||
DnsmasqConfKeys.StrictOrder,
|
||||
DnsmasqConfKeys.AllServers,
|
||||
DnsmasqConfKeys.NoResolv,
|
||||
DnsmasqConfKeys.DomainNeeded,
|
||||
DnsmasqConfKeys.DnsLoopDetect,
|
||||
DnsmasqConfKeys.StopDnsRebind,
|
||||
DnsmasqConfKeys.RebindLocalhostOk,
|
||||
DnsmasqConfKeys.ClearOnReload,
|
||||
DnsmasqConfKeys.Filterwin2k,
|
||||
DnsmasqConfKeys.FilterA,
|
||||
DnsmasqConfKeys.FilterAaaa,
|
||||
DnsmasqConfKeys.LocaliseQueries,
|
||||
DnsmasqConfKeys.NoRoundRobin);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||
DnsmasqConfKeys.DhcpAuthoritative,
|
||||
DnsmasqConfKeys.DhcpRapidCommit,
|
||||
DnsmasqConfKeys.LeasefileRo,
|
||||
DnsmasqConfKeys.DhcpSequentialIp,
|
||||
DnsmasqConfKeys.DhcpIgnoreClid,
|
||||
DnsmasqConfKeys.NoPing,
|
||||
DnsmasqConfKeys.ScriptArp,
|
||||
DnsmasqConfKeys.ScriptOnRenewal,
|
||||
DnsmasqConfKeys.DhcpNoOverride);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
||||
DnsmasqConfKeys.EnableTftp,
|
||||
DnsmasqConfKeys.TftpSecure,
|
||||
DnsmasqConfKeys.TftpNoFail,
|
||||
DnsmasqConfKeys.TftpNoBlocksize);
|
||||
RegisterFlags(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx, DnsmasqConfKeys.Selfmx);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionDnssec,
|
||||
DnsmasqConfKeys.Dnssec,
|
||||
DnsmasqConfKeys.ProxyDnssec,
|
||||
DnsmasqConfKeys.DnssecNoTimecheck,
|
||||
DnsmasqConfKeys.DnssecDebug);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionCache,
|
||||
DnsmasqConfKeys.NoNegcache,
|
||||
DnsmasqConfKeys.StripMac,
|
||||
DnsmasqConfKeys.StripSubnet);
|
||||
RegisterFlags(
|
||||
EffectiveConfigFieldBuilder.SectionProcess,
|
||||
DnsmasqConfKeys.NoPoll,
|
||||
DnsmasqConfKeys.BindInterfaces,
|
||||
DnsmasqConfKeys.BindDynamic,
|
||||
DnsmasqConfKeys.LogDebug,
|
||||
DnsmasqConfKeys.EnableRa,
|
||||
DnsmasqConfKeys.LogDhcp,
|
||||
DnsmasqConfKeys.KeepInForeground,
|
||||
DnsmasqConfKeys.NoDaemon,
|
||||
DnsmasqConfKeys.Conntrack,
|
||||
DnsmasqConfKeys.QuietDhcp,
|
||||
DnsmasqConfKeys.QuietDhcp6,
|
||||
DnsmasqConfKeys.QuietRa,
|
||||
DnsmasqConfKeys.QuietTftp);
|
||||
}
|
||||
|
||||
// log-queries: dropdown (Off / On / extra / proto / auth).
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay));
|
||||
private void RegisterKeyOnlyOrValueDisplays()
|
||||
{
|
||||
RegisterSemanticSingleComponent(
|
||||
EffectiveConfigFieldBuilder.SectionCache,
|
||||
typeof(KeyOnlyOrValueDisplay),
|
||||
DnsmasqConfKeys.UseStaleCache,
|
||||
DnsmasqConfKeys.AddMac,
|
||||
DnsmasqConfKeys.AddSubnet,
|
||||
DnsmasqConfKeys.Umbrella);
|
||||
RegisterSemanticSingleComponent(
|
||||
EffectiveConfigFieldBuilder.SectionResolver,
|
||||
typeof(KeyOnlyOrValueDisplay),
|
||||
DnsmasqConfKeys.ConnmarkAllowlistEnable);
|
||||
RegisterSemanticSingleComponent(
|
||||
EffectiveConfigFieldBuilder.SectionDnssec,
|
||||
typeof(KeyOnlyOrValueDisplay),
|
||||
DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
RegisterComponents(
|
||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||
typeof(KeyOnlyOrValueDisplay),
|
||||
DnsmasqConfKeys.DhcpGenerateNames,
|
||||
DnsmasqConfKeys.DhcpBroadcast,
|
||||
DnsmasqConfKeys.BootpDynamic);
|
||||
}
|
||||
|
||||
// local-service: dropdown (not set / net / host).
|
||||
RegisterComponent(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay));
|
||||
private void RegisterPathValidatedSingles()
|
||||
{
|
||||
RegisterSemanticSingles(
|
||||
EffectiveConfigFieldBuilder.SectionHosts,
|
||||
DnsmasqConfKeys.Hostsdir);
|
||||
RegisterSemanticSingles(
|
||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||
DnsmasqConfKeys.DhcpLeasefile,
|
||||
DnsmasqConfKeys.DhcpScript,
|
||||
DnsmasqConfKeys.DhcpLuascript);
|
||||
RegisterSemanticSingles(
|
||||
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
||||
DnsmasqConfKeys.TftpRoot);
|
||||
RegisterSemanticSingles(
|
||||
EffectiveConfigFieldBuilder.SectionDnssec,
|
||||
DnsmasqConfKeys.DnssecTimestamp);
|
||||
RegisterSemanticSingles(
|
||||
EffectiveConfigFieldBuilder.SectionCache,
|
||||
DnsmasqConfKeys.Dumpfile);
|
||||
RegisterSemanticSingles(
|
||||
EffectiveConfigFieldBuilder.SectionProcess,
|
||||
DnsmasqConfKeys.PidFile);
|
||||
}
|
||||
|
||||
private void RegisterMultiValueDescriptors()
|
||||
{
|
||||
RegisterSemanticMultiDescriptor(
|
||||
EffectiveConfigFieldBuilder.SectionResolver,
|
||||
DnsmasqConfKeys.Server,
|
||||
behavior: new DistinctMultiValueEditBehavior());
|
||||
RegisterSemanticMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.ListenAddress);
|
||||
|
||||
RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts);
|
||||
RegisterSemanticMultis(
|
||||
EffectiveConfigFieldBuilder.SectionResolver,
|
||||
DnsmasqConfKeys.Local,
|
||||
DnsmasqConfKeys.ResolvFile,
|
||||
DnsmasqConfKeys.RevServer,
|
||||
DnsmasqConfKeys.Address,
|
||||
DnsmasqConfKeys.RebindDomainOk,
|
||||
DnsmasqConfKeys.BogusNxdomain,
|
||||
DnsmasqConfKeys.IgnoreAddress,
|
||||
DnsmasqConfKeys.Alias,
|
||||
DnsmasqConfKeys.Ipset,
|
||||
DnsmasqConfKeys.Nftset,
|
||||
DnsmasqConfKeys.ConnmarkAllowlist);
|
||||
RegisterSemanticMultis(
|
||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||
DnsmasqConfKeys.DhcpRange,
|
||||
DnsmasqConfKeys.DhcpHost,
|
||||
DnsmasqConfKeys.DhcpOption,
|
||||
DnsmasqConfKeys.DhcpOptionForce,
|
||||
DnsmasqConfKeys.DhcpMatch,
|
||||
DnsmasqConfKeys.DhcpMac,
|
||||
DnsmasqConfKeys.DhcpIgnoreNames,
|
||||
DnsmasqConfKeys.DhcpNameMatch,
|
||||
DnsmasqConfKeys.DhcpHostsfile,
|
||||
DnsmasqConfKeys.DhcpOptsfile,
|
||||
DnsmasqConfKeys.DhcpHostsdir,
|
||||
DnsmasqConfKeys.DhcpOptsdir,
|
||||
DnsmasqConfKeys.Leasequery,
|
||||
DnsmasqConfKeys.DhcpRelay,
|
||||
DnsmasqConfKeys.DhcpProxy,
|
||||
DnsmasqConfKeys.RaParam,
|
||||
DnsmasqConfKeys.TagIf,
|
||||
DnsmasqConfKeys.BridgeInterface,
|
||||
DnsmasqConfKeys.SharedNetwork,
|
||||
DnsmasqConfKeys.DhcpBoot,
|
||||
DnsmasqConfKeys.DhcpIgnore,
|
||||
DnsmasqConfKeys.DhcpVendorclass,
|
||||
DnsmasqConfKeys.DhcpUserclass,
|
||||
DnsmasqConfKeys.Slaac);
|
||||
|
||||
RegisterSemanticMultis(
|
||||
EffectiveConfigFieldBuilder.SectionResolver,
|
||||
DnsmasqConfKeys.FilterRr);
|
||||
RegisterSemanticMultis(
|
||||
EffectiveConfigFieldBuilder.SectionDnsRecords,
|
||||
DnsmasqConfKeys.Domain,
|
||||
DnsmasqConfKeys.Cname,
|
||||
DnsmasqConfKeys.MxHost,
|
||||
DnsmasqConfKeys.PtrRecord,
|
||||
DnsmasqConfKeys.InterfaceName,
|
||||
DnsmasqConfKeys.CaaRecord,
|
||||
DnsmasqConfKeys.Srv,
|
||||
DnsmasqConfKeys.NaptrRecord,
|
||||
DnsmasqConfKeys.TxtRecord,
|
||||
DnsmasqConfKeys.HostRecord,
|
||||
DnsmasqConfKeys.DynamicHost,
|
||||
DnsmasqConfKeys.DnsRr,
|
||||
DnsmasqConfKeys.SynthDomain,
|
||||
DnsmasqConfKeys.AuthZone,
|
||||
DnsmasqConfKeys.AuthSoa,
|
||||
DnsmasqConfKeys.AuthSecServers,
|
||||
DnsmasqConfKeys.AuthPeer);
|
||||
RegisterSemanticMultis(
|
||||
EffectiveConfigFieldBuilder.SectionDhcp,
|
||||
DnsmasqConfKeys.DhcpCircuitid,
|
||||
DnsmasqConfKeys.DhcpRemoteid,
|
||||
DnsmasqConfKeys.DhcpSubscrid);
|
||||
RegisterSemanticMultis(
|
||||
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
||||
DnsmasqConfKeys.PxeService);
|
||||
RegisterSemanticMultis(
|
||||
EffectiveConfigFieldBuilder.SectionTftpPxe,
|
||||
DnsmasqConfKeys.DhcpOptionPxe);
|
||||
RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor);
|
||||
RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr);
|
||||
RegisterSemanticMultis(
|
||||
EffectiveConfigFieldBuilder.SectionProcess,
|
||||
DnsmasqConfKeys.Interface,
|
||||
DnsmasqConfKeys.ExceptInterface,
|
||||
DnsmasqConfKeys.AuthServer,
|
||||
DnsmasqConfKeys.NoDhcpInterface,
|
||||
DnsmasqConfKeys.NoDhcpv4Interface,
|
||||
DnsmasqConfKeys.NoDhcpv6Interface);
|
||||
}
|
||||
|
||||
private void RegisterComponents(string sectionId, Type componentType, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
RegisterComponent(sectionId, optionName, componentType);
|
||||
}
|
||||
|
||||
private void RegisterFlags(string sectionId, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
RegisterFlag(sectionId, optionName);
|
||||
}
|
||||
|
||||
private void RegisterSemanticSingles(string sectionId, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
RegisterSemanticSingleValidator(sectionId, optionName);
|
||||
}
|
||||
|
||||
private void RegisterSemanticSingleComponent(string sectionId, Type componentType, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
{
|
||||
RegisterComponent(sectionId, optionName, componentType);
|
||||
RegisterSemanticSingleValidator(sectionId, optionName);
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterMultis(string sectionId, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
RegisterMultiDescriptor(sectionId, optionName);
|
||||
}
|
||||
|
||||
private void RegisterSemanticMultis(string sectionId, params string[] optionNames)
|
||||
{
|
||||
foreach (var optionName in optionNames)
|
||||
RegisterSemanticMultiDescriptor(sectionId, optionName);
|
||||
}
|
||||
|
||||
private void RegisterMultiDescriptor(
|
||||
string sectionId,
|
||||
string optionName,
|
||||
IMultiValueEditBehavior? behavior = null,
|
||||
IMultiValueOptionValidator? validator = null)
|
||||
{
|
||||
_multiDescriptorFactories[(sectionId, optionName)] =
|
||||
(sid, name, status, getItems) => new EffectiveMultiValueConfigFieldDescriptor(
|
||||
sid,
|
||||
name,
|
||||
status,
|
||||
getItems,
|
||||
behavior ?? DefaultMultiBehavior,
|
||||
validator);
|
||||
}
|
||||
|
||||
private void RegisterMultiComponent(string sectionId, string optionName, Type componentType)
|
||||
{
|
||||
_multiDisplayComponents[(sectionId, optionName)] = componentType;
|
||||
}
|
||||
|
||||
private void RegisterMulti(
|
||||
string sectionId,
|
||||
string optionName,
|
||||
Type componentType,
|
||||
IMultiValueEditBehavior? behavior = null,
|
||||
IMultiValueOptionValidator? validator = null)
|
||||
{
|
||||
RegisterMultiDescriptor(sectionId, optionName, behavior, validator);
|
||||
RegisterMultiComponent(sectionId, optionName, componentType);
|
||||
}
|
||||
|
||||
private void RegisterComponent(string sectionId, string optionName, Type componentType)
|
||||
@@ -99,7 +361,42 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
||||
{
|
||||
_displayComponents[(sectionId, optionName)] = typeof(IntegerValueDisplay);
|
||||
_descriptorFactories[(sectionId, optionName)] = (sectionId, optionName, status, getValue, getSource, getItems) =>
|
||||
new EffectiveIntegerConfigFieldDescriptor(sectionId, optionName, false, status, getValue, getSource, getItems, viewSuffix, unit, min, max, defaultValue);
|
||||
new EffectiveIntegerConfigFieldDescriptor(sectionId, optionName, status, getValue, getSource, getItems, viewSuffix, unit, min, max, defaultValue);
|
||||
}
|
||||
|
||||
private void RegisterValidatedSingle(string sectionId, string optionName, EffectiveConfigSingleValueValidator validator)
|
||||
{
|
||||
_descriptorFactories[(sectionId, optionName)] =
|
||||
(sid, name, status, getValue, getSource, getItems) => new EffectiveConfigFieldDescriptor(
|
||||
sid,
|
||||
name,
|
||||
false,
|
||||
status,
|
||||
getValue,
|
||||
getSource,
|
||||
getItems,
|
||||
validator);
|
||||
}
|
||||
|
||||
private void RegisterSemanticSingleValidator(string sectionId, string optionName)
|
||||
{
|
||||
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName);
|
||||
if (semantics is null)
|
||||
return;
|
||||
|
||||
RegisterValidatedSingle(
|
||||
sectionId,
|
||||
optionName,
|
||||
value => _semanticValidator.ValidateSingle(optionName, value, semantics.Validation));
|
||||
}
|
||||
|
||||
private void RegisterSemanticMultiDescriptor(string sectionId, string optionName, IMultiValueEditBehavior? behavior = null)
|
||||
{
|
||||
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName);
|
||||
IMultiValueOptionValidator? validator = semantics is null
|
||||
? null
|
||||
: new DelegateMultiValueOptionValidator(v => _semanticValidator.ValidateMultiItem(optionName, v, semantics.Validation));
|
||||
RegisterMultiDescriptor(sectionId, optionName, behavior, validator);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -131,4 +428,25 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
||||
builder.CloseComponent();
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public RenderFragment<EffectiveConfigFieldDescriptor>? BuildMultiFieldComponentFragment(string sectionId, string optionName, EventCallback<IReadOnlyList<string>> onValuesChanged)
|
||||
{
|
||||
if (!_multiDisplayComponents.TryGetValue((sectionId, optionName), out var componentType))
|
||||
return null;
|
||||
|
||||
return descriptor => builder =>
|
||||
{
|
||||
builder.OpenComponent(0, componentType);
|
||||
builder.AddAttribute(1, "Descriptor", descriptor);
|
||||
builder.AddAttribute(2, "OnValuesChanged", onValuesChanged);
|
||||
builder.CloseComponent();
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public EffectiveConfigMultiDescriptorFactory? GetMultiDescriptorFactory(string sectionId, string optionName)
|
||||
{
|
||||
return _multiDescriptorFactories.TryGetValue((sectionId, optionName), out var factory) ? factory : null;
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -1,6 +1,9 @@
|
||||
using System.Linq;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -11,21 +14,30 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
||||
{
|
||||
private readonly IDnsmasqConfigSetService _configSetService;
|
||||
private readonly IDnsmasqConfigService _configService;
|
||||
private readonly IConfigSetCache _configSetCache;
|
||||
private readonly IConfigValidationService _validationService;
|
||||
private readonly IEffectiveConfigSemanticValidationService _semanticValidationService;
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly IDnsmasqVersionService _versionService;
|
||||
private readonly ILogger<EffectiveConfigSaveService> _logger;
|
||||
|
||||
public EffectiveConfigSaveService(
|
||||
IDnsmasqConfigSetService configSetService,
|
||||
IDnsmasqConfigService configService,
|
||||
IConfigSetCache configSetCache,
|
||||
IConfigValidationService validationService,
|
||||
IEffectiveConfigSemanticValidationService semanticValidationService,
|
||||
IReloadService reloadService,
|
||||
IDnsmasqVersionService versionService,
|
||||
ILogger<EffectiveConfigSaveService> logger)
|
||||
{
|
||||
_configSetService = configSetService;
|
||||
_configService = configService;
|
||||
_configSetCache = configSetCache;
|
||||
_validationService = validationService;
|
||||
_semanticValidationService = semanticValidationService;
|
||||
_reloadService = reloadService;
|
||||
_versionService = versionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -37,6 +49,44 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
||||
if (changes.Count == 0)
|
||||
return EffectiveConfigSaveResult.NoChanges();
|
||||
|
||||
var version = await _versionService.GetVersionInfoAsync(ct);
|
||||
if (!version.ProbeSucceeded)
|
||||
{
|
||||
return new EffectiveConfigSaveResult(
|
||||
BackupCreated: false,
|
||||
BackupPath: null,
|
||||
Saved: false,
|
||||
Validated: false,
|
||||
ValidationExitCode: -1,
|
||||
ValidationStdOut: null,
|
||||
ValidationStdErr: null,
|
||||
Restarted: false,
|
||||
RestartExitCode: -1,
|
||||
RestartStdOut: null,
|
||||
RestartStdErr: null,
|
||||
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.VersionProbeFailed,
|
||||
UserMessage: string.IsNullOrWhiteSpace(version.Error)
|
||||
? "Cannot save: dnsmasq version could not be determined."
|
||||
: $"Cannot save: dnsmasq version could not be determined. {version.Error}");
|
||||
}
|
||||
if (!version.IsSupported)
|
||||
{
|
||||
return new EffectiveConfigSaveResult(
|
||||
BackupCreated: false,
|
||||
BackupPath: null,
|
||||
Saved: false,
|
||||
Validated: false,
|
||||
ValidationExitCode: -1,
|
||||
ValidationStdOut: null,
|
||||
ValidationStdErr: null,
|
||||
Restarted: false,
|
||||
RestartExitCode: -1,
|
||||
RestartStdOut: null,
|
||||
RestartStdErr: null,
|
||||
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.UnsupportedVersion,
|
||||
UserMessage: $"Installed dnsmasq {version.InstalledVersion} is below required {version.MinimumVersion}.");
|
||||
}
|
||||
|
||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(set.ManagedFilePath))
|
||||
{
|
||||
@@ -49,6 +99,68 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
||||
var managedPath = set.ManagedFilePath!;
|
||||
var backupPath = BuildBackupPath(managedPath);
|
||||
|
||||
// Reject changes for options not supported by this dnsmasq build (e.g. DNSSEC when built without DNSSEC).
|
||||
var unsupported = changes
|
||||
.Where(c => !EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(c.OptionName, version.Capabilities))
|
||||
.Select(c => c.OptionName)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (unsupported.Count > 0)
|
||||
{
|
||||
var firstReason = EffectiveConfigFeatureRequirements.GetRequiredFeature(unsupported[0]) is { } f
|
||||
? EffectiveConfigFeatureRequirements.GetUnsupportedReason(f)
|
||||
: "This option isn't available with your current dnsmasq build.";
|
||||
var optionList = string.Join(", ", unsupported);
|
||||
_logger.LogWarning("Save rejected: unsupported options in pending changes: {Options}", optionList);
|
||||
var userMessage = unsupported.Count == 1
|
||||
? firstReason
|
||||
: $"Some of the options you changed aren't supported by your dnsmasq build. Options not saved: {optionList}.";
|
||||
return new EffectiveConfigSaveResult(
|
||||
BackupCreated: false,
|
||||
BackupPath: null,
|
||||
Saved: false,
|
||||
Validated: false,
|
||||
ValidationExitCode: -1,
|
||||
ValidationStdOut: null,
|
||||
ValidationStdErr: null,
|
||||
Restarted: false,
|
||||
RestartExitCode: -1,
|
||||
RestartStdOut: null,
|
||||
RestartStdErr: null,
|
||||
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.UnsupportedCapabilities,
|
||||
UserMessage: userMessage);
|
||||
}
|
||||
|
||||
var semanticIssues = _semanticValidationService.Validate(changes);
|
||||
if (semanticIssues.Any(i => i.Severity == FieldIssueSeverity.Error))
|
||||
{
|
||||
var errorIssues = semanticIssues.Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
|
||||
var messages = errorIssues.Select(i => i.Message).Distinct().Take(5).ToList();
|
||||
var userMessage = messages.Count == 1
|
||||
? messages[0]
|
||||
: "Some values are invalid. Fix validation errors before saving. " + string.Join("; ", messages);
|
||||
var failedFields = errorIssues.Select(i => i.FieldKey).Distinct().ToList();
|
||||
_logger.LogWarning(
|
||||
"Save blocked: semantic validation failed for {ErrorCount} error(s) on field(s) {FailedFields}. First message: {FirstMessage}",
|
||||
errorIssues.Count,
|
||||
failedFields,
|
||||
messages.FirstOrDefault());
|
||||
return new EffectiveConfigSaveResult(
|
||||
BackupCreated: false,
|
||||
BackupPath: null,
|
||||
Saved: false,
|
||||
Validated: false,
|
||||
ValidationExitCode: -1,
|
||||
ValidationStdOut: null,
|
||||
ValidationStdErr: null,
|
||||
Restarted: false,
|
||||
RestartExitCode: -1,
|
||||
RestartStdOut: null,
|
||||
RestartStdErr: null,
|
||||
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.SemanticValidationFailed,
|
||||
UserMessage: userMessage);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
CreateBackupIfSourceExists(managedPath, backupPath);
|
||||
@@ -139,6 +251,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
||||
|
||||
var managedPath = set.ManagedFilePath!;
|
||||
File.Copy(backupPath, managedPath, overwrite: true);
|
||||
_configSetCache.Invalidate();
|
||||
_logger.LogInformation("Restored managed config from backup: {BackupPath}", backupPath);
|
||||
|
||||
var restartResult = await _reloadService.ReloadAsync(ct);
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-save semantic validation: runs option semantics validation on pending changes.
|
||||
/// </summary>
|
||||
public sealed class EffectiveConfigSemanticValidationService : IEffectiveConfigSemanticValidationService
|
||||
{
|
||||
private readonly IOptionSemanticValidator _validator;
|
||||
|
||||
public EffectiveConfigSemanticValidationService(IOptionSemanticValidator validator)
|
||||
{
|
||||
_validator = validator;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<FieldIssue> Validate(IReadOnlyList<PendingEffectiveConfigChange> changes)
|
||||
{
|
||||
var issues = new List<FieldIssue>();
|
||||
foreach (var change in changes)
|
||||
{
|
||||
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(change.OptionName);
|
||||
if (semantics is null) continue;
|
||||
|
||||
var fieldKey = $"{change.SectionId}:{change.OptionName}";
|
||||
var severity = semantics.Validation.Severity;
|
||||
|
||||
if (change.NewValue is IReadOnlyList<string> list)
|
||||
{
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
var err = _validator.ValidateMultiItem(change.OptionName, list[i], semantics.Validation);
|
||||
if (err is not null)
|
||||
issues.Add(new FieldIssue(fieldKey, err, severity, i));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var err = _validator.ValidateSingle(change.OptionName, change.NewValue, semantics.Validation);
|
||||
if (err is not null)
|
||||
issues.Add(new FieldIssue(fieldKey, err, severity, null));
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Validates pending effective-config changes against option semantics before write.
|
||||
/// Returns field-level issues (errors block save; warnings can be confirmed).
|
||||
/// Registered as singleton via assembly scanning (<see cref="IApplicationSingleton"/>).
|
||||
/// </summary>
|
||||
public interface IEffectiveConfigSemanticValidationService : IApplicationSingleton
|
||||
{
|
||||
/// <summary>Validates each change using option semantics. Returns all issues (errors and warnings).</summary>
|
||||
IReadOnlyList<FieldIssue> Validate(IReadOnlyList<PendingEffectiveConfigChange> changes);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
using System.Net;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Specialized semantic behavior for <c>address</c> values.
|
||||
/// Validates the <c>/domain[/domain...]/ip</c> structure and accepts empty or <c>#</c> address forms.
|
||||
/// </summary>
|
||||
public sealed class AddressSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) =>
|
||||
optionName == DnsmasqConfKeys.Address;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = value?.Trim() ?? "";
|
||||
if (s.Length == 0)
|
||||
return "Value cannot be empty.";
|
||||
|
||||
if (!DnsmasqScopedDomainSyntax.TrySplitScopedValue(s, out var domainParts, out var addressPart, out var error))
|
||||
return error == "Value must start with '/'."
|
||||
? "Address must start with '/'."
|
||||
: "Address must use /domain[/domain...]/ip syntax.";
|
||||
|
||||
error = DnsmasqScopedDomainSyntax.ValidateDomainPatterns(domainParts);
|
||||
if (error is not null)
|
||||
return error.Replace("Value", "Address", StringComparison.Ordinal);
|
||||
|
||||
addressPart = addressPart.Trim();
|
||||
if (addressPart.Length == 0 || addressPart == "#")
|
||||
return null;
|
||||
|
||||
return IPAddress.TryParse(addressPart, out _)
|
||||
? null
|
||||
: "Address target must be empty, '#', or a valid IP address.";
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using System.Net;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Specialized semantic behavior for <c>alias</c> values.
|
||||
/// Supports IPv4 single-address or IPv4 range mapping forms.
|
||||
/// </summary>
|
||||
public sealed class AliasSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) =>
|
||||
optionName == DnsmasqConfKeys.Alias;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = value?.Trim() ?? "";
|
||||
if (s.Length == 0)
|
||||
return "Value cannot be empty.";
|
||||
|
||||
var tokens = s.Split(',').Select(t => t.Trim()).ToArray();
|
||||
if (tokens.Length is < 2 or > 3 || tokens.Any(t => t.Length == 0))
|
||||
return "alias must be old-ip,new-ip[,mask] or start-ip-end-ip,new-ip[,mask].";
|
||||
|
||||
if (!IsValidOldIpOrRange(tokens[0]))
|
||||
return "alias first value must be an IPv4 address or IPv4 range.";
|
||||
if (!IsIPv4(tokens[1]))
|
||||
return "alias replacement address must be a valid IPv4 address.";
|
||||
if (tokens.Length == 3 && !IsIPv4(tokens[2]))
|
||||
return "alias mask must be a valid IPv4 address.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsValidOldIpOrRange(string value)
|
||||
{
|
||||
if (IsIPv4(value))
|
||||
return true;
|
||||
|
||||
var parts = value.Split('-', 2);
|
||||
return parts.Length == 2 && IsIPv4(parts[0]) && IsIPv4(parts[1]);
|
||||
}
|
||||
|
||||
private static bool IsIPv4(string value) =>
|
||||
IPAddress.TryParse(value, out var ip) &&
|
||||
ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using System.Net;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>auth-peer</c> values: <ip-address>[,<ip-address>...].
|
||||
/// One or more IP addresses (secondary servers allowed to initiate AXFR).
|
||||
/// </summary>
|
||||
public sealed class AuthPeerSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.AuthPeer;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = (value ?? "").Trim();
|
||||
if (s.Length == 0)
|
||||
return "auth-peer value cannot be empty.";
|
||||
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length == 0 || tokens.Any(t => t.Length == 0))
|
||||
return "auth-peer must contain at least one IP address.";
|
||||
foreach (var t in tokens)
|
||||
{
|
||||
if (!IPAddress.TryParse(t, out _))
|
||||
return "auth-peer each value must be a valid IP address.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>auth-sec-servers</c> values: <domain>[,<domain>...].
|
||||
/// One or more domain names (secondary servers for authoritative zones).
|
||||
/// </summary>
|
||||
public sealed class AuthSecServersSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.AuthSecServers;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = (value ?? "").Trim();
|
||||
if (s.Length == 0)
|
||||
return "auth-sec-servers value cannot be empty.";
|
||||
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length == 0 || tokens.Any(t => t.Length == 0))
|
||||
return "auth-sec-servers must contain at least one domain name.";
|
||||
foreach (var t in tokens)
|
||||
{
|
||||
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(t))
|
||||
return "auth-sec-servers each domain must be a valid DNS name.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
using System.Net;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>auth-server</c> values: <domain>,[<interface>|<ip-address>...].
|
||||
/// Domain (glue record) is required; optional comma-separated list of interfaces or IPs; interface may have /4 or /6.
|
||||
/// </summary>
|
||||
public sealed class AuthServerSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.AuthServer;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = (value ?? "").Trim();
|
||||
if (s.Length == 0)
|
||||
return "auth-server value cannot be empty.";
|
||||
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length == 0 || tokens[0].Length == 0)
|
||||
return "auth-server requires a domain (glue record).";
|
||||
for (var i = 1; i < tokens.Length; i++)
|
||||
{
|
||||
var token = tokens[i];
|
||||
if (token.Length == 0)
|
||||
return "auth-server list cannot contain empty interface or address.";
|
||||
if (IsIpOrInterface(token))
|
||||
continue;
|
||||
return "auth-server: each item after the domain must be an IP address or interface name (optional /4 or /6).";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsIpOrInterface(string token)
|
||||
{
|
||||
if (IPAddress.TryParse(token, out _))
|
||||
return true;
|
||||
if (token.EndsWith("/4", StringComparison.Ordinal) || token.EndsWith("/6", StringComparison.Ordinal))
|
||||
return DnsmasqRelaySyntax.IsInterfaceName(token[..^2]);
|
||||
return DnsmasqRelaySyntax.IsInterfaceName(token);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using System.Globalization;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>auth-soa</c> values: <serial>[,<hostmaster>[,<refresh>[,<retry>[,<expiry>]]]].
|
||||
/// Serial required; optional hostmaster (DNS name), refresh, retry, expiry (numbers).
|
||||
/// </summary>
|
||||
public sealed class AuthSoaSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.AuthSoa;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = (value ?? "").Trim();
|
||||
if (s.Length == 0)
|
||||
return "auth-soa value cannot be empty.";
|
||||
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length is 0 or > 5)
|
||||
return "auth-soa must be <serial>[,<hostmaster>[,<refresh>[,<retry>[,<expiry>]]]].";
|
||||
if (tokens.Any(t => t.Length == 0))
|
||||
return "auth-soa fields cannot be empty.";
|
||||
if (!uint.TryParse(tokens[0], NumberStyles.None, CultureInfo.InvariantCulture, out _))
|
||||
return "auth-soa serial must be a number.";
|
||||
if (tokens.Length >= 2 && !DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[1]))
|
||||
return "auth-soa hostmaster must be a valid DNS name.";
|
||||
for (var i = 2; i < tokens.Length; i++)
|
||||
{
|
||||
if (!uint.TryParse(tokens[i], NumberStyles.None, CultureInfo.InvariantCulture, out _))
|
||||
return "auth-soa refresh, retry and expiry must be numbers.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>auth-zone</c> values: <domain>[,<subnet>[/prefix]...][,exclude:<subnet>...].
|
||||
/// Domain required; optional subnets or exclude: clauses (interface names or CIDR).
|
||||
/// </summary>
|
||||
public sealed class AuthZoneSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.AuthZone;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = (value ?? "").Trim();
|
||||
if (s.Length == 0)
|
||||
return "auth-zone value cannot be empty.";
|
||||
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length == 0 || tokens[0].Length == 0)
|
||||
return "auth-zone requires a domain name.";
|
||||
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
|
||||
return "auth-zone domain must be a valid DNS name.";
|
||||
for (var i = 1; i < tokens.Length; i++)
|
||||
{
|
||||
if (tokens[i].Length == 0)
|
||||
return "auth-zone cannot have empty subnet or exclude fields.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>bogus-nxdomain</c> values.
|
||||
/// </summary>
|
||||
public sealed class BogusNxdomainSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.BogusNxdomain;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = value?.Trim() ?? "";
|
||||
if (s.Length == 0)
|
||||
return "Value cannot be empty.";
|
||||
return DnsmasqIpPrefixSyntax.ValidateIpWithOptionalPrefix(s, "bogus-nxdomain");
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>bridge-interface</c> values.
|
||||
/// </summary>
|
||||
public sealed class BridgeInterfaceSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.BridgeInterface;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = value?.Trim() ?? "";
|
||||
if (s.Length == 0)
|
||||
return "Value cannot be empty.";
|
||||
|
||||
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length < 2 || tokens.Any(t => t.Length == 0))
|
||||
return "bridge-interface must be interface,alias[,alias].";
|
||||
|
||||
if (!DnsmasqDhcpTagSyntax.IsInterfaceLike(tokens[0]))
|
||||
return "bridge-interface must start with a valid interface name.";
|
||||
|
||||
for (var i = 1; i < tokens.Length; i++)
|
||||
{
|
||||
if (!DnsmasqDhcpTagSyntax.IsInterfaceLike(tokens[i], allowWildcard: true))
|
||||
return $"Invalid bridge-interface alias '{tokens[i]}'.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>caa-record</c> values: <name>,<flags>,<tag>,<value> (RFC6844).
|
||||
/// All four parts required.
|
||||
/// </summary>
|
||||
public sealed class CaaRecordSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.CaaRecord;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = (value ?? "").Trim();
|
||||
if (s.Length == 0)
|
||||
return "caa-record value cannot be empty.";
|
||||
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length != 4)
|
||||
return "caa-record must be <name>,<flags>,<tag>,<value>.";
|
||||
if (tokens.Any(t => t.Length == 0))
|
||||
return "caa-record name, flags, tag and value cannot be empty.";
|
||||
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[0]))
|
||||
return "caa-record name must be a valid DNS name.";
|
||||
if (!byte.TryParse(tokens[1], out _))
|
||||
return "caa-record flags must be 0-255.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>cache-rr</c> values: RR-type or comma-separated list (e.g. A,AAAA,TXT or ANY).
|
||||
/// </summary>
|
||||
public sealed class CacheRrSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.CacheRr;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = (value ?? "").Trim();
|
||||
if (s.Length == 0)
|
||||
return "cache-rr value cannot be empty.";
|
||||
foreach (var token in s.Split(',', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (token.Length == 0)
|
||||
return "cache-rr list cannot contain empty RR-type.";
|
||||
if (!DnsmasqRrTypeSyntax.IsValidRrType(token))
|
||||
return "cache-rr RR-type must be a name (e.g. A, TXT, ANY) or decimal number.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation for <c>cname</c> values: <cname>[,<cname>,]<target>[,<TTL>].
|
||||
/// At least one cname and target required; optional TTL as positive integer.
|
||||
/// </summary>
|
||||
public sealed class CnameSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
public bool CanHandle(string optionName) => optionName == DnsmasqConfKeys.Cname;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = (value ?? "").Trim();
|
||||
if (s.Length == 0)
|
||||
return "cname value cannot be empty.";
|
||||
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length < 2)
|
||||
return "cname must be <cname>,<target> or <cname>[,<cname>...],<target>[,<TTL>].";
|
||||
if (tokens.Any(t => t.Length == 0))
|
||||
return "cname list cannot contain empty parts.";
|
||||
var lastIsTtl = tokens.Length >= 3 && int.TryParse(tokens[^1], out var ttl) && ttl > 0;
|
||||
var targetIndex = lastIsTtl ? tokens.Length - 2 : tokens.Length - 1;
|
||||
if (targetIndex < 1)
|
||||
return "cname requires at least one cname and a target.";
|
||||
for (var i = 0; i <= targetIndex; i++)
|
||||
{
|
||||
if (!DnsmasqScopedDomainSyntax.IsValidDnsName(tokens[i]))
|
||||
return "cname and target must be valid DNS names (e.g. host.example.com).";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Specialized semantic behavior for <c>connmark-allowlist</c> values.
|
||||
/// Validates the connmark[/mask] prefix and one or more domain-style patterns or '*' disable form.
|
||||
/// </summary>
|
||||
public sealed partial class ConnmarkAllowlistSemanticHandler : IOptionSemanticHandler
|
||||
{
|
||||
[GeneratedRegex(@"^[A-Za-z0-9*]([A-Za-z0-9*-]*[A-Za-z0-9*])?(\.[A-Za-z0-9*]([A-Za-z0-9*-]*[A-Za-z0-9*])?)+$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex PatternRegex();
|
||||
|
||||
public bool CanHandle(string optionName) =>
|
||||
optionName == DnsmasqConfKeys.ConnmarkAllowlist;
|
||||
|
||||
public string? ValidateSingle(object? value) => null;
|
||||
|
||||
public string? ValidateMultiItem(string? value)
|
||||
{
|
||||
var s = value?.Trim() ?? "";
|
||||
if (s.Length == 0)
|
||||
return "Value cannot be empty.";
|
||||
|
||||
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length < 2 || tokens.Any(t => t.Length == 0))
|
||||
return "connmark-allowlist must be connmark[/mask],pattern[/pattern...].";
|
||||
|
||||
if (!IsValidConnmark(tokens[0]))
|
||||
return "connmark-allowlist mark must be decimal or hex, with optional /mask.";
|
||||
|
||||
if (tokens.Length == 2 && tokens[1] == "*")
|
||||
return null;
|
||||
|
||||
foreach (var token in tokens.Skip(1))
|
||||
{
|
||||
var patterns = token.Split('/');
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
return "connmark-allowlist contains an empty pattern.";
|
||||
if (!IsValidPattern(pattern))
|
||||
return $"Invalid allowlist pattern '{pattern}'.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsValidConnmark(string value)
|
||||
{
|
||||
var parts = value.Split('/', 2);
|
||||
return IsUInt(parts[0]) && (parts.Length == 1 || IsUInt(parts[1]));
|
||||
}
|
||||
|
||||
private static bool IsUInt(string value)
|
||||
{
|
||||
if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return uint.TryParse(value[2..], System.Globalization.NumberStyles.HexNumber, null, out _);
|
||||
return uint.TryParse(value, out _);
|
||||
}
|
||||
|
||||
private static bool IsValidPattern(string value)
|
||||
{
|
||||
if (value.Equals("local", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
return PatternRegex().IsMatch(value) &&
|
||||
!value.Split('.').Last().All(char.IsDigit);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user