20 Commits
Author SHA1 Message Date
alex 9809ce9f7b Bump version to 0.0.7 2026-03-11 09:44:40 +10:00
alex 181cbbed48 Option semantics: handlers, shared syntax, RaParam interface validation
- Add IOptionSemanticHandler implementations for DNS/DHCP/auth/interface options
- Add shared syntax helpers (DnsmasqRrTypeSyntax, DnsmasqScopedDomainSyntax, etc.)
- Wire semantics and registry for multi/single options; metadata-only semantics
- RaParam: use DnsmasqRelaySyntax.IsInterfaceNameWithOptionalTrailingWildcard so
  interface names allow only trailing *, not asterisks in the middle (eth*0, *eth0)
- Tests: RaParam trailing-wildcard and invalid-asterisk cases
2026-03-11 09:38:36 +10:00
alex 36a160dfc7 Refactor: option semantics to metadata + handlers, shared syntax, registry/save single path
- OptionSemantics: metadata only (no validator delegates); OptionValidationKind + PathPolicy
- IOptionSemanticHandler: handler-first validation; handlers registered via IApplicationMultiSingleton
- OptionSemanticValidator: single engine, handler dispatch then generic kind-based validation
- Shared syntax: DnsmasqScopedDomainSyntax, DnsmasqDhcpRangeValueParser, DnsmasqDhcpOptionSyntax,
  DnsmasqRelaySyntax, DnsmasqDhcpTagSyntax, DnsmasqIpPrefixSyntax for reuse across handlers
- New handlers: Server, Local, RevServer, Address, Alias, Ipset, Nftset, DhcpRange, DhcpHost,
  DhcpOption, DhcpRelay, DhcpProxy, IgnoreAddress, ConnmarkAllowlist, DhcpMatch, DhcpMac,
  PxeService, TrustAnchor, RebindDomainOk, BogusNxdomain, DhcpIgnoreNames, DhcpBoot, Slaac,
  BridgeInterface, SharedNetwork, DhcpOptionPxe, DhcpNameMatch, DhcpIgnore, DhcpVendorclass, DhcpUserclass, TagIf
- Registry and pre-save validation use engine only; placeholders via DnsmasqOptionPlaceholders
- DnsmasqConfigSetService uses DnsmasqDhcpRangeValueParser; DhcpRangeValueParserTests added
2026-03-10 23:44:51 +10:00
alex 1bf33f1a9b EffectiveConfig: centralize semantic validation and improve editor UX
Unify parser, editor, and save-time validation around semantic metadata and handlers, add path-aware and structured option checks, and improve effective-config editing placeholders and unset behavior.
2026-03-10 22:33:11 +10:00
alex b4d1f39762 EffectiveConfig: add semantics wiring, validation, and tests
- Add DelegateMultiValueOptionValidator for multi-value option validation
- Extend EffectiveConfigOptionKindMap, SpecialOptionSemantics, RenderFragmentRegistry
- Add EffectiveConfigSemanticsWiringTests
2026-03-10 18:12:28 +10:00
alex 214d6c686e dnsmasq options: dhcp-optsdir, key-only-or-value semantics, capability detection
- Add dhcp-optsdir support (model, sections, parser, UI multi-value).
- Model leasequery, connmark-allowlist-enable, dhcp-generate-names, dhcp-broadcast,
  bootp-dynamic, dnssec-check-unsigned as key-only-or-value with validation.
- Parse compile-time capabilities from dnsmasq --version (DHCP, TFTP, DNSSEC, DBus).
- Expose capabilities via DnsmasqVersionInfo and status API (DnsmasqSupports*).
- Map options to required features; disable unsupported option editors with badge + tooltip.
- Save guard: reject changes for unsupported options; use UnsupportedCapabilities error code.
- MultiKeyOnlyOrValue write path: filter out readonly values to avoid duplicating in managed file.
- User-friendly tooltips for disabled options; dedicated UnsupportedCapabilities vs WriteFailed.
2026-03-10 02:23:14 +10:00
alex 4c1c19b792 EffectiveConfig section CSS/panel updates; add ConfigFileList components 2026-03-09 23:38:47 +10:00
alex 98d1bc560e Reload button labels: StatusSection shows 'Reload', Dnsmasq page button shows 'Reload config' 2026-03-09 23:36:37 +10:00
alex fd16d50107 Add DNS Records and Filters pages; unify effective config layout
- Add EffectiveConfigContext.DnsRecords and .Filters; map in EffectiveConfigViews
- Add DnsRecords.razor and Filters.razor with context-scoped effective config
- Add nav links for DNS records and Filters (icons: diagram-3-fill, funnel-fill)
- Put effective config block at top of Hosts, DHCP, DNS Records, Filters (no config file list on sub-pages)
- Hosts view: add expand-hosts to allow-list. Filters view: add domain-needed, bogus-priv, filterwin2k, no-round-robin
- Page titles: Hosts Configuration, DHCP Configuration, DNS Records Configuration, Filters Configuration
- Remove 'Config affecting...' subheadings and page intro text; add .page-intro and .page-chip styles
2026-03-09 23:22:13 +10:00
alex de8afed68c Effective config: use switch styling for flag and do-0x20-encode checkboxes
- Add shared .ec-effective-switch styles in app.css for consistent switch look
- Wrap flag and do-0x20-encode checkboxes in form-check form-switch
- Add aria-label and title for accessibility
- Matches existing KeyOnlyOrValueDisplay (use-stale-cache, add-mac, etc.)
2026-03-09 00:29:45 +10:00
alex 9e42a4e2df Effective config: no-0x20 precedence, write semantics, validators, strip-mac/strip-subnet
- ConfigSetCache: no-0x20-encode wins per dnsmasq docs; effective state and source attribution updated
- ReadonlyConfigCommandGenerator: use EffectiveConfigWriteSemantics for inverse-pair and key-only-or-value
- SpecialOptionValidators for UseStaleCache, AddMac, AddSubnet, Umbrella; wired via RegisterValidatedSingle
- OptionSemantics + EffectiveConfigWriteSemantics (GetBehavior/GetInversePairKeys); remove EffectiveConfigWriteBehaviorMap
- strip-mac / strip-subnet: full support in EffectiveDnsmasqConfig, sources, cache, sections, field builder, registry, tests
- Multi-value editor: toasts and block commit on validation error; 2s toast dedupe
- Do0x20 view label comment + Do0x20EncodeDisplayLabelTests
- DnsmasqOptionTooltips + extract-option-help.sh for strip-mac/strip-subnet; regenerated option-help HTML
2026-03-09 00:17:29 +10:00
alex d910688742 Add dnsmasq version awareness, readiness endpoint, and Docker healthcheck
- Options: VersionCommand, VersionTimeoutSeconds, MinimumVersion, EnforceMinimumVersion
- Version probe service and parser; startup enforcement when EnforceMinimumVersion
- Save flow: block save on probe failure (VersionProbeFailed) or version below minimum (UnsupportedVersion)
- Status API and Dnsmasq page: show version with tooltip, link to CHANGELOG
- Readiness: GET /healthz/ready (version + dnsmasq running when StatusCommand set)
- MapReadyHealthCheck extension; Dockerfile HEALTHCHECK + curl; compose healthcheck and service_healthy
- Docs: README and agents.md updated
2026-03-08 23:12:38 +10:00
alex a03e93bcd3 Effective config: do-0x20-encode revert fix, validation UI, custom displays, option help
- Fix do-0x20-encode: clearing pending when toggling back to original (Disabled/Default)
  - Do0x20EncodeDisplay: uncheck uses pending.OldValue when present
  - EffectiveConfigFieldDisplay: use pending.OldValue as _valueAtActivation when re-entering with pending
- Effective config: validation errors/warnings in toolbar and save modal, revert from modal
- Custom displays: Do0x20EncodeDisplay (tri-state), KeyOnlyOrValueDisplay
- ExplicitToggleState, config/apply support for do-0x20-encode, option help and tests
2026-03-08 12:52:46 +10:00
alex 6cf4c07695 Fix RestartTimeoutSeconds default; order-aware multi-value diff in save modal
- DnsmasqOptions: set RestartTimeoutSeconds default to 30 to match XML comment
  and original reload timeout (avoids halving timeout for restart/reload).
- Save modal: multi-value diff no longer uses set-only Except(); when lists
  have same items but different order (or duplicate count), show 'Order or
  duplicates changed' with before/after so reorders (e.g. server=) are visible.
2026-03-07 22:31:38 +10:00
alex f2e73107d1 Effective config: editable badge in view mode, refresh on continue/stop after save failure
- Multi-value items: EditableAsButton=true so 'editable' badge is clickable in view
  mode and activates the field like other editable badges.
- Save modal: 'Continue editing' after ValidateFailed/ReloadFailed now discards
  session, refreshes config from disk, then re-enters edit mode so remove/UI
  work correctly.
- 'Stop editing' after ValidateFailed/ReloadFailed now triggers config refresh
  so the invalid value on disk is shown instead of stale UI.
- SaveFlow: HandleCloseAndEndEdit invokes OnSaveCompleted when in failed state;
  OnContinueEditing uses EventCallback.Factory.Create to fix method-group
  conversion warning.
2026-03-07 22:13:31 +10:00
alex ff94be87b2 Effective config: multi descriptor refactor, validation UX, restore cleanup
- All multi fields use EffectiveMultiValueConfigFieldDescriptor; registry has
  RegisterMultiDescriptor/RegisterMultiComponent/RegisterMulti; every multi
  option registered; plain multi fallback removed (AddMultiDescriptor throws).
- Descriptor-driven validation: base descriptor ValidateValue + Validate();
  integer descriptor enforces Min/Max; ListenAddressMultiValidator for
  listen-address; single/flag commit uses Descriptor.Validate (no session
  field issues for inline edits).
- Multi list editor: local _displayItems so remove/add/edit update UI
  immediately; remove button PreventBlurOnMouseDown; inline validation
  message under input (ec-multi-list-input-group); root div not span.
- Save flow: on successful backup restore call OnCloseAndEndEdit then
  OnSaveCompleted so session and pending changes are cleared and config
  is reloaded.
- Server uses descriptor-only (no custom multi component); ServerMultiValueDisplay
  removed. RegisterValidatedSingle added for future single-field validation.
2026-03-07 21:11:12 +10:00
alex a9f8961c32 App logs: add Refresh and Download logs buttons; add api/logging/logs/download endpoint 2026-03-02 23:10:39 +10:00
alex baaba38e23 Effective config: conntrack as flag; avoid key-only directives for empty non-flag values
- Model conntrack as Flag (parser, config bool, cache, registry FlagValueDisplay)
- Apply: for non-flag options with empty/invalid value, remove existing line
  and never write key-only directive (was producing malformed option= without value)
2026-03-02 22:52:27 +10:00
alex 5f728900dc Effective config: auto-revert flag when toggled to original; fix stale cache after restore
- Flag options: when user toggles value back to original, clear pending change (same as revert button); trigger revert immediately on change so UI updates without blur
- UiContext: add RevertFieldAsync so section can revert a field and refresh; section wires it to Session.RevertChange + StateHasChanged
- RestoreAsync: call config cache Invalidate() after File.Copy so next read sees restored file (fixes validate-fail → restore showing pre-restore data)
2026-03-02 22:28:47 +10:00
alex 87179f0195 Effective config: empty multi-value UX, clickable changes count
- Multi-value: show editable badge next to (none); clicking activates edit and opens add row
- List editor: when empty and add allowed, do not show (none) row; auto-focus new-item input on open and when clicking add item
- Toolbar: make 'X change(s)' clickable to open save dialog (keyboard accessible)
2026-03-02 22:13:07 +10:00
237 changed files with 10303 additions and 352 deletions
+35 -4
View File
@@ -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
+41 -6
View File
@@ -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 -->
---
+19 -7
View File
@@ -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
View File
@@ -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:
+54
View File
@@ -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)
+89 -3
View File
@@ -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;
@@ -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;
@@ -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;
@@ -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>
@@ -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
{
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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>;
@@ -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>
@@ -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();
}
}
@@ -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;
}
@@ -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>
+5 -5
View File
@@ -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
}
}
}
+17 -33
View File
@@ -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);
}
+1 -1
View File
@@ -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,
@@ -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,
]),
];
@@ -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();
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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)
@@ -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;
}
}
@@ -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);
@@ -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;
}
}
@@ -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);
}
@@ -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.";
}
}
@@ -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;
}
@@ -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: &lt;ip-address&gt;[,&lt;ip-address&gt;...].
/// 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;
}
}
@@ -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: &lt;domain&gt;[,&lt;domain&gt;...].
/// 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;
}
}
@@ -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: &lt;domain&gt;,[&lt;interface&gt;|&lt;ip-address&gt;...].
/// 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);
}
}
@@ -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: &lt;serial&gt;[,&lt;hostmaster&gt;[,&lt;refresh&gt;[,&lt;retry&gt;[,&lt;expiry&gt;]]]].
/// 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;
}
}
@@ -0,0 +1,32 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>auth-zone</c> values: &lt;domain&gt;[,&lt;subnet&gt;[/prefix]...][,exclude:&lt;subnet&gt;...].
/// 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;
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -0,0 +1,31 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>caa-record</c> values: &lt;name&gt;,&lt;flags&gt;,&lt;tag&gt;,&lt;value&gt; (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;
}
}
@@ -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;
}
}
@@ -0,0 +1,36 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Semantic validation for <c>cname</c> values: &lt;cname&gt;[,&lt;cname&gt;,]&lt;target&gt;[,&lt;TTL&gt;].
/// 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;
}
}
@@ -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