41 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
alex 3b22e4fb7d Bump version to 0.0.6 for release 2026-03-02 21:12:57 +10:00
alex a3cc3968af Effective config: multi-value editor, config service fixes, save result improvements
- Add EffectiveConfigMultiValueListEditor with badge-style actions, draft merge,
  and display-mode editable badges; fix row callback capture and bounds checks
- EffectiveConfigFieldDisplay: compute multi-value state in OnParametersSet +
  HandleMultiValueChanged; BuildItemsWithSource preserves readonly/editable;
  dedupe list on commit; defensive copy for multi-value commit
- DnsmasqConfigService: generic MatchesOption via key parsing; FilterManagedOnly
  + ReadReadonlyMultiValuesByOptionAsync (single-pass readonly); EnsureManagedConfigInvariants
  + EnsureUniqueDirective; fix DHCP write cache notification (re-parse written output)
- EffectiveConfigSaveResult: ErrorCodes constants, IsValidateFailed; set Validated=false
  when validation fails; EffectiveConfigSaveFlow use IsValidateFailed; save service
  use ErrorCodes
- OptionStateBadge: OnEditableClickRequested for list-editor per-item edit
- Validation service and config save flow updates
2026-03-02 21:10:35 +10:00
alex 8ed66cdf48 Process runner, save/restore flow, status script, listen-address IPv6
- Process: Add IProcessHandle, ProcessOutputLine, StartAsync; RunAsync via StartAsync+WaitForExitAsync. Wait for stdout/stderr stream close before reading to fix empty restart output. ProcessTimeouts; ReloadService/StatusController/LogsService use config timeouts.
- Effective config: Rename reload→restart in save/restore result types and UI. Save modal: show restart output, danger alert, Stop editing/Continue editing (yellow), footer one-line; fallback exit code when no output; ensure pre visibility (CSS). Restore failed: combined output helper.
- Status script: When dnsmasq dead, run dnsmasq --test and show config error in status block (Docker harness; DNSMASQ_CONF).
- listen-address: Allow IPv6 (ValidateDraftValue accepts InterNetworkV6).
- Docker test: RestartCommand uses DnsmasqOptions timeout; DnsmasqOptions RestartTimeoutSeconds.
2026-03-01 22:23:17 +10:00
alex a776f445b6 Assembly-scanned services, options validator marker, UI tweaks, and config fixes
- Register ISettingsModalService, INotificationService, IUpdateCheckService via assembly scanning (marker interfaces)
- Add IApplicationOptionsValidator<TOptions> marker; scan only those for IValidateOptions registration
- Fix ApplicationLifecycleLoggingHostedService: log RestartCommand/ReloadCommand/StatusCommand (no ApplyCommand)
- NavMenu: spin animation for update-check button icon when checking; single icon with conditional nav-footer-spin class
- Add NotificationHost/NotificationToast components and NotificationService for toasts
- Effective config save flow, restore result, save service; various EffectiveConfig and DnsmasqOptions/validator updates
- Test and script updates; docker-compose.test.yml, appsettings, DnsmasqConfKeys/tooltips/sections
2026-03-01 19:21:09 +10:00
alex b0ce7afb58 Reorganize: Hubs→Realtime, Dnsmasq→Status/Logs/AppLogs, Extensions→DI/Hosting/Interop, EffectiveConfig→shared feature 2026-02-28 23:17:40 +10:00
alex 2cfbaf000c Reorganise Infrastructure/Services into feature namespaces
- Move registration marker interfaces to Registration/Abstractions
- Add feature folders with Abstractions subfolders: EffectiveConfig,
  Dnsmasq/Config, Dnsmasq/Hosts, Dnsmasq/Leases, Dnsmasq/Reload,
  Logs, Updates, UI/Settings, Common/Process
- Place interfaces in each feature's Abstractions namespace; align
  implementation namespaces to folder paths
- Update Program, extensions, controllers, hubs, helpers, components,
  and tests to use new namespaces
- Remove old flat Services/Abstractions and root-level service files
2026-02-28 22:17:20 +10:00
alex f8754d8d6e Shared StatusBadge, OK/Cancel/Revert, fixes, badge styling
- Add Components/Shared/StatusBadge + StatusBadgeKind; centralize badge
  styles and map Kind to Bootstrap classes. Refactor OptionStateBadge to
  compose StatusBadge; add OK/Cancel/Revert when active with
  OnConfirm/OnCancel/OnRevert callbacks and exit-action flag in
  EffectiveConfigFieldDisplay.
- Fix _valueAtActivation to use Descriptor.GetValue() (persisted value)
  so save modal shows correct before/after and no phantom pending on
  revert-to-original.
- OptionHelpController: add CancelCloseFromLabelEnter() and call when
  opening/switching help so pending 300ms close does not dismiss new help.
- Migrate Dnsmasq.razor and HostsFileSection to StatusBadge; save modal
  option chip uses NeutralPill. Centralize badge CSS in StatusBadge.
- Badge styling: font-size/weight/padding, hover lift+shadow, active
  press with visible release animation.
2026-02-28 21:34:12 +10:00
alex 71a8a61cb3 EffectiveConfig: toolbar, save modal, cancel/revert UX
- Toolbar: single expand/collapse toggle; change count after Save/Exit.
- Save modal: selective revert per change; modern list styling, empty state;
  Session + OnReverted; Save disabled when no changes.
- Cancel: prevent blur-before-click (mousedown preventDefault on cancel
  badge); _cancelRequested so cancel never commits.
- DeactivateFieldAsync in UiContext + Section; OptionStateBadge cancel
  clears draft and deactivates.
2026-02-28 20:06:10 +10:00
alex bf1f6a0d82 EffectiveConfig: migrate edit activation/commit to shared UiContext
- OptionStateBadge: add FieldKey param, call Ui.ActivateFieldAsync instead of OnActivateEdit
- EffectiveConfigOptionItem: add FieldKey, pass to badge; remove OnActivateEdit
- EffectiveConfigFieldDisplay: add FieldKey, commit only via Ui.CommitFieldAsync; remove ActiveFieldKey, OnActivateEdit, OnEditCommitted
- EffectiveConfigSectionPanel: pass FieldKey to FieldDisplay; remove OnActivateField, OnFieldEditCommitted
- EffectiveConfigPanels: remove OnActivateField, OnFieldEditCommitted params and relay
- EffectiveConfigSection: stop passing callback props to Panels (Session + UiContext drive behavior)
2026-02-28 19:25:25 +10:00
alex 91beb9d181 EffectiveConfig: single expand/collapse toolbar button
Replace separate Expand all / Collapse all buttons with one toggle button.
Icon and label switch based on AllSectionsExpanded (expand when any
section collapsed, collapse when all expanded).
2026-02-28 19:13:42 +10:00
alex 4e122ba439 EffectiveConfig: fix pending null display, OptionStateBadge, OptionHelpLabel
- Use pending != null ? pending.NewValue : Descriptor.GetValue() so clearing
  a value (port, log-queries, local-service) shows '(not set)' instead of
  reverting to the old value; fix effective value and commit logic in
  EffectiveConfigFieldDisplay.
- Rename ReadonlyBadge to OptionStateBadge; add editable/pending state and
  use it from EffectiveConfigOptionItem, EffectiveConfigFieldDisplay,
  EffectiveConfigMultiValueItem.
- OptionHelpLabel: default CssClass from HelpKey (EffectiveCssClass).
2026-02-28 19:04:38 +10:00
alex 6a7c0f62e1 Effective config refactor: session, UI context, cascade cleanup, save modal z-index
- Add IEffectiveConfigEditSession + EffectiveConfigEditSession
- Add EffectiveConfigUiContext for option help / readonly popover / commit intents
- Extract OptionHelpController, ReadonlyPopoverController, EffectiveConfigSaveFlow,
  EffectiveConfigToolbar, EffectiveConfigPanels, CopyableCodeLine, OptionHelpLabel,
  ReadonlyBadge, EffectiveConfigValueDisplayBase
- Remove redundant named CascadingValues (IsEditMode, ActiveFieldKey, ManagedFilePath)
- Remove unused ManagedFilePath cascading parameter from EffectiveConfigFieldDisplay
- Fix save modal stacking: add EffectiveConfigSaveModal.razor.css (dialog above backdrop)
- Pending changes displayed before save; hover-close fix for option help and readonly popover
2026-02-28 18:52:29 +10:00
alex f22b039e74 Effective config: readonly edit workflow and save staging
Add readonly badge/popover guidance, pending change tracking, and save confirmation flow for effective config editing. Update field renderers and config services to commit staged changes, and remove bundled Bootstrap dist assets from wwwroot.
2026-02-27 20:49:31 +10:00
alex 906e222ec1 Effective config: option kind map, parser behavior map, custom displays
- Add EffectiveConfigOptionKindMap (Single/Multi kind + LastWins/Flag/Multi parser
  behavior) and drive field builder + ConfigSetCache from it.
- Single AddDescriptor() in list extensions that dispatches via kind map to
  AddSingleDescriptor/AddMultiDescriptor.
- ConfigSetCache: ParseOptionValue/ParseOptionWithSource dispatch by option
  name; BuildEffectiveConfig and BuildEffectiveConfigSources use them with
  casts; special cases (NoHosts, AddnHosts, server/local, DhcpLeaseFilePath)
  remain explicit.
- Add IntegerValueDisplay, LogQueriesDisplay, LocalServiceDisplay; register
  integers with bounds/units in EffectiveConfigRenderFragmentRegistry.
- Add EffectiveConfigDescriptorFactory, EffectiveIntegerConfigFieldDescriptor,
  IEffectiveConfigFieldDescriptor for integer descriptor metadata.
2026-02-14 00:18:28 +10:00
alex ecd9e5cc7b UI polish: update check tooltip date, expand/collapse icons, dialog JS
- NavMenu: show short date in update-check tooltip when last check was not today
- Settings modal: expand/collapse all sections with bi-arrows-expand/collapse icons
- Effective config: add Expand all button (bi-arrows-expand), same icon style as toolbar
- Rename settings-modal.js to dialog.js; add config-option-help.js, JS extension helpers
- Config option help modal and client settings/dialog wiring updates
2026-02-13 00:59:33 +10:00
alex e7c2560d07 Release v0.0.5: option help modal, update check, effective config sections
- Config option help: modal with dnsmasq docs, label click/mouseleave
- Update check: background + manual check, nav footer tooltip with last check time
- Effective config: sectioned views (DNS, DHCP, etc.), section toggle
- Version bump to 0.0.5
2026-02-13 00:11:02 +10:00
alex 439192058e Add 16 dnsmasq options, fix srv→srv-host, expand tests
- DnsmasqConfKeys: add ReadEthers, DhcpOptionForce, DhcpMac, DhcpNameMatch,
  DhcpIgnoreNames, Ipset, Nftset, MxTarget, TftpNoFail, TftpNoBlocksize,
  DhcpRapidCommit, Localmx, Selfmx, EnableRa, LogDhcp, DhcpScript.
  Fix SRV option key: srv → srv-host (dnsmasq uses srv-host).
- Effective config: new multi-value, flags, single-value fields and sources;
  ConfigSetCache, EffectiveConfigFieldBuilder, Registry, Tooltips updated.
- Tests: split DnsmasqConfIncludeParserTests into focused classes
  (CoreBehaviour, Include, HostsAndLease, SingleValue, Records, Interfaces,
  ServerAndResolv, DhcpTftpDnssec, Flags, OfficialExample, NewOptions).
  Rename *_ParsesRealExample to *_WhenSet_ReturnsValue(s).
  Add DnsmasqConfIncludeParserOfficialExampleTests (dnsmasq.official.conf.example).
  Add DnsmasqConfIncludeParserNewOptionsTests (positive tests for 16 options).
  EffectiveDnsmasqConfigTests: extend CreateConfig for new constructor params.
- Testdata: add dnsmasq.official.conf.example; update README.
2026-02-12 02:14:33 +10:00
alex 7965fc669b Implement remaining dnsmasq config options and add parser tests
- Add DnsmasqConfKeys for DHCP advanced, DHCPv6/RA, TFTP/PXE, DNSSEC, Other
- Add EffectiveDnsmasqConfig + EffectiveConfigSources for new options
- Wire ConfigSetCache BuildEffectiveConfig/Sources/CreateDefault
- Add EffectiveConfigFieldBuilder sections (TFTP/PXE, DNSSEC), tooltips, registry
- Update EffectiveDnsmasqConfigTests CreateConfig with all new params
- Add one parser test per conf option with documented example (200 tests)
2026-02-11 12:29:15 +10:00
alex dfee96650b Add Phase 2/3 effective config options and parser tests
- DNS/resolver: dns-loop-detect, stop-dns-rebind, rebind-localhost-ok,
  clear-on-reload, filterwin2k, filter-A, filter-AAAA, localise-queries;
  auth-ttl, edns-packet-max, query-port, port-limit, min-port, max-port;
  rebind-domain-ok, bogus-nxdomain, ignore-address, alias, filter-rr, cache-rr
- Process: log-debug, local-service, auth-server (multi), no-dhcp-interface,
  no-dhcpv4-interface, no-dhcpv6-interface
- Cache: neg-ttl, max-ttl, max-cache-ttl, min-cache-ttl
- DHCP: dhcp-lease-max, dhcp-ttl, leasefile-ro
- Wire through ConfigSetCache, EffectiveDnsmasqConfig, EffectiveConfigSources,
  EffectiveConfigFieldBuilder, DnsmasqOptionTooltips, registry
- Add parser tests for new options
2026-02-11 11:52:14 +10:00
alex cefc488712 Refactor client settings fields, fix logs overwrite, improve settings modal
- Replace FieldBounds with generic ClientSettingsField<T> abstraction
- Add IClientSettingsField, ClientSettingsMinMaxField, ClientSettingsBoolField
- Put abstractions in Models/Client/Abstractions namespace
- Fields hold values; CoerceValue for clamping on set
- ClientSettingsService hydrates fields on load; ToDto for save
- Settings modal binds directly to field values, collapsible only for All context
- Add LogsPreView with ShouldRender freeze to prevent Blazor overwriting JS-owned logs DOM
- Add left margin to static group heading in single-section mode
2026-02-11 08:28:44 +10:00
alex 5ac2006b80 UI, logging, and settings improvements
- Add app version to sidebar footer with link to GitHub release
- CSS cache busting: append version query string to static assets
- Remove LogEvents; improve startup logging with version, env, paths, uptime
- Settings modal: collapsible groups with search-aware expand/collapse
- Release workflow and publish script: pass version from tag for builds
2026-02-11 07:26:35 +10:00
543 changed files with 23808 additions and 62248 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
docker run --rm \
-v "${{ github.workspace }}:/src" -w /src \
mcr.microsoft.com/dotnet/sdk:10.0 \
./scripts/publish-self-contained.sh ${{ matrix.rid }}
./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
- name: Setup .NET
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
@@ -40,7 +40,7 @@ jobs:
- name: Publish for ${{ matrix.rid }} (musl on host)
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }}
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
- name: Zip publish output
run: |
+38 -8
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
@@ -19,15 +27,37 @@ RUN dotnet build "DnsmasqWebUI.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS app
FROM base AS final
ARG DNSMASQ_VERSION=latest
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
COPY --from=publish /app/publish .
FROM app AS final
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/entrypoint.sh .
COPY scripts/dnsmasq-status.sh .
# Install dnsmasq either from the distro package or by building an upstream release.
RUN if [ "$DNSMASQ_VERSION" = "distro" ]; then \
apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
&& rm -rf /var/lib/apt/lists/*; \
else \
apt-get update && apt-get install -y --no-install-recommends procps curl build-essential xz-utils ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& if [ "$DNSMASQ_VERSION" = "latest" ]; then \
DNSMASQ_VERSION="$(curl -fsSL https://thekelleys.org.uk/dnsmasq/ | grep -o 'dnsmasq-[0-9][0-9.]*\.tar\.xz' | sed 's/^dnsmasq-//; s/\.tar\.xz$//' | sort -V | tail -n1)"; \
fi \
&& test -n "$DNSMASQ_VERSION" \
&& echo "Building dnsmasq $DNSMASQ_VERSION from upstream source" \
&& curl -fsSL "https://thekelleys.org.uk/dnsmasq/dnsmasq-${DNSMASQ_VERSION}.tar.xz" -o /tmp/dnsmasq.tar.xz \
&& tar -C /tmp -xJf /tmp/dnsmasq.tar.xz \
&& make -C /tmp/dnsmasq-${DNSMASQ_VERSION} -j"$(nproc)" \
&& make -C /tmp/dnsmasq-${DNSMASQ_VERSION} install PREFIX=/usr \
&& rm -rf /tmp/dnsmasq-${DNSMASQ_VERSION} /tmp/dnsmasq.tar.xz \
&& apt-get purge -y build-essential curl xz-utils \
&& apt-get autoremove -y --purge \
&& rm -rf /var/lib/apt/lists/*; \
fi
COPY scripts/entrypoint.sh scripts/dnsmasq-status.sh ./
RUN chmod +x entrypoint.sh dnsmasq-status.sh
# curl for HEALTHCHECK (aspnet image has no curl/wget)
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# .NET publish last - only this layer invalidates when code changes
COPY --from=publish /app/publish .
ENTRYPOINT ["./entrypoint.sh"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f -s http://localhost:8080/healthz/ready || exit 1
+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.
+22 -7
View File
@@ -1,7 +1,8 @@
# Run the app with real dnsmasq and several DHCP clients so hosts/leases are real.
# Some clients also make periodic DNS requests so the DNS cache and logs show activity.
# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
# Prepare mount: ./scripts/prepare-test-mount.sh [--minimal-conf | --prepare-only | ...]
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
# Set TEST_DNSMASQ_CONF to override main config path (default: /data/dnsmasq-test.conf; use /data/dnsmasq-test-minimal.conf for minimal).
#
# No systemd in container (dnsmasq runs as a process from entrypoint, like most dnsmasq images).
# StatusShowCommand/LogsCommand use ps so the Service status / Recent logs sections show something
@@ -13,14 +14,24 @@ services:
app:
build:
context: .
args:
DNSMASQ_VERSION: ${DNSMASQ_VERSION:-latest}
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "-f", "-s", "http://localhost:8080/healthz/ready"]
interval: 30s
timeout: 5s
start_period: 5s
retries: 3
cap_add:
- NET_ADMIN
environment:
DNSMASQ_CONF: /data/dnsmasq-test.conf
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
DNSMASQ_CONF: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
Dnsmasq__MainConfigPath: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf
# No systemd in container: restart by killing and re-running dnsmasq (same as entrypoint.sh). Config path must match DNSMASQ_CONF/MainConfigPath.
Dnsmasq__RestartCommand: "pkill -x dnsmasq || true; sleep 1; dnsmasq --test --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} && dnsmasq --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}"
Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
@@ -41,7 +52,8 @@ services:
networks:
testnet: {}
depends_on:
- app
app:
condition: service_healthy
# DHCP + periodic DNS lookups (every 20s) so DNS cache/logs show activity.
dhcp-client-dns-a:
@@ -60,7 +72,8 @@ services:
networks:
testnet: {}
depends_on:
- app
app:
condition: service_healthy
# DHCP + periodic DNS lookups (every 45s); different interval for variety.
dhcp-client-dns-b:
@@ -79,7 +92,8 @@ services:
networks:
testnet: {}
depends_on:
- app
app:
condition: service_healthy
# Third DHCP-only client so the leases table has more entries.
dhcp-client-2:
@@ -88,7 +102,8 @@ services:
networks:
testnet: {}
depends_on:
- app
app:
condition: service_healthy
networks:
testnet:
+10
View File
@@ -1,12 +1,22 @@
#!/bin/sh
# Simulates "systemctl status dnsmasq" output when run in a container without systemd.
# Uses ps/pgrep. Memory is RSS. Uptime line omitted; Active line has break after semicolon with continuation indented.
# When dnsmasq is not running, runs dnsmasq --test to show config/startup errors (use DNSMASQ_CONF for config path).
pid=$(pgrep -x dnsmasq)
if [ -z "$pid" ]; then
echo "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server"
echo " Loaded: (container, no systemd)"
echo " Active: inactive (dead)"
conf="${DNSMASQ_CONF:-/etc/dnsmasq.conf}"
if [ -f "$conf" ]; then
err=$(dnsmasq --test --conf-file="$conf" 2>&1)
if [ -n "$err" ]; then
echo ""
echo " Config test (dnsmasq --test):"
echo "$err" | sed 's/^/ /'
fi
fi
echo ""
exit 0
fi
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env bash
# Extract per-option HTML from the dnsmasq man page for use as field help in the web UI.
#
# Runs a temporary Python (BeautifulSoup) container to fetch the page, parse the OPTIONS
# <DL>/<DT>/<DD> blocks, and write one .html file per option. The container is removed
# when done. If the page URL or structure changes, pass a new --url.
#
# Usage:
# ./scripts/extract-option-help.sh [--url URL] [--output-dir DIR]
#
# Requires: Docker (python:3-slim image will be pulled if missing)
set -e
DEFAULT_URL="https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html"
DEFAULT_OUTPUT_DIR="src/DnsmasqWebUI/wwwroot/option-help"
URL="$DEFAULT_URL"
OUTPUT_DIR="$DEFAULT_OUTPUT_DIR"
while [[ $# -gt 0 ]]; do
case "$1" in
--url) URL="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
[[ "$OUTPUT_DIR" != /* ]] && OUTPUT_DIR="$PWD/$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
SCRIPT_FILE="$(mktemp)"
trap 'rm -f "$SCRIPT_FILE"' EXIT
# Embed Python script (uses requests + BeautifulSoup; runs inside container)
cat << 'PYTHON_SCRIPT' > "$SCRIPT_FILE"
import os
import re
import sys
try:
import requests
from bs4 import BeautifulSoup
except ImportError:
sys.stderr.write("Installing requests and beautifulsoup4...\n")
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "requests", "beautifulsoup4"])
import requests
from bs4 import BeautifulSoup
OUT_DIR = "/out"
URL = os.environ.get("URL", "https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html")
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections.
OPTION_KEYS = [
"no-hosts",
"addn-hosts",
"hostsdir",
"read-ethers",
"server",
"local",
"rev-server",
"address",
"resolv-file",
"no-resolv",
"domain-needed",
"port",
"log-queries",
"strict-order",
"all-servers",
"auth-ttl",
"edns-packet-max",
"query-port",
"port-limit",
"min-port",
"max-port",
"dns-loop-detect",
"stop-dns-rebind",
"rebind-localhost-ok",
"clear-on-reload",
"expand-hosts",
"bogus-priv",
"rebind-domain-ok",
"bogus-nxdomain",
"ignore-address",
"alias",
"filter-rr",
"filterwin2k",
"filter-A",
"filter-AAAA",
"localise-queries",
"fast-dns-retry",
"ipset",
"nftset",
"domain",
"cname",
"mx-host",
"srv-host",
"ptr-record",
"txt-record",
"naptr-record",
"host-record",
"dynamic-host",
"interface-name",
"mx-target",
"localmx",
"selfmx",
"dhcp-authoritative",
"dhcp-rapid-commit",
"leasefile-ro",
"dhcp-script",
"dhcp-leasefile",
"dhcp-lease-max",
"dhcp-ttl",
"dhcp-range",
"dhcp-host",
"dhcp-option",
"dhcp-option-force",
"dhcp-match",
"dhcp-mac",
"dhcp-name-match",
"dhcp-ignore-names",
"dhcp-hostsfile",
"dhcp-optsfile",
"dhcp-hostsdir",
"dhcp-optsdir",
"dhcp-boot",
"dhcp-ignore",
"dhcp-vendorclass",
"dhcp-userclass",
"ra-param",
"slaac",
"enable-tftp",
"tftp-secure",
"tftp-no-fail",
"tftp-no-blocksize",
"tftp-root",
"pxe-prompt",
"pxe-service",
"dnssec",
"dnssec-check-unsigned",
"proxy-dnssec",
"trust-anchor",
"cache-rr",
"cache-size",
"local-ttl",
"no-negcache",
"neg-ttl",
"max-ttl",
"max-cache-ttl",
"min-cache-ttl",
"no-poll",
"bind-interfaces",
"bind-dynamic",
"log-debug",
"log-async",
"local-service",
"interface",
"listen-address",
"except-interface",
"auth-server",
"no-dhcp-interface",
"no-dhcpv4-interface",
"no-dhcpv6-interface",
"pid-file",
"user",
"group",
"log-facility",
"enable-dbus",
"enable-ubus",
"enable-ra",
"log-dhcp",
"keep-in-foreground",
"no-daemon",
"conntrack",
"connmark-allowlist-enable",
"no-round-robin",
"dns-forward-max",
"connmark-allowlist",
"do-0x20-encode",
"caa-record",
"dns-rr",
"synth-domain",
"auth-zone",
"auth-soa",
"auth-sec-servers",
"auth-peer",
"leasequery",
"dhcp-generate-names",
"dhcp-broadcast",
"dhcp-sequential-ip",
"dhcp-ignore-clid",
"bootp-dynamic",
"no-ping",
"script-arp",
"script-on-renewal",
"dhcp-no-override",
"dhcp-alternate-port",
"dhcp-duid",
"dhcp-luascript",
"dhcp-scriptuser",
"dhcp-relay",
"dhcp-circuitid",
"dhcp-remoteid",
"dhcp-subscrid",
"dhcp-proxy",
"tag-if",
"bridge-interface",
"shared-network",
"dhcp-pxe-vendor",
"dhcp-option-pxe",
"dnssec-no-timecheck",
"dnssec-debug",
"add-cpe-id",
"dnssec-timestamp",
"dnssec-limits",
"dumpfile",
"dumpmask",
"use-stale-cache",
"add-mac",
"strip-mac",
"add-subnet",
"strip-subnet",
"umbrella",
"quiet-dhcp",
"quiet-dhcp6",
"quiet-ra",
"quiet-tftp",
]
long_opt_re = re.compile(r"--([a-z][a-z0-9-]*)(?:=[^,\s]*)?", re.IGNORECASE)
def option_names_from_dt(dt):
return [m.group(1).lower() for m in long_opt_re.finditer(dt.get_text())]
def main():
r = requests.get(URL, timeout=30)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
h2 = None
for el in soup.find_all("h2"):
if el.get_text(strip=True).upper() == "OPTIONS":
h2 = el
break
if not h2:
sys.stderr.write("Could not find OPTIONS H2\n")
sys.exit(1)
dl = h2.find_next("dl")
if not dl:
sys.stderr.write("Could not find DL after OPTIONS\n")
sys.exit(1)
# Build option -> (dt_html, dd_html) from raw HTML; split by <dt> so we get one block per option.
raw_dl = str(dl)
option_to_block = {}
blocks = re.split(r"<dt\b", raw_dl, flags=re.IGNORECASE)
for block in blocks[1:]:
p_dt = block.find("</dt>") if "</dt>" in block.lower() else len(block) + 1
m_dd = re.search(r"<dd\b", block, re.IGNORECASE)
p_dd = m_dd.start() if m_dd else len(block) + 1
end_pos = min(p_dt, p_dd)
if end_pos > len(block):
continue
dt_part = block[:end_pos].strip()
rest = block[end_pos:]
if rest.lower().startswith("</dt>"):
rest = rest[6:].lstrip()
if ">" in dt_part:
dt_part = dt_part.split(">", 1)[1]
dt_html = "<dt>" + dt_part + "</dt>"
dd_match = re.search(r"<dd\b[^>]*>(.*)", rest, re.DOTALL | re.IGNORECASE)
dd_body = dd_match.group(1) if dd_match else ""
dd_body = re.split(r"</dd>|<dt\s|<dt>", dd_body, flags=re.IGNORECASE)[0].strip()
dd_html = "<dd>" + dd_body + "</dd>" if dd_body else "<dd></dd>"
soup_dt = BeautifulSoup(dt_html, "html.parser").find("dt")
if not soup_dt:
continue
for n in option_names_from_dt(soup_dt):
if n not in option_to_block:
option_to_block[n] = (dt_html, dd_html)
os.makedirs(OUT_DIR, exist_ok=True)
written = 0
for key in OPTION_KEYS:
norm = key.lower()
if norm not in option_to_block:
continue
dt_html, dd_html = option_to_block[norm]
frag = "\n".join([dt_html, dd_html])
safe = re.sub(r"[^a-z0-9-]", "", norm) or key
path = os.path.join(OUT_DIR, f"{safe}.html")
with open(path, "w", encoding="utf-8") as f:
f.write(frag)
written += 1
print(f"Wrote {written} files to {OUT_DIR}")
if __name__ == "__main__":
main()
PYTHON_SCRIPT
echo "Fetching man page and extracting option help (Docker python:3-slim) ..."
docker run --rm \
-v "$OUTPUT_DIR:/out" \
-v "$SCRIPT_FILE:/script.py:ro" \
-e "URL=$URL" \
-e "PIP_ROOT_USER_ACTION=ignore" \
python:3-slim \
sh -c "pip install -q requests beautifulsoup4 && python /script.py"
echo "Done. Files in $OUTPUT_DIR"
+107 -2
View File
@@ -15,12 +15,15 @@ COMPOSE_FILE="docker-compose.test.yml"
SOURCE_DIR=""
MOUNT_DIR=""
DNSMASQ_VERSION=""
PREPARE_ONLY=false
BUILD=false
NO_CACHE_BUILD=false
RECREATE=false
CLEAR=false
STOP=false
TIDY=false
MINIMAL_CONF=false
usage() {
echo "Usage: $0 [OPTIONS] [--]"
@@ -37,17 +40,25 @@ usage() {
echo " --source DIR Source to copy from (default: testdata)"
echo " --mount DIR Target mount directory (default: testdata-mount)"
echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount."
echo " --dnsmasq-version V Dnsmasq version for the harness image:"
echo " latest (default), distro, or an exact upstream version like 2.91."
echo " Non-default values require --build or --no-cache-build; script fails with --no-build."
echo ""
echo "Mount behaviour:"
echo " (default) Preserve mount dir; sync source over existing contents."
echo " --clear Clear mount dir completely before sync. Use for a clean run."
echo ""
echo "Compose behaviour:"
echo " --minimal-conf Use minimal dnsmasq config (dnsmasq-test-minimal.conf) so effective"
echo " config has few readonly options; good for testing reload/restart failure flows."
echo " --prepare-only Only prepare the mount; do not run docker compose."
echo " Use to inspect or edit the mount before starting containers."
echo " --build Pass --build to docker compose (rebuild images before starting)."
echo " Use after changing the app or Dockerfile. Default: use existing images."
echo " --no-cache-build Rebuild images with --pull --no-cache before starting."
echo " Use to force a fresh image build and refresh 'latest' dnsmasq."
echo " --no-build Do not rebuild (default). Use existing images for a quick restart."
echo " With --no-build, 'latest' is not resolved (no network)."
echo " --recreate Pass --force-recreate to docker compose (recreate containers)."
echo " Use to ensure fresh container state and mounts."
echo ""
@@ -69,6 +80,9 @@ usage() {
echo " $0 --recreate"
echo " Sync, then up -d --force-recreate (fresh containers)."
echo ""
echo " $0 --no-cache-build"
echo " Force a fresh image build (no Docker build cache), then start containers."
echo ""
echo " $0 --clear"
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
echo ""
@@ -85,6 +99,38 @@ usage() {
echo ""
echo " $0 --tidy"
echo " Stop harness and clear testdata-mount for a clean next run."
echo ""
echo " $0 --minimal-conf"
echo " Use dnsmasq-test-minimal.conf (single file, few options) and start."
echo ""
echo " $0 --dnsmasq-version 2.91 --build"
echo " Rebuild the harness image with dnsmasq 2.91."
}
fetch_url() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1"
return $?
fi
if command -v wget >/dev/null 2>&1; then
wget -qO- "$1"
return $?
fi
echo "Error: need curl or wget to resolve the latest upstream dnsmasq version." >&2
return 1
}
resolve_dnsmasq_version() {
if [ "$DNSMASQ_VERSION" != "latest" ]; then
return 0
fi
RESOLVED_VERSION="$(fetch_url "https://thekelleys.org.uk/dnsmasq/" | sed -n 's/.*LATEST_IS_\([0-9][0-9.]*\).*/\1/p' | head -n1)"
if [ -z "$RESOLVED_VERSION" ]; then
echo "Error: could not resolve the latest upstream dnsmasq version." >&2
exit 1
fi
DNSMASQ_VERSION="$RESOLVED_VERSION"
}
while [ $# -gt 0 ]; do
@@ -105,6 +151,12 @@ while [ $# -gt 0 ]; do
MOUNT_DIR="$1"
shift
;;
--dnsmasq-version)
shift
[ $# -gt 0 ] || { echo "Error: --dnsmasq-version requires VERSION" >&2; exit 1; }
DNSMASQ_VERSION="$1"
shift
;;
--clear)
CLEAR=true
shift
@@ -117,14 +169,24 @@ while [ $# -gt 0 ]; do
BUILD=true
shift
;;
--no-cache-build)
NO_CACHE_BUILD=true
BUILD=true
shift
;;
--no-build)
BUILD=false
NO_CACHE_BUILD=false
shift
;;
--recreate)
RECREATE=true
shift
;;
--minimal-conf)
MINIMAL_CONF=true
shift
;;
--stop)
STOP=true
shift
@@ -160,6 +222,7 @@ cd "$REPO_ROOT"
: "${SOURCE_DIR:=testdata}"
: "${MOUNT_DIR:=testdata-mount}"
: "${DNSMASQ_VERSION:=latest}"
# Stop and/or tidy: no prepare, no start
if [ "$STOP" = true ] || [ "$TIDY" = true ]; then
@@ -182,6 +245,17 @@ if [ ! -d "$SOURCE_DIR" ]; then
exit 1
fi
# Resolve "latest" only when we will build; avoid network when doing a no-build run.
WILL_BUILD=false
[ "$BUILD" = true ] || [ "$NO_CACHE_BUILD" = true ] && WILL_BUILD=true
if [ "$WILL_BUILD" = false ] && [ "$DNSMASQ_VERSION" != "latest" ] && [ "$DNSMASQ_VERSION" != "distro" ]; then
echo "Error: --dnsmasq-version $DNSMASQ_VERSION has no effect without --build or --no-cache-build. Add --build or omit --dnsmasq-version for a no-build run." >&2
exit 1
fi
if [ "$WILL_BUILD" = true ]; then
resolve_dnsmasq_version
fi
# Take the harness down so all containers (including one-shot DHCP clients) are recreated on up.
echo "Stopping test harness: docker compose -f $COMPOSE_FILE down"
docker compose -f "$COMPOSE_FILE" down
@@ -210,7 +284,16 @@ if [ "$PREPARE_ONLY" = true ]; then
/*) MOUNT_EXPORT="$MOUNT_DIR" ;;
*) MOUNT_EXPORT="./$MOUNT_DIR" ;;
esac
echo "To start the harness: TESTDATA_MOUNT=$MOUNT_EXPORT docker compose -f $COMPOSE_FILE up -d [--build]"
START_PREFIX="TESTDATA_MOUNT=$MOUNT_EXPORT DNSMASQ_VERSION=$DNSMASQ_VERSION"
if [ "$MINIMAL_CONF" = true ]; then
START_PREFIX="$START_PREFIX TEST_DNSMASQ_CONF=/data/dnsmasq-test-minimal.conf"
fi
if [ "$NO_CACHE_BUILD" = true ]; then
START_CMD="$START_PREFIX docker compose -f $COMPOSE_FILE build --pull --no-cache && $START_PREFIX docker compose -f $COMPOSE_FILE up -d"
else
START_CMD="$START_PREFIX docker compose -f $COMPOSE_FILE up -d [--build]"
fi
echo "To start the harness: $START_CMD"
exit 0
fi
@@ -220,8 +303,30 @@ case "$MOUNT_DIR" in
*) export TESTDATA_MOUNT="./$MOUNT_DIR" ;;
esac
export DNSMASQ_VERSION
case "$DNSMASQ_VERSION" in
distro)
echo "Using distro-packaged dnsmasq for the harness image."
;;
*)
echo "Using dnsmasq $DNSMASQ_VERSION for the harness image."
;;
esac
if [ "$MINIMAL_CONF" = true ]; then
export TEST_DNSMASQ_CONF="/data/dnsmasq-test-minimal.conf"
echo "Using minimal config: $TEST_DNSMASQ_CONF"
fi
if [ "$NO_CACHE_BUILD" = true ]; then
BUILD_CMD="docker compose -f $COMPOSE_FILE build --pull --no-cache"
echo "Running: $BUILD_CMD"
$BUILD_CMD
fi
COMPOSE_CMD="docker compose -f $COMPOSE_FILE up -d"
if [ "$BUILD" = true ]; then
if [ "$BUILD" = true ] && [ "$NO_CACHE_BUILD" != true ]; then
COMPOSE_CMD="$COMPOSE_CMD --build"
fi
if [ "$RECREATE" = true ]; then
+21 -4
View File
@@ -59,14 +59,17 @@ default_rid() {
esac
}
# Optional version override (e.g. v0.0.4 from CI tag). When set, passed to dotnet publish for assembly metadata.
VERSION_OVERRIDE=""
while [ $# -gt 0 ]; do
case "$1" in
-h|-?|--help)
echo "Usage: $0 [OPTIONS] [RID]"
echo "Usage: $0 [OPTIONS] [RID] [VERSION]"
echo ""
echo "Build a self-contained folder publish for Linux. If RID is omitted, the script"
echo "auto-detects from the current OS and architecture (recommended on Ubuntu and Alpine)."
echo "Pass options first, then RID if desired (e.g. $0 --trim linux-x64)."
echo "Pass options first, then RID, then optional VERSION (e.g. v0.0.4 from a release tag)."
echo ""
echo "Options:"
echo " --trim Enable trimming (smaller output; can cause 404/routing issues with Blazor)"
@@ -112,6 +115,10 @@ while [ $# -gt 0 ]; do
*)
RID="$1"
shift
if [ $# -gt 0 ] && [ -n "$1" ] && [ "${1#-}" = "$1" ]; then
VERSION_OVERRIDE="$1"
shift
fi
break
;;
esac
@@ -148,19 +155,29 @@ if [ "$CLEAN" = true ]; then
dotnet clean "$PROJECT" -c Release -nologo -v q
fi
VERSION_ARGS=""
if [ -n "$VERSION_OVERRIDE" ]; then
# Strip leading 'v' (e.g. v0.0.4 -> 0.0.4) for assembly metadata
VER="${VERSION_OVERRIDE#v}"
VERSION_ARGS="-p:Version=$VER -p:InformationalVersion=$VER"
echo "Using version: $VER (from $VERSION_OVERRIDE)"
fi
if [ "$TRIM" = true ]; then
echo "Publishing self-contained (trimmed, folder) for $RID..."
dotnet publish "$PROJECT" \
-c Release \
-r "$RID" \
--self-contained true \
-p:PublishTrimmed=true
-p:PublishTrimmed=true \
$VERSION_ARGS
else
echo "Publishing self-contained (no trim, folder) for $RID..."
dotnet publish "$PROJECT" \
-c Release \
-r "$RID" \
--self-contained true
--self-contained true \
$VERSION_ARGS
fi
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish"
@@ -0,0 +1,137 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests effective-config resolution in ConfigSetCache (BuildEffectiveConfig / BuildEffectiveConfigSources).
/// Verifies no-0x20-encode precedence and source attribution per dnsmasq docs.
/// </summary>
public class ConfigSetCacheEffectiveConfigTests
{
private static (string Dir, string MainPath, string ManagedPath, ConfigSetCache Cache) CreateCacheWithManaged(string managedContent)
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cache-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, managedContent);
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
return (dir, mainPath, managedPath, cache);
}
[Fact]
public async Task Do0x20_BothPresent_No0x20Wins()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("do-0x20-encode\nno-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_OnlyDo_Enabled()
{
var (dir, _, _, cache) = CreateCacheWithManaged("do-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Enabled, snapshot.Config.Do0x20EncodeState);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_OnlyNo_Disabled()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("no-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_Neither_Default()
{
var (dir, _, _, cache) = CreateCacheWithManaged("port=53\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Default, snapshot.Config.Do0x20EncodeState);
Assert.Null(snapshot.Sources.Do0x20Encode);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_SourceAttribution_WhenBothPresent_IsNo0x20Line()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("no-0x20-encode\ndo-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task StripFlags_ParseState_And_Source()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("strip-mac\nstrip-subnet\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.StripMac);
Assert.True(snapshot.Config.StripSubnet);
Assert.NotNull(snapshot.Sources.StripMac);
Assert.NotNull(snapshot.Sources.StripSubnet);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.StripMac!.FilePath, StringComparer.OrdinalIgnoreCase);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.StripSubnet!.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,72 @@
using DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for parsing "Compile time options: ..." from dnsmasq --version output.
/// </summary>
public class DnsmasqCompileOptionsParserTests
{
[Fact]
public void Parse_WhenNoCompileLine_ReturnsAllCapabilitiesFalse()
{
var result = DnsmasqCompileOptionsParser.Parse("Dnsmasq version 2.89\nCopyright ...", null);
Assert.False(result.Dhcp);
Assert.False(result.Tftp);
Assert.False(result.Dnssec);
Assert.False(result.Dbus);
Assert.NotNull(result.RawTokens);
Assert.Empty(result.RawTokens);
}
[Fact]
public void Parse_WhenCompileLineHasDnssec_SetsDnssecTrue()
{
var stdout = "Dnsmasq version 2.89\nCompile time options: IPv6 GNU-getopt no-DBus no-Idn DHCP DHCPv6 TFTP DNSSEC auth";
var result = DnsmasqCompileOptionsParser.Parse(stdout, null);
Assert.True(result.Dnssec);
Assert.True(result.Dhcp);
Assert.True(result.Tftp);
Assert.False(result.Dbus);
Assert.Contains("DNSSEC", result.RawTokens, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void Parse_WhenCompileLineHasDhcpOnly_SetsDhcpTrueOthersFalse()
{
var result = DnsmasqCompileOptionsParser.Parse(null, "Compile time options: DHCP no-DBus no-DNSSEC");
Assert.True(result.Dhcp);
Assert.False(result.Tftp);
Assert.False(result.Dnssec);
Assert.False(result.Dbus);
}
[Fact]
public void Parse_WhenCompileLineHasDhcpv6_SetsDhcpTrue()
{
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: DHCPv6", null);
Assert.True(result.Dhcp);
}
[Fact]
public void Parse_WhenCompileLineHasTftp_SetsTftpTrue()
{
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: TFTP", null);
Assert.True(result.Tftp);
}
[Fact]
public void Parse_WhenCompileLineHasDbus_SetsDbusTrue()
{
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: DBus", null);
Assert.True(result.Dbus);
}
[Fact]
public void Parse_CombinesStdoutAndStderr()
{
var result = DnsmasqCompileOptionsParser.Parse("Version line\n", "Compile time options: DNSSEC");
Assert.True(result.Dnssec);
}
}
@@ -70,4 +70,24 @@ public class DnsmasqConfDirectiveParserTests
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
Assert.Equal(value, kv.Value.value);
}
[Fact]
public void TryParseKeyValue_ValueContainsEquals_ParsesFirstEqualsAsKeyValueSeparator()
{
var line = "server=/local/1.2.3.4";
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
Assert.NotNull(kv);
Assert.Equal("server", kv!.Value.key);
Assert.Equal("/local/1.2.3.4", kv.Value.value);
}
[Fact]
public void TryParseKeyValue_ErrorOverload_InvalidLine_ReturnsFalseAndSetsError()
{
// KeyValueLine requires at least one key character before '='; "=no-key" has no key and fails.
var ok = DnsmasqConfDirectiveParser.TryParseKeyValue("=no-key", out var kv, out var error, out var errorPosition);
Assert.False(ok);
Assert.NotNull(error);
Assert.True(errorPosition.Absolute >= 0);
}
}
@@ -0,0 +1,329 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Parser semantics: flags (option present/absent/with value), last-wins, case-sensitive, path order.</summary>
public class DnsmasqConfIncludeParserCoreBehaviourTests
{
[Fact]
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
Assert.True(result);
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "port=53\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
Assert.False(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var firstVal = "100";
var secondVal = "200";
try
{
File.WriteAllText(f1, $"cache-size={firstVal}\n");
File.WriteAllText(f2, $"cache-size={secondVal}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
Assert.Equal(secondVal, value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var logPath = "/data/dnsmasq.log";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"log-facility={logPath}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
Assert.Equal(logPath, value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogFacility_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var firstLogPath = "/var/log/dnsmasq.log";
var secondLogPath = "/data/dnsmasq.log";
try
{
File.WriteAllText(f1, $"log-facility={firstLogPath}\n");
File.WriteAllText(f2, $"log-facility={secondLogPath}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
Assert.Equal(secondLogPath, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ResolvePath_Relative_ResolvesAgainstDir()
{
var dir = Path.GetTempPath();
var subDirName = "sub";
var fileName = "pid.pid";
var relativePath = $"{subDirName}/{fileName}";
var result = DnsmasqConfIncludeParser.ResolvePath(relativePath, dir);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subDirName, fileName)), result);
}
[Fact]
public void ResolvePath_Absolute_ReturnsAsIs()
{
var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid");
var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir");
Assert.Equal(Path.GetFullPath(absPath), result);
}
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
[Fact]
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var invalidValue = "1";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"expand-hosts={invalidValue}\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
Assert.False(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
[Fact]
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var wrongKey = "Port";
var value = "53";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"{wrongKey}={value}\n");
var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
Assert.Null(parsedValue);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
[Fact]
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var main = Path.Combine(dir, "dnsmasq.conf");
var extra = Path.Combine(dir, "extra.conf");
var port1 = "53";
var port2 = "5353";
var port3 = "54";
try
{
File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n");
File.WriteAllText(extra, $"port={port3}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.Equal(port3, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
// --- pathToLines overloads: same result as file-based when content is pre-read ---
[Fact]
public void GetFlagFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var lines = new[] { "expand-hosts", "no-resolv" };
try
{
File.WriteAllText(conf, string.Join("\n", lines) + "\n");
var paths = new[] { conf };
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
[Path.GetFullPath(conf)] = lines
};
var fromFile = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts);
var fromDict = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ExpandHosts);
Assert.Equal(fromFile, fromDict);
Assert.True(fromDict);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-last-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var value = "1000";
var lines = new[] { $"cache-size={value}" };
try
{
File.WriteAllText(conf, lines[0] + "\n");
var paths = new[] { conf };
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
[Path.GetFullPath(conf)] = lines
};
var (fromFileVal, fromFileDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
var (fromDictVal, fromDictDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.CacheSize);
Assert.Equal(fromFileVal, fromDictVal);
Assert.Equal(value, fromDictVal);
Assert.Equal(fromFileDir, fromDictDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-multi-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var lines = new[] { "server=1.1.1.1", "server=8.8.8.8" };
try
{
File.WriteAllText(conf, string.Join("\n", lines) + "\n");
var paths = new[] { conf };
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
[Path.GetFullPath(conf)] = lines
};
var fromFile = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
var fromDict = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Server);
Assert.Equal(fromFile, fromDict);
Assert.Equal(2, fromDict.Count);
Assert.Equal("1.1.1.1", fromDict[0]);
Assert.Equal("8.8.8.8", fromDict[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
// --- Empty path list ---
[Fact]
public void GetLastValueFromConfigFiles_EmptyPathList_ReturnsNullAndNull()
{
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(Array.Empty<string>(), DnsmasqConfKeys.Port);
Assert.Null(value);
Assert.Null(configDir);
}
[Fact]
public void GetMultiValueFromConfigFiles_EmptyPathList_ReturnsEmptyList()
{
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(Array.Empty<string>(), DnsmasqConfKeys.Server);
Assert.NotNull(result);
Assert.Empty(result);
}
// --- Non-existent file in path list: skipped without throw, last-wins from existing ---
[Fact]
public void GetLastValueFromConfigFiles_NonExistentFileInList_SkipsMissingFileLastWinsFromExisting()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-missing-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var existing = Path.Combine(dir, "existing.conf");
var missing = Path.Combine(dir, "missing.conf");
var other = Path.Combine(dir, "other.conf");
var valueInExisting = "100";
var valueInOther = "200";
try
{
File.WriteAllText(existing, $"port={valueInExisting}\n");
File.WriteAllText(other, $"port={valueInOther}\n");
Assert.False(File.Exists(missing));
var paths = new[] { existing, missing, other };
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.Equal(valueInOther, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,377 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>DHCP advanced, TFTP/PXE, DNSSEC, enable-dbus/ubus, fast-dns-retry.</summary>
public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
{
// --- One test per option: key wired, when set returns value(s) ---
[Fact]
public void GetMultiValueFromConfigFiles_DhcpMatch_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpmatch-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "set:bios,option:client-arch,0";
try
{
File.WriteAllText(conf, $"dhcp-match={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpMatch);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpBoot_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpboot-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "tag:bios-x86,firmware/ipxe.pxe,192.168.1.1";
try
{
File.WriteAllText(conf, $"dhcp-boot={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpBoot);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpIgnore_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpignore-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "tag:!known";
try
{
File.WriteAllText(conf, $"dhcp-ignore={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpIgnore);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpVendorclass_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpvc-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "set:pxe,option:vendor-class-identifier,PXEClient";
try
{
File.WriteAllText(conf, $"dhcp-vendorclass={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpVendorclass);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpUserclass_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpuc-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "set:ipxe,iPXE";
try
{
File.WriteAllText(conf, $"dhcp-userclass={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpUserclass);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_RaParam_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-raparam-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "eth0,high,0";
try
{
File.WriteAllText(conf, $"ra-param={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RaParam);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_Slaac_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-slaac-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "eth0,0";
try
{
File.WriteAllText(conf, $"slaac={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Slaac);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_EnableTftp_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftp-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "enable-tftp\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableTftp);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_TftpRoot_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftproot-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/var/lib/tftpboot";
try
{
File.WriteAllText(conf, $"tftp-root={path}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TftpRoot);
Assert.Equal(path, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_TftpSecure_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftpsec-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "tftp-secure\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TftpSecure);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_PxeService_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pxe-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "x86PC,pxelinux.0";
try
{
File.WriteAllText(conf, $"pxe-service={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PxeService);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_PxePrompt_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pxeprompt-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "timeout,0";
try
{
File.WriteAllText(conf, $"pxe-prompt={line}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PxePrompt);
Assert.Equal(line, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_Dnssec_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnssec-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dnssec\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Dnssec);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_TrustAnchor_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-trust-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = ". IN DS 19036 8 2 49AAC11D7B6F6446702E54A1607371607A1A41855200FD2CE1CDE32F024E8FC5";
try
{
File.WriteAllText(conf, $"trust-anchor={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TrustAnchor);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dnssec-check-unsigned\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
Assert.NotNull(value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_DnssecCheckUnsigned_WhenEqualsNo_ReturnsNo()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-no-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dnssec-check-unsigned=no\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
Assert.Equal("no", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_EnableDbus_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dbus-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var svc = "org.freedesktop.NetworkManager";
try
{
File.WriteAllText(conf, $"enable-dbus={svc}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableDbus);
Assert.Equal(svc, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_EnableUbus_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ubus-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var svc = "com.example.dnsmasq";
try
{
File.WriteAllText(conf, $"enable-ubus={svc}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableUbus);
Assert.Equal(svc, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_FastDnsRetry_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-fastretry-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "500,5000";
try
{
File.WriteAllText(conf, $"fast-dns-retry={line}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FastDnsRetry);
Assert.Equal(line, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,299 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Boolean flags: no-hosts, bogus-priv, strict-order, domain-needed, no-poll, bind-*, rebind-*, clear-on-reload, filter*, localise-queries, log-debug, dhcp-authoritative, leasefile-ro.</summary>
public class DnsmasqConfIncludeParserFlagsTests
{
[Fact]
public void GetFlagFromConfigFiles_BogusPriv_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-boguspriv-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "bogus-priv\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BogusPriv));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_StrictOrder_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-strict-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "strict-order\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.StrictOrder));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_DomainNeeded_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-domainneed-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "domain-needed\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DomainNeeded));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_NoPoll_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nopoll-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-poll\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoPoll));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_BindInterfaces_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-bindif-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "bind-interfaces\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BindInterfaces));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_BindDynamic_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-binddyn-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "bind-dynamic\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BindDynamic));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_NoNegcache_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noneg-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-negcache\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoNegcache));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_DnsLoopDetect_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-loop-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dns-loop-detect\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnsLoopDetect));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_StopDnsRebind_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebind-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "stop-dns-rebind\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.StopDnsRebind));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_RebindLocalhostOk_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebindlo-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "rebind-localhost-ok\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RebindLocalhostOk));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_ClearOnReload_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-clear-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "clear-on-reload\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ClearOnReload));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_Filterwin2k_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filtw2k-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "filterwin2k\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Filterwin2k));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_FilterA_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filta-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "filter-A\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterA));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_FilterAaaa_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filtaaaa-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "filter-AAAA\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterAaaa));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_LocaliseQueries_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localise-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "localise-queries\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocaliseQueries));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_LogDebug_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logdbg-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "log-debug\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogDebug));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_DhcpAuthoritative_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpauth-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dhcp-authoritative\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpAuthoritative));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,199 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Hosts (addn-hosts, hostsdir) and DHCP lease file parsing.</summary>
public class DnsmasqConfIncludeParserHostsAndLeaseTests
{
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath()
{
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
var paths = new[] { mainPath };
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
Assert.NotNull(result);
Assert.Equal(Path.GetFullPath("/data/leases"), result);
}
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
[Fact]
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
{
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
var paths = new[] { mainPath };
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines");
Assert.Contains("1.1.1.1", servers);
Assert.Contains("8.8.8.8", servers);
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
Assert.Single(listenAddrs);
Assert.Equal("172.28.0.2", listenAddrs[0]);
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var firstLeasePath = "/var/first.leases";
var secondLeasePath = "/var/second.leases";
var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, $"dhcp-leasefile={firstLeasePath}\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
Assert.Equal(Path.GetFullPath(secondLeasePath), result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var subdir = "subdir";
var leasesFile = "leases";
var relativePath = $"{subdir}/{leasesFile}";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"dhcp-leasefile={relativePath}\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty()
{
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty<string>());
Assert.Empty(result);
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var path1 = "/etc/hosts.d/first";
var path2 = "/data/hosts";
var path3 = "/data/extra.hosts";
var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, $"addn-hosts={path1}\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, $"addn-hosts={path2}\naddn-hosts={path3}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath(path1), result[0]);
Assert.Equal(Path.GetFullPath(path2), result[1]);
Assert.Equal(Path.GetFullPath(path3), result[2]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var pathPart1 = "hosts.d";
var pathPart2 = "app.hosts";
var addnRelativePath = $"{pathPart1}/{pathPart2}";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"addn-hosts={addnRelativePath}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
Assert.Single(result);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, pathPart1, pathPart2)), result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var managedPath = Path.Combine(dir, "managed.conf");
var path1 = "/etc/hosts.a";
var path2 = "/etc/hosts.b";
var path3 = "/etc/hosts.c";
try
{
File.WriteAllText(f1, $"addn-hosts={path1}\n");
File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath(path1), result[0].Path);
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
Assert.False(result[0].Source.IsManaged);
Assert.Equal(1, result[0].Source.LineNumber);
Assert.Equal(Path.GetFullPath(path2), result[1].Path);
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
Assert.Equal(1, result[1].Source.LineNumber);
Assert.Equal(Path.GetFullPath(path3), result[2].Path);
Assert.Equal(2, result[2].Source.LineNumber);
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedPath = Path.Combine(dir, "zz-managed.conf");
var readOnlyPath = "/other/hosts";
var managedAddnPath = "/managed/hosts";
try
{
File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n");
File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
Assert.Equal(2, result.Count);
Assert.False(result[0].Source.IsManaged);
Assert.Equal(1, result[0].Source.LineNumber);
Assert.True(result[1].Source.IsManaged);
Assert.Equal(1, result[1].Source.LineNumber);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,289 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Config discovery: conf-file=, conf-dir=, GetIncludedPaths, GetFirstConfDir, GetIncludedPathsWithSource, conf-dir suffix.</summary>
public class DnsmasqConfIncludeParserIncludeTests
{
[Fact]
public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath()
{
var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
[Fact]
public void GetIncludedPaths_MainOnly_ReturnsSinglePath()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "domain=local\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var includeFileName = "extra.conf";
var includePath = Path.Combine(dir, includeFileName);
File.WriteAllText(includePath, "# extra\n");
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-file={includeFileName}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Equal(Path.GetFullPath(main), paths[0]);
Assert.Equal(Path.GetFullPath(includePath), paths[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDirName = "d";
var subDir = Path.Combine(baseDir, confDirName);
Directory.CreateDirectory(subDir);
var file1 = "aa.conf";
var file2 = "zz.conf";
try
{
File.WriteAllText(Path.Combine(subDir, file2), "");
File.WriteAllText(Path.Combine(subDir, file1), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-dir={confDirName}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
Assert.Equal(Path.GetFullPath(main), paths[0]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetFirstConfDir_NoConfDir_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "domain=local\nconf-file=other.conf\n");
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
Assert.Null(first);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDirName = "dnsmasq.d";
var subDir = Path.Combine(baseDir, confDirName);
Directory.CreateDirectory(subDir);
try
{
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-dir={confDirName}\n");
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
Assert.NotNull(first);
Assert.Equal(Path.GetFullPath(subDir), first);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_CommentsAndBlanks_Ignored()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n");
var extra = Path.Combine(dir, "extra.conf");
File.WriteAllText(extra, "");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Equal(Path.GetFullPath(extra), paths[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_ConfDirMissing_ReturnsMainOnly()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-missing-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
try
{
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=nonexistent-subdir\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>Mirrors harness layout: main + conf-dir with multiple files (including lan-dns.conf) + conf-file. Asserts all appear in load order with correct sources.</summary>
[Fact]
public void GetIncludedPathsWithSource_ConfDirAndConfFile_ReturnsAllFilesInLoadOrderWithSources()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-harness-layout-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDir = Path.Combine(baseDir, "dnsmasq.d");
Directory.CreateDirectory(confDir);
try
{
File.WriteAllText(Path.Combine(confDir, "01-other.conf"), "# other\n");
File.WriteAllText(Path.Combine(confDir, "02-servers.conf"), "# servers\n");
File.WriteAllText(Path.Combine(confDir, "lan-dns.conf"), "# lan dns\n");
File.WriteAllText(Path.Combine(baseDir, "zz-dnsmasq-webui.conf"), "# managed\n");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=dnsmasq.d\nconf-file=zz-dnsmasq-webui.conf\n");
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(main);
Assert.Equal(5, withSource.Count);
Assert.Equal(Path.GetFullPath(main), withSource[0].Path);
Assert.Equal(DnsmasqConfFileSource.Main, withSource[0].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "01-other.conf")), withSource[1].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[1].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "02-servers.conf")), withSource[2].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[2].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "lan-dns.conf")), withSource[3].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[3].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "zz-dnsmasq-webui.conf")), withSource[4].Path);
Assert.Equal(DnsmasqConfFileSource.ConfFile, withSource[4].Source);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
[Fact]
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
var fileConf1 = "a.conf";
var fileTxt = "b.txt";
var fileConf2 = "c.conf";
try
{
File.WriteAllText(Path.Combine(subDir, fileConf1), "");
File.WriteAllText(Path.Combine(subDir, fileTxt), "");
File.WriteAllText(Path.Combine(subDir, fileConf2), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,*.conf\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count);
Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal));
Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal));
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
[Fact]
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
var fileIncluded = "a.conf";
var fileExcluded = "b.conf.bak";
try
{
File.WriteAllText(Path.Combine(subDir, fileIncluded), "");
File.WriteAllText(Path.Combine(subDir, fileExcluded), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,.bak\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal));
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>Direct test for EnumerateConfigLinesInDnsmasqOrder: main has line1, conf-file=extra, line2; extra has extra1. Asserts interleaving order.</summary>
[Fact]
public void EnumerateConfigLinesInDnsmasqOrder_ConfFileInMiddle_InterleavesMainThenIncludedThenMain()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-enum-order-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var main = Path.Combine(dir, "dnsmasq.conf");
var extra = Path.Combine(dir, "extra.conf");
try
{
File.WriteAllText(main, "line1\nconf-file=extra.conf\nline2\n");
File.WriteAllText(extra, "extra1\n");
var mainFull = Path.GetFullPath(main);
var extraFull = Path.GetFullPath(extra);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var seq = DnsmasqConfIncludeParser.EnumerateConfigLinesInDnsmasqOrder(mainFull, mainDir, DnsmasqConfFileSource.Main, null).ToList();
Assert.Equal(5, seq.Count);
Assert.Equal((mainFull, 0, "", DnsmasqConfFileSource.Main), seq[0]);
Assert.Equal((mainFull, 1, "line1", DnsmasqConfFileSource.Main), seq[1]);
Assert.Equal((extraFull, 0, "", DnsmasqConfFileSource.ConfFile), seq[2]);
Assert.Equal((extraFull, 1, "extra1", DnsmasqConfFileSource.ConfFile), seq[3]);
Assert.Equal((mainFull, 3, "line2", DnsmasqConfFileSource.Main), seq[4]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,326 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Interfaces, auth-server, DHCP/option/resolv multi-value, rebind/bogus/ignore/alias, filter-rr, cache-rr.</summary>
public class DnsmasqConfIncludeParserInterfacesAndFilteringTests
{
// --- Additional tests: one per conf field so every option has a documented example test ---
[Fact]
public void GetMultiValueFromConfigFiles_Interface_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-if-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "interface=eth0\ninterface=wlan0\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Interface);
Assert.Equal(2, result.Count);
Assert.Equal("eth0", result[0]);
Assert.Equal("wlan0", result[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_ExceptInterface_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-except-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "except-interface=eth1\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExceptInterface);
Assert.Single(result);
Assert.Equal("eth1", result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_NoDhcpInterface_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcpi-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-dhcp-interface=eth2\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpInterface);
Assert.Single(result);
Assert.Equal("eth2", result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_NoDhcpv4Interface_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcp4-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-dhcpv4-interface=eth0\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpv4Interface);
Assert.Single(result);
Assert.Equal("eth0", result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_NoDhcpv6Interface_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcp6-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-dhcpv6-interface=eth0\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpv6Interface);
Assert.Single(result);
Assert.Equal("eth0", result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_AuthServer_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-auth-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "lan,eth0";
try
{
File.WriteAllText(conf, $"auth-server={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AuthServer);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpHost_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcphost-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "00:11:22:33:44:55,192.168.1.100,router,12h";
try
{
File.WriteAllText(conf, $"dhcp-host={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHost);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpOption_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpopt-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "option:router,192.168.1.1";
try
{
File.WriteAllText(conf, $"dhcp-option={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOption);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_ResolvFile_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-resolv-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/resolv.dnsmasq";
try
{
File.WriteAllText(conf, $"resolv-file={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ResolvFile);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_RebindDomainOk_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebind-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "/local.lan/";
try
{
File.WriteAllText(conf, $"rebind-domain-ok={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RebindDomainOk);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_BogusNxdomain_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-bogusnx-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "64.94.110.11";
try
{
File.WriteAllText(conf, $"bogus-nxdomain={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BogusNxdomain);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_IgnoreAddress_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignoreaddr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "192.168.1.0/24";
try
{
File.WriteAllText(conf, $"ignore-address={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.IgnoreAddress);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_Alias_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-alias-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "1.2.3.0,6.7.8.0,255.255.255.0";
try
{
File.WriteAllText(conf, $"alias={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Alias);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_FilterRr_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filterrr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "TXT,MX";
try
{
File.WriteAllText(conf, $"filter-rr={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterRr);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_CacheRr_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cacherr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "TXT";
try
{
File.WriteAllText(conf, $"cache-rr={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.CacheRr);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_NoHosts_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nohosts-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "no-hosts\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,448 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Positive parser tests for added options: key wired and "option present" behaviour.
/// Ensures key names in DnsmasqConfKeys match what the parser expects (complement to OfficialExampleTests which assert "all commented → false/empty").
/// Covers process flags (keep-in-foreground, no-daemon), DHCP include paths (dhcp-hostsfile, dhcp-optsfile, dhcp-hostsdir), proxy-dnssec, conntrack.
/// </summary>
public class DnsmasqConfIncludeParserNewOptionsTests
{
// --- Flags (8): key-only line → GetFlag returns true ---
[Fact]
public void GetFlagFromConfigFiles_ReadEthers_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.ReadEthers);
}
[Fact]
public void GetFlagFromConfigFiles_DhcpRapidCommit_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpRapidCommit);
}
[Fact]
public void GetFlagFromConfigFiles_TftpNoFail_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.TftpNoFail);
}
[Fact]
public void GetFlagFromConfigFiles_TftpNoBlocksize_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.TftpNoBlocksize);
}
[Fact]
public void GetFlagFromConfigFiles_Localmx_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.Localmx);
}
[Fact]
public void GetFlagFromConfigFiles_Selfmx_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.Selfmx);
}
[Fact]
public void GetFlagFromConfigFiles_EnableRa_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.EnableRa);
}
[Fact]
public void GetFlagFromConfigFiles_LogDhcp_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.LogDhcp);
}
[Fact]
public void GetFlagFromConfigFiles_KeepInForeground_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.KeepInForeground);
}
[Fact]
public void GetFlagFromConfigFiles_NoDaemon_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoDaemon);
}
[Fact]
public void GetFlagFromConfigFiles_ProxyDnssec_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.ProxyDnssec);
}
[Fact]
public void GetLastValueFromConfigFiles_ConnmarkAllowlistEnable_WhenPresent_ReturnsValue()
{
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.ConnmarkAllowlistEnable);
}
[Fact]
public void GetFlagFromConfigFiles_NoRoundRobin_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoRoundRobin);
}
[Fact]
public void GetFlagFromConfigFiles_DnssecNoTimecheck_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.DnssecNoTimecheck);
}
[Fact]
public void GetFlagFromConfigFiles_DnssecDebug_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.DnssecDebug);
}
[Fact]
public void GetMultiValueFromConfigFiles_Leasequery_WhenPresent_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "leasequery\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Leasequery);
Assert.Single(result);
Assert.Equal("", result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_Leasequery_WithValue_IsParsed()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "leasequery=10.0.0.0/24\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Leasequery);
Assert.Equal("10.0.0.0/24", Assert.Single(result));
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetFlagFromConfigFiles_QuietDhcp_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietDhcp);
}
[Fact]
public void GetFlagFromConfigFiles_QuietTftp_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietTftp);
}
[Fact]
public void GetLastValueFromConfigFiles_DhcpGenerateNames_WhenPresent_ReturnsValue()
{
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.DhcpGenerateNames);
}
[Fact]
public void GetLastValueFromConfigFiles_DhcpBroadcast_WhenPresent_ReturnsValue()
{
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.DhcpBroadcast);
}
[Fact]
public void GetFlagFromConfigFiles_DhcpSequentialIp_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpSequentialIp);
}
[Fact]
public void GetFlagFromConfigFiles_DhcpIgnoreClid_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpIgnoreClid);
}
[Fact]
public void GetLastValueFromConfigFiles_BootpDynamic_WhenPresent_ReturnsValue()
{
WriteLastWinsKeyOnlyAndAssertPresent(DnsmasqConfKeys.BootpDynamic);
}
[Fact]
public void GetFlagFromConfigFiles_NoPing_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoPing);
}
[Fact]
public void GetFlagFromConfigFiles_ScriptArp_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.ScriptArp);
}
[Fact]
public void GetFlagFromConfigFiles_ScriptOnRenewal_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.ScriptOnRenewal);
}
[Fact]
public void GetFlagFromConfigFiles_DhcpNoOverride_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpNoOverride);
}
[Fact]
public void GetFlagFromConfigFiles_QuietDhcp6_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietDhcp6);
}
[Fact]
public void GetFlagFromConfigFiles_QuietRa_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.QuietRa);
}
// --- Multi-value (6): key=value line(s) → GetMultiValue returns value(s) ---
[Fact]
public void GetMultiValueFromConfigFiles_DhcpOptionForce_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "208,f1:00:74:7e";
try
{
File.WriteAllText(conf, $"dhcp-option-force={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOptionForce);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_Ipset_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "/example.com/vpn,search";
try
{
File.WriteAllText(conf, $"ipset={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Ipset);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_Nftset_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "/example.com/ip#test#vpn";
try
{
File.WriteAllText(conf, $"nftset={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Nftset);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpMac_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "set:red,00:60:8C:*:*:*";
try
{
File.WriteAllText(conf, $"dhcp-mac={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpMac);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpNameMatch_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "set:wpad-ignore,wpad";
try
{
File.WriteAllText(conf, $"dhcp-name-match={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpNameMatch);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpIgnoreNames_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "tag:wpad-ignore";
try
{
File.WriteAllText(conf, $"dhcp-ignore-names={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpIgnoreNames);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpHostsfile_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/hosts.d";
try
{
File.WriteAllText(conf, $"dhcp-hostsfile={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHostsfile);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpOptsfile_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/opts.conf";
try
{
File.WriteAllText(conf, $"dhcp-optsfile={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOptsfile);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpHostsdir_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/hosts.d";
try
{
File.WriteAllText(conf, $"dhcp-hostsdir={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHostsdir);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
// --- Single-value (2): key=value → GetLastValue returns value ---
[Fact]
public void GetLastValueFromConfigFiles_MxTarget_WhenSet_ReturnsValue()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var value = "mail.example.com";
try
{
File.WriteAllText(conf, $"mx-target={value}\n");
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MxTarget);
Assert.Equal(value, result);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetLastValueFromConfigFiles_DhcpScript_WhenSet_ReturnsValue()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var value = "/usr/bin/dnsmasq-script";
try
{
File.WriteAllText(conf, $"dhcp-script={value}\n");
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpScript);
Assert.Equal(value, result);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetFlagFromConfigFiles_Conntrack_WhenPresent_ReturnsTrue()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "conntrack\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack));
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetFlagFromConfigFiles_Conntrack_WhenAbsent_ReturnsFalse()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "port=53\n");
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack));
}
finally { Directory.Delete(dir, recursive: true); }
}
private static void WriteFlagAndAssertTrue(string key)
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, key + "\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, key));
}
finally { Directory.Delete(dir, recursive: true); }
}
private static void WriteLastWinsKeyOnlyAndAssertPresent(string key)
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, key + "\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, key);
Assert.NotNull(value);
}
finally { Directory.Delete(dir, recursive: true); }
}
private static string CreateTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-newopts-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
}
@@ -0,0 +1,126 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests that the parser correctly handles the official dnsmasq example (testdata/dnsmasq.official.conf.example).
/// All options in that file are commented out, so parsing it should yield no active values (single path, empty/false/null).
/// </summary>
public class DnsmasqConfIncludeParserOfficialExampleTests
{
private static string GetOfficialExamplePath()
{
var path = TestDataHelper.GetPath("dnsmasq.official.conf.example");
Assert.True(File.Exists(path), "Testdata dnsmasq.official.conf.example required (download from https://github.com/imp/dnsmasq)");
return path;
}
[Fact]
public void GetIncludedPaths_OfficialExample_ReturnsSinglePath()
{
var mainPath = GetOfficialExamplePath();
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(mainPath), paths[0]);
}
[Fact]
public void GetLastValueFromConfigFiles_OfficialExample_AllCommented_ReturnsNull()
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
var (cacheVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
var (mxTargetVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.MxTarget);
var (dhcpScriptVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpScript);
Assert.Null(portVal);
Assert.Null(cacheVal);
Assert.Null(mxTargetVal);
Assert.Null(dhcpScriptVal);
}
[Fact]
public void GetMultiValueFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
var dhcpOptionForce = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpOptionForce);
var ipset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Ipset);
var nftset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Nftset);
var dhcpMac = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpMac);
var dhcpNameMatch = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpNameMatch);
var dhcpIgnoreNames = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpIgnoreNames);
Assert.Empty(servers);
Assert.Empty(locals);
Assert.Empty(dhcpOptionForce);
Assert.Empty(ipset);
Assert.Empty(nftset);
Assert.Empty(dhcpMac);
Assert.Empty(dhcpNameMatch);
Assert.Empty(dhcpIgnoreNames);
}
[Fact]
public void GetFlagFromConfigFiles_OfficialExample_AllCommented_ReturnsFalse()
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ReadEthers));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.DhcpRapidCommit));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.TftpNoFail));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.TftpNoBlocksize));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.Localmx));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.Selfmx));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.EnableRa));
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.LogDhcp));
}
[Fact]
public void OfficialExample_FileContainsExpectedOptionNames()
{
var path = GetOfficialExamplePath();
var content = File.ReadAllText(path);
// Sanity: official example documents the options we support (as commented lines).
Assert.Contains("read-ethers", content);
Assert.Contains("dhcp-option-force", content);
Assert.Contains("dhcp-rapid-commit", content);
Assert.Contains("dhcp-script", content);
Assert.Contains("tftp-no-fail", content);
Assert.Contains("tftp-no-blocksize", content);
Assert.Contains("mx-target", content);
Assert.Contains("localmx", content);
Assert.Contains("selfmx", content);
Assert.Contains("enable-ra", content);
Assert.Contains("log-dhcp", content);
Assert.Contains("ipset=", content);
Assert.Contains("nftset=", content);
Assert.Contains("dhcp-mac", content);
Assert.Contains("dhcp-name-match", content);
Assert.Contains("dhcp-ignore-names", content);
// SRV records use option name srv-host (not "srv")
Assert.Contains("srv-host", content);
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
Assert.NotNull(addnHosts);
Assert.Empty(addnHosts);
}
[Fact]
public void GetNoHostsFromConfigFiles_OfficialExample_AllCommented_ReturnsFalse()
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
Assert.False(noHosts);
}
}
@@ -0,0 +1,232 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>DNS records and local names: hostsdir, domain, cname, mx-host, srv, ptr/txt/naptr/host-record, dynamic-host, interface-name.</summary>
public class DnsmasqConfIncludeParserRecordsTests
{
// --- One test per option: key wired, when set returns value(s) ---
[Fact]
public void GetLastValueFromConfigFiles_Hostsdir_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hostsdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/hosts";
try
{
File.WriteAllText(conf, $"hostsdir={path}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Hostsdir);
Assert.Equal(path, value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_Domain_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-domain-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "home,192.168.1.1";
try
{
File.WriteAllText(conf, $"domain={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Domain);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_Cname_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cname-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "mail.example.com,real.example.com";
try
{
File.WriteAllText(conf, $"cname={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Cname);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_MxHost_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-mx-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "example.com,mail.example.com,50";
try
{
File.WriteAllText(conf, $"mx-host={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MxHost);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_Srv_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-srv-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "_http._tcp.example.com,server.example.com,80";
try
{
File.WriteAllText(conf, $"srv-host={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Srv);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_PtrRecord_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "1.168.192.in-addr.arpa,host.example.com";
try
{
File.WriteAllText(conf, $"ptr-record={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PtrRecord);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_TxtRecord_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-txt-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "example.com,v=spf1 include:_spf.example.com";
try
{
File.WriteAllText(conf, $"txt-record={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TxtRecord);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_NaptrRecord_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-naptr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "example.com,100,10,u,sip+E2U,sips:.*@example.com,.";
try
{
File.WriteAllText(conf, $"naptr-record={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NaptrRecord);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_HostRecord_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hostrecord-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "router.example.com,192.168.1.1";
try
{
File.WriteAllText(conf, $"host-record={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.HostRecord);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DynamicHost_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dynamichost-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "hostname,example.com,192.168.1.50";
try
{
File.WriteAllText(conf, $"dynamic-host={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DynamicHost);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_InterfaceName_WhenSet_ReturnsValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ifname-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var line = "eth0,router.local";
try
{
File.WriteAllText(conf, $"interface-name={line}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.InterfaceName);
Assert.Single(result);
Assert.Equal(line, result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,119 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Server/local, rev-server, dhcp-range, all-servers, log-queries.</summary>
public class DnsmasqConfIncludeParserServerAndResolvTests
{
[Fact]
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var v1 = "1.1.1.1";
var v2 = "/local/";
var v3 = "/example.com/192.168.1.1";
try
{
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
File.WriteAllText(f2, $"server={v3}\n");
var resultServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Server);
var resultLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Local);
Assert.Equal(2, resultServer.Count);
Assert.Equal(v1, resultServer[0]);
Assert.Equal(v3, resultServer[1]);
Assert.Single(resultLocal);
Assert.Equal(v2, resultLocal[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var range1 = "172.28.0.10,172.28.0.50,12h";
var range2 = "192.168.1.10,192.168.1.100,255.255.255.0,24h";
try
{
File.WriteAllText(conf, $"dhcp-range={range1}\ndhcp-range={range2}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
Assert.Equal(2, result.Count);
Assert.Equal(range1, result[0]);
Assert.Equal(range2, result[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_AllServers_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-allsrv-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "all-servers\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AllServers);
Assert.True(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogQueries_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logq-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "log-queries=extra\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogQueries);
Assert.Equal("extra", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_RevServer_ReturnsAllValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rev-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var v1 = "1.2.3.0/24,192.168.0.1";
var v2 = "10.0.0.0/8,10.0.0.1";
try
{
File.WriteAllText(conf, $"rev-server={v1}\nrev-server={v2}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RevServer);
Assert.Equal(2, result.Count);
Assert.Equal(v1, result[0]);
Assert.Equal(v2, result[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,352 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>Single-value options (last wins): local-ttl, pid-file, user, group, auth-ttl, edns-packet-max, query-port, port-limit, min/max-port, log-async, local-service, dhcp-lease-max, neg/max/min-cache-ttl, dhcp-ttl.</summary>
public class DnsmasqConfIncludeParserSingleValueTests
{
[Fact]
public void GetFlagFromConfigFiles_LeasefileRo_WhenPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-leasero-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "leasefile-ro\n");
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LeasefileRo));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LocalTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "local-ttl=300\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocalTtl);
Assert.Equal("300", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_PidFile_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pid-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/var/run/dnsmasq.pid";
try
{
File.WriteAllText(conf, $"pid-file={path}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PidFile);
Assert.Equal(path, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_User_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-user-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "user=nobody\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.User);
Assert.Equal("nobody", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_Group_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-group-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "group=dip\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Group);
Assert.Equal("dip", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_AuthTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-authttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "auth-ttl=60\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AuthTtl);
Assert.Equal("60", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_EdnsPacketMax_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-edns-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "edns-packet-max=1232\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EdnsPacketMax);
Assert.Equal("1232", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_QueryPort_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-qport-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "query-port=0\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.QueryPort);
Assert.Equal("0", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_PortLimit_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-portlimit-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "port-limit=3\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PortLimit);
Assert.Equal("3", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_MinPort_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-minport-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "min-port=1024\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MinPort);
Assert.Equal("1024", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_MaxPort_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxport-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "max-port=65535\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxPort);
Assert.Equal("65535", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogAsync_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logasync-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "log-async=25\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogAsync);
Assert.Equal("25", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LocalService_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localsvc-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "local-service=host\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocalService);
Assert.Equal("host", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_DhcpLeaseMax_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-leasemax-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dhcp-lease-max=150\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpLeaseMax);
Assert.Equal("150", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_NegTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-negttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "neg-ttl=60\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NegTtl);
Assert.Equal("60", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_MaxTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "max-ttl=3600\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxTtl);
Assert.Equal("3600", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_MaxCacheTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxcttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "max-cache-ttl=86400\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxCacheTtl);
Assert.Equal("86400", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_MinCacheTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-mincttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "min-cache-ttl=60\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MinCacheTtl);
Assert.Equal("60", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_DhcpTtl_WhenSet_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpttl-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, "dhcp-ttl=0\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpTtl);
Assert.Equal("0", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -1,690 +0,0 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for DnsmasqConfIncludeParser. Discovers conf-file= and conf-dir= from main dnsmasq config
/// to build ordered list of included file paths and first conf-dir for managed file.
/// </summary>
public class DnsmasqConfIncludeParserTests
{
[Fact]
public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath()
{
var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
[Fact]
public void GetIncludedPaths_MainOnly_ReturnsSinglePath()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "domain=local\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var includeFileName = "extra.conf";
var includePath = Path.Combine(dir, includeFileName);
File.WriteAllText(includePath, "# extra\n");
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-file={includeFileName}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Equal(Path.GetFullPath(main), paths[0]);
Assert.Equal(Path.GetFullPath(includePath), paths[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDirName = "d";
var subDir = Path.Combine(baseDir, confDirName);
Directory.CreateDirectory(subDir);
var file1 = "aa.conf";
var file2 = "zz.conf";
try
{
File.WriteAllText(Path.Combine(subDir, file2), "");
File.WriteAllText(Path.Combine(subDir, file1), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-dir={confDirName}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
Assert.Equal(Path.GetFullPath(main), paths[0]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetFirstConfDir_NoConfDir_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "domain=local\nconf-file=other.conf\n");
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
Assert.Null(first);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDirName = "dnsmasq.d";
var subDir = Path.Combine(baseDir, confDirName);
Directory.CreateDirectory(subDir);
try
{
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, $"conf-dir={confDirName}\n");
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
Assert.NotNull(first);
Assert.Equal(Path.GetFullPath(subDir), first);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_CommentsAndBlanks_Ignored()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n");
var extra = Path.Combine(dir, "extra.conf");
File.WriteAllText(extra, "");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Equal(Path.GetFullPath(extra), paths[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetIncludedPaths_ConfDirMissing_ReturnsMainOnly()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-missing-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
try
{
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=nonexistent-subdir\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(main), paths[0]);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>Mirrors harness layout: main + conf-dir with multiple files (including lan-dns.conf) + conf-file. Asserts all appear in load order with correct sources.</summary>
[Fact]
public void GetIncludedPathsWithSource_ConfDirAndConfFile_ReturnsAllFilesInLoadOrderWithSources()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-harness-layout-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir);
var confDir = Path.Combine(baseDir, "dnsmasq.d");
Directory.CreateDirectory(confDir);
try
{
File.WriteAllText(Path.Combine(confDir, "01-other.conf"), "# other\n");
File.WriteAllText(Path.Combine(confDir, "02-servers.conf"), "# servers\n");
File.WriteAllText(Path.Combine(confDir, "lan-dns.conf"), "# lan dns\n");
File.WriteAllText(Path.Combine(baseDir, "zz-dnsmasq-webui.conf"), "# managed\n");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=dnsmasq.d\nconf-file=zz-dnsmasq-webui.conf\n");
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(main);
Assert.Equal(5, withSource.Count);
Assert.Equal(Path.GetFullPath(main), withSource[0].Path);
Assert.Equal(DnsmasqConfFileSource.Main, withSource[0].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "01-other.conf")), withSource[1].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[1].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "02-servers.conf")), withSource[2].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[2].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "lan-dns.conf")), withSource[3].Path);
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[3].Source);
Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "zz-dnsmasq-webui.conf")), withSource[4].Path);
Assert.Equal(DnsmasqConfFileSource.ConfFile, withSource[4].Source);
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath()
{
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
if (!File.Exists(mainPath))
return;
var paths = new[] { mainPath };
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
Assert.NotNull(result);
Assert.Equal(Path.GetFullPath("/data/leases"), result);
}
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
[Fact]
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
{
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
if (!File.Exists(mainPath))
return;
var paths = new[] { mainPath };
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines");
Assert.Contains("1.1.1.1", servers);
Assert.Contains("8.8.8.8", servers);
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
Assert.Single(listenAddrs);
Assert.Equal("172.28.0.2", listenAddrs[0]);
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var firstLeasePath = "/var/first.leases";
var secondLeasePath = "/var/second.leases";
var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, $"dhcp-leasefile={firstLeasePath}\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
Assert.Equal(Path.GetFullPath(secondLeasePath), result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var subdir = "subdir";
var leasesFile = "leases";
var relativePath = $"{subdir}/{leasesFile}";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"dhcp-leasefile={relativePath}\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty()
{
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty<string>());
Assert.Empty(result);
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var path1 = "/etc/hosts.d/first";
var path2 = "/data/hosts";
var path3 = "/data/extra.hosts";
var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, $"addn-hosts={path1}\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, $"addn-hosts={path2}\naddn-hosts={path3}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath(path1), result[0]);
Assert.Equal(Path.GetFullPath(path2), result[1]);
Assert.Equal(Path.GetFullPath(path3), result[2]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var pathPart1 = "hosts.d";
var pathPart2 = "app.hosts";
var addnRelativePath = $"{pathPart1}/{pathPart2}";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"addn-hosts={addnRelativePath}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
Assert.Single(result);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, pathPart1, pathPart2)), result[0]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
Assert.True(result);
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "port=53\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
Assert.False(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var firstVal = "100";
var secondVal = "200";
try
{
File.WriteAllText(f1, $"cache-size={firstVal}\n");
File.WriteAllText(f2, $"cache-size={secondVal}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
Assert.Equal(secondVal, value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var logPath = "/data/dnsmasq.log";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"log-facility={logPath}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
Assert.Equal(logPath, value);
Assert.Equal(dir, configDir);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetLastValueFromConfigFiles_LogFacility_LastWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var firstLogPath = "/var/log/dnsmasq.log";
var secondLogPath = "/data/dnsmasq.log";
try
{
File.WriteAllText(f1, $"log-facility={firstLogPath}\n");
File.WriteAllText(f2, $"log-facility={secondLogPath}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
Assert.Equal(secondLogPath, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ResolvePath_Relative_ResolvesAgainstDir()
{
var dir = Path.GetTempPath();
var subDirName = "sub";
var fileName = "pid.pid";
var relativePath = $"{subDirName}/{fileName}";
var result = DnsmasqConfIncludeParser.ResolvePath(relativePath, dir);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subDirName, fileName)), result);
}
[Fact]
public void ResolvePath_Absolute_ReturnsAsIs()
{
var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid");
var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir");
Assert.Equal(Path.GetFullPath(absPath), result);
}
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
[Fact]
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var invalidValue = "1";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"expand-hosts={invalidValue}\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
Assert.False(result);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
[Fact]
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var wrongKey = "Port";
var value = "53";
var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, $"{wrongKey}={value}\n");
var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
Assert.Null(parsedValue);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
[Fact]
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var main = Path.Combine(dir, "dnsmasq.conf");
var extra = Path.Combine(dir, "extra.conf");
var port1 = "53";
var port2 = "5353";
var port3 = "54";
try
{
File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n");
File.WriteAllText(extra, $"port={port3}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.Equal(port3, value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
[Fact]
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
var fileConf1 = "a.conf";
var fileTxt = "b.txt";
var fileConf2 = "c.conf";
try
{
File.WriteAllText(Path.Combine(subDir, fileConf1), "");
File.WriteAllText(Path.Combine(subDir, fileTxt), "");
File.WriteAllText(Path.Combine(subDir, fileConf2), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,*.conf\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count);
Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal));
Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal));
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
[Fact]
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
{
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
var fileIncluded = "a.conf";
var fileExcluded = "b.conf.bak";
try
{
File.WriteAllText(Path.Combine(subDir, fileIncluded), "");
File.WriteAllText(Path.Combine(subDir, fileExcluded), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,.bak\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count);
Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal));
}
finally
{
Directory.Delete(baseDir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var managedPath = Path.Combine(dir, "managed.conf");
var path1 = "/etc/hosts.a";
var path2 = "/etc/hosts.b";
var path3 = "/etc/hosts.c";
try
{
File.WriteAllText(f1, $"addn-hosts={path1}\n");
File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath(path1), result[0].Path);
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
Assert.False(result[0].Source.IsManaged);
Assert.Equal(1, result[0].Source.LineNumber);
Assert.Equal(Path.GetFullPath(path2), result[1].Path);
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
Assert.Equal(1, result[1].Source.LineNumber);
Assert.Equal(Path.GetFullPath(path3), result[2].Path);
Assert.Equal(2, result[2].Source.LineNumber);
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedPath = Path.Combine(dir, "zz-managed.conf");
var readOnlyPath = "/other/hosts";
var managedAddnPath = "/managed/hosts";
try
{
File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n");
File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
Assert.Equal(2, result.Count);
Assert.False(result[0].Source.IsManaged);
Assert.Equal(1, result[0].Source.LineNumber);
Assert.True(result[1].Source.IsManaged);
Assert.Equal(1, result[1].Source.LineNumber);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf");
var v1 = "1.1.1.1";
var v2 = "/local/";
var v3 = "/example.com/192.168.1.1";
try
{
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
File.WriteAllText(f2, $"server={v3}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
Assert.Equal(3, result.Count);
Assert.Equal(v1, result[0]);
Assert.Equal(v2, result[1]);
Assert.Equal(v3, result[2]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
var range1 = "172.28.0.10,172.28.0.50,12h";
var range2 = "192.168.1.10,192.168.1.100,255.255.255.0,24h";
try
{
File.WriteAllText(conf, $"dhcp-range={range1}\ndhcp-range={range2}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
Assert.Equal(2, result.Count);
Assert.Equal(range1, result[0]);
Assert.Equal(range2, result[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,564 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Save-path tests for DnsmasqConfigService.ApplyEffectiveConfigChangesAsync.
/// Verifies that pending changes produce the correct managed config lines.
/// </summary>
public class DnsmasqConfigServiceApplyChangesTests
{
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_ConntrackTrue_WritesBareFlagLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Conntrack, false, true, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.Contains(lines, l => l == "conntrack");
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_ConntrackFalse_RemovesFlagLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, "conntrack\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Conntrack, true, false, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.DoesNotContain(lines, l => l == "conntrack" || l.StartsWith("conntrack=", StringComparison.Ordinal));
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_StripMacTrue_WritesBareFlagLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.StripMac, false, true, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.Contains(lines, l => l == DnsmasqConfKeys.StripMac);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_StripSubnetFalse_RemovesFlagLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, "strip-subnet\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.StripSubnet, true, false, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.DoesNotContain(lines, l => l == DnsmasqConfKeys.StripSubnet || l.StartsWith($"{DnsmasqConfKeys.StripSubnet}=", StringComparison.Ordinal));
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_DnssecCheckUnsignedNo_WritesEqualsNo()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var text = await File.ReadAllTextAsync(managedPath);
Assert.Contains("dnssec-check-unsigned=no", text);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_KeyOnlyOrValue_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("use-stale-cache", content);
Assert.DoesNotContain("use-stale-cache=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_InversePair_Enabled_WritesDo0x20Encode()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Enabled, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("do-0x20-encode", content);
Assert.DoesNotContain("no-0x20-encode", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Do0x20_Default_RemovesBothLines()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, "do-0x20-encode\nno-0x20-encode\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Disabled, ExplicitToggleState.Default, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.DoesNotContain(lines, l => l == "do-0x20-encode" || l == "no-0x20-encode");
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Do0x20_Disabled_WritesNo0x20Encode()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Disabled, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("no-0x20-encode", content);
Assert.DoesNotContain("do-0x20-encode", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_UseStaleCache_Value_WritesKeyEqualsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "60", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("use-stale-cache=60", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_AddMac_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddMac, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("add-mac", content);
Assert.DoesNotContain("add-mac=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_AddMac_ValueBase64_WritesKeyEqualsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddMac, null, "base64", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("add-mac=base64", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_AddSubnet_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddSubnet, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("add-subnet", content);
Assert.DoesNotContain("add-subnet=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Umbrella_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.Umbrella, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("umbrella", content);
Assert.DoesNotContain("umbrella=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_Leasequery_MultiKeyOnlyOrValue_DoesNotDuplicateReadonlyValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var readonlyPath = Path.Combine(dir, "readonly.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, $"port=53\nconf-file={readonlyPath}\n");
File.WriteAllText(readonlyPath, "leasequery\nleasequery=10.0.0.0/24\n");
File.WriteAllText(managedPath, "leasequery=172.16.0.0/16\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
// Simulate UI effective values (readonly + managed). Save should write only managed-only values.
var changes = new List<PendingEffectiveConfigChange>
{
new(
EffectiveConfigSections.SectionDhcp,
DnsmasqConfKeys.Leasequery,
null,
new List<string> { "", "10.0.0.0/24", "192.168.50.0/24" },
null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var lines = (await File.ReadAllLinesAsync(managedPath))
.Select(l => l.Trim())
.Where(l => l.StartsWith("leasequery", StringComparison.Ordinal))
.ToList();
Assert.Single(lines);
Assert.Equal("leasequery=192.168.50.0/24", lines[0]);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
}
@@ -1,7 +1,7 @@
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -25,7 +25,8 @@ public class DnsmasqConfigSetServiceTests
Assert.Empty(config.AddnHostsPaths);
Assert.NotNull(sources);
Assert.Empty(sources.AddnHostsPaths);
Assert.Empty(sources.ServerLocalValues);
Assert.Empty(sources.ServerValues);
Assert.Empty(sources.LocalValues);
Assert.Empty(sources.DhcpRanges);
Assert.Null(sources.NoHosts);
Assert.Null(sources.Port);
@@ -59,7 +60,8 @@ public class DnsmasqConfigSetServiceTests
Assert.Equal(cacheSize, config.CacheSize);
Assert.Single(config.AddnHostsPaths);
Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
Assert.Empty(config.ServerLocalValues);
Assert.Empty(config.ServerValues);
Assert.Empty(config.LocalValues);
Assert.Empty(config.DhcpRanges);
Assert.NotNull(sources);
@@ -0,0 +1,42 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
namespace DnsmasqWebUI.Tests;
public class DnsmasqDhcpRangeValueParserTests
{
[Fact]
public void TryParse_ValidRange_ReturnsStructuredTokens()
{
var ok = DnsmasqDhcpRangeValueParser.TryParse(
"tag:guest,set:known,192.168.1.50,192.168.1.150,12h",
out var parsed,
out var error);
Assert.True(ok);
Assert.Null(error);
Assert.NotNull(parsed);
Assert.Equal(["tag:guest", "set:known"], parsed!.Tags);
Assert.Equal("192.168.1.50", parsed.StartToken);
Assert.Equal("192.168.1.150", parsed.SecondToken);
Assert.Equal(["12h"], parsed.RemainingTokens);
}
[Fact]
public void TryParse_MalformedTrailingComma_ReturnsError()
{
var ok = DnsmasqDhcpRangeValueParser.TryParse("172.28.0.10,", out var parsed, out var error);
Assert.False(ok);
Assert.Null(parsed);
Assert.Equal("dhcp-range contains an empty comma-separated segment.", error);
}
[Fact]
public void GetIPv4StartEnd_ReturnsParsedIpv4Range()
{
var result = DnsmasqDhcpRangeValueParser.GetIPv4StartEnd("tag:guest,192.168.1.50,192.168.1.150,12h");
Assert.Equal(("192.168.1.50", "192.168.1.150"), result);
}
}
@@ -0,0 +1,32 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Documents and guards the Do0x20 display label rule: Default and Disabled both show "Disabled" (product choice).
/// Do not "fix" the display to show "Default" for unset without updating this test and the component comment.
/// </summary>
public class Do0x20EncodeDisplayLabelTests
{
/// <summary>Same logic as Do0x20EncodeDisplay.razor view mode: Enabled → "Enabled", else "Disabled".</summary>
private static string GetDisplayLabel(ExplicitToggleState state) =>
state == ExplicitToggleState.Enabled ? "Enabled" : "Disabled";
[Fact]
public void Do0x20_Default_ShowsDisabled()
{
Assert.Equal("Disabled", GetDisplayLabel(ExplicitToggleState.Default));
}
[Fact]
public void Do0x20_Disabled_ShowsDisabled()
{
Assert.Equal("Disabled", GetDisplayLabel(ExplicitToggleState.Disabled));
}
[Fact]
public void Do0x20_Enabled_ShowsEnabled()
{
Assert.Equal("Enabled", GetDisplayLabel(ExplicitToggleState.Enabled));
}
}
@@ -0,0 +1,22 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Ensures effective-config editable options list includes supported cache flags.
/// </summary>
public class EffectiveConfigEditableOptionsTests
{
/// <summary>
/// strip-mac and strip-subnet are supported in the effective-config UI and should be listed in cache options.
/// </summary>
[Fact]
public void EditableOptions_Include_StripMac_And_StripSubnet()
{
var allOptionNames = EffectiveConfigSections.GetSectionsInOrder()
.SelectMany(t => EffectiveConfigSections.GetOptionsInSection(t.SectionId))
.ToHashSet(StringComparer.Ordinal);
Assert.Contains(DnsmasqConfKeys.StripMac, allOptionNames);
Assert.Contains(DnsmasqConfKeys.StripSubnet, allOptionNames);
}
}
@@ -0,0 +1,142 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for option -> feature mapping and capability checks.
/// </summary>
public class EffectiveConfigFeatureRequirementsTests
{
[Theory]
[InlineData(DnsmasqConfKeys.Dnssec, DnsmasqFeature.Dnssec)]
[InlineData(DnsmasqConfKeys.DnssecCheckUnsigned, DnsmasqFeature.Dnssec)]
[InlineData(DnsmasqConfKeys.TrustAnchor, DnsmasqFeature.Dnssec)]
[InlineData(DnsmasqConfKeys.DhcpRange, DnsmasqFeature.Dhcp)]
[InlineData(DnsmasqConfKeys.EnableTftp, DnsmasqFeature.Tftp)]
[InlineData(DnsmasqConfKeys.EnableDbus, DnsmasqFeature.Dbus)]
public void GetRequiredFeature_ReturnsExpectedFeature(string optionName, DnsmasqFeature expected)
{
var feature = EffectiveConfigFeatureRequirements.GetRequiredFeature(optionName);
Assert.NotNull(feature);
Assert.Equal(expected, feature.Value);
}
[Fact]
public void GetRequiredFeature_ForUnknownOption_ReturnsNull()
{
var feature = EffectiveConfigFeatureRequirements.GetRequiredFeature("no-such-option");
Assert.Null(feature);
}
[Fact]
public void IsSupportedByCapabilities_WhenCapabilityPresent_ReturnsTrue()
{
var caps = new DnsmasqCompileCapabilities(true, true, true, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP", "DNSSEC" });
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, caps));
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DhcpRange, caps));
}
[Fact]
public void IsSupportedByCapabilities_WhenDnssecMissing_ReturnsFalseForDnssecOptions()
{
var caps = new DnsmasqCompileCapabilities(true, true, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase));
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.Dnssec, caps));
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, caps));
}
[Fact]
public void IsSupportedByCapabilities_ForOptionWithNoRequiredFeature_ReturnsTrue()
{
var caps = new DnsmasqCompileCapabilities(false, false, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase));
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.Port, caps));
}
[Fact]
public void GetCapabilityDisabled_WhenStatusSupportsFeature_ReturnsNotDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: true);
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, status);
Assert.False(isDisabled);
Assert.Null(reason);
}
[Fact]
public void GetCapabilityDisabled_WhenStatusMissingDnssec_ReturnsDisabledWithReason()
{
var status = CreateStatusWithCapabilities(dnssec: false);
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, status);
Assert.True(isDisabled);
Assert.NotNull(reason);
Assert.Contains("DNSSEC", reason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetCapabilityDisabled_WhenStatusNull_ReturnsNotDisabled()
{
var (isDisabled, _) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, null);
Assert.False(isDisabled);
}
/// <summary>
/// Save path uses IsSupportedByCapabilities(optionName, version.Capabilities) to reject changes before write.
/// This test asserts that logic: a build without DNSSEC would reject dnssec-check-unsigned.
/// </summary>
[Fact]
public void SaveGuard_WhenBuildHasNoDnssec_DnssecOptionChangesWouldBeRejected()
{
var noDnssecCaps = new DnsmasqCompileCapabilities(true, true, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP" });
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, noDnssecCaps));
var reason = EffectiveConfigFeatureRequirements.GetUnsupportedReason(DnsmasqFeature.Dnssec);
Assert.Contains("DNSSEC", reason, StringComparison.OrdinalIgnoreCase);
}
private static DnsmasqServiceStatus CreateStatusWithCapabilities(bool dhcp = true, bool tftp = true, bool dnssec = true, bool dbus = false)
{
return new DnsmasqServiceStatus(
SystemHostsPath: null,
SystemHostsPathExists: false,
ManagedHostsFilePath: null,
ManagedHostsPathExists: false,
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
EffectiveConfig: null,
EffectiveConfigSources: null,
MainConfigPath: null,
ManagedFilePath: null,
LeasesPath: null,
MainConfigPathExists: false,
ManagedFilePathExists: false,
LeasesPathConfigured: false,
LeasesPathExists: false,
ConfigFiles: null,
ReloadCommandConfigured: false,
StatusCommandConfigured: false,
StatusShowConfigured: false,
LogsConfigured: false,
LogsPath: null,
StatusShowCommand: null,
LogsCommand: null,
DnsmasqStatus: "active",
StatusCommandExitCode: null,
StatusCommandStdout: null,
StatusCommandStderr: null,
StatusShowOutput: null,
LogsOutput: null,
DhcpRangeStart: null,
DhcpRangeEnd: null,
DnsmasqVersion: "2.91",
MinimumSupportedDnsmasqVersion: "2.91",
DnsmasqVersionSupported: true,
DnsmasqVersionError: null,
DnsmasqSupportsDhcp: dhcp,
DnsmasqSupportsTftp: tftp,
DnsmasqSupportsDnssec: dnssec,
DnsmasqSupportsDbus: dbus);
}
}
@@ -0,0 +1,104 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests that field descriptors get IsCapabilityDisabled set when the status indicates the option is not supported by the dnsmasq build.
/// </summary>
public class EffectiveConfigFieldBuilderCapabilityTests
{
[Fact]
public void GetSingleDescriptor_WhenDnssecOptionAndStatusHasNoDnssec_ReturnsDescriptorWithCapabilityDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: false);
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
null,
DnsmasqConfKeys.DnssecCheckUnsigned,
status,
_ => null,
_ => null,
null);
Assert.True(descriptor.IsCapabilityDisabled);
Assert.NotNull(descriptor.CapabilityDisabledReason);
Assert.Contains("DNSSEC", descriptor.CapabilityDisabledReason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetSingleDescriptor_WhenDnssecOptionAndStatusHasDnssec_ReturnsDescriptorWithCapabilityNotDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: true);
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
null,
DnsmasqConfKeys.DnssecCheckUnsigned,
status,
_ => null,
_ => null,
null);
Assert.False(descriptor.IsCapabilityDisabled);
Assert.Null(descriptor.CapabilityDisabledReason);
}
[Fact]
public void GetSingleDescriptor_WhenOptionWithNoFeatureRequirement_ReturnsDescriptorWithCapabilityNotDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: false);
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
null,
DnsmasqConfKeys.Port,
status,
_ => null,
_ => null,
null);
Assert.False(descriptor.IsCapabilityDisabled);
Assert.Null(descriptor.CapabilityDisabledReason);
}
private static DnsmasqServiceStatus CreateStatusWithCapabilities(bool dhcp = true, bool tftp = true, bool dnssec = true, bool dbus = false)
{
return new DnsmasqServiceStatus(
SystemHostsPath: null,
SystemHostsPathExists: false,
ManagedHostsFilePath: null,
ManagedHostsPathExists: false,
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
EffectiveConfig: null,
EffectiveConfigSources: null,
MainConfigPath: null,
ManagedFilePath: null,
LeasesPath: null,
MainConfigPathExists: false,
ManagedFilePathExists: false,
LeasesPathConfigured: false,
LeasesPathExists: false,
ConfigFiles: null,
ReloadCommandConfigured: false,
StatusCommandConfigured: false,
StatusShowConfigured: false,
LogsConfigured: false,
LogsPath: null,
StatusShowCommand: null,
LogsCommand: null,
DnsmasqStatus: "active",
StatusCommandExitCode: null,
StatusCommandStdout: null,
StatusCommandStderr: null,
StatusShowOutput: null,
LogsOutput: null,
DhcpRangeStart: null,
DhcpRangeEnd: null,
DnsmasqVersion: "2.91",
MinimumSupportedDnsmasqVersion: "2.91",
DnsmasqVersionSupported: true,
DnsmasqVersionError: null,
DnsmasqSupportsDhcp: dhcp,
DnsmasqSupportsTftp: tftp,
DnsmasqSupportsDnssec: dnssec,
DnsmasqSupportsDbus: dbus);
}
}
@@ -0,0 +1,56 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Wiring tests that ensure special-option semantics actually feed parser and registry behavior.
/// </summary>
public class EffectiveConfigSemanticsWiringTests
{
[Fact]
public void ParserBehaviorMap_UsesSpecialSemantics_ForAllSpecialOptions()
{
foreach (var option in EffectiveConfigSpecialOptionSemantics.GetAllOptionNames())
{
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(option)!;
Assert.Equal(semantics.ParserBehavior, EffectiveConfigParserBehaviorMap.GetBehavior(option));
}
}
[Fact]
public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics()
{
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new LeasequerySemanticHandler()]));
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery);
Assert.NotNull(factory);
var descriptor = factory!(
EffectiveConfigFieldBuilder.SectionDhcp,
DnsmasqConfKeys.Leasequery,
status: null,
getItems: _ => null);
Assert.NotNull(descriptor.Validator);
Assert.NotNull(descriptor.Validator!.ValidateItem("not-an-ip", Array.Empty<string>()));
Assert.Null(descriptor.Validator.ValidateItem("10.0.0.0/24", Array.Empty<string>()));
}
/// <summary>Pre-save semantic blocking: invalid leasequery value produces errors; SaveService uses this and returns SemanticValidationFailed before write.</summary>
[Fact]
public void SemanticValidationService_InvalidLeasequery_ReturnsError()
{
var validator = new OptionSemanticValidator([new LeasequerySemanticHandler()]);
var service = new EffectiveConfigSemanticValidationService(validator);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery, null,
new List<string> { "not-an-ip" }, null)
};
var issues = service.Validate(changes);
Assert.NotEmpty(issues);
Assert.Contains(issues, i => i.Severity == FieldIssueSeverity.Error);
}
}
@@ -11,7 +11,10 @@ public class EffectiveDnsmasqConfigTests
new(
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
ServerLocalValues: Array.Empty<string>(),
HostsdirPath: null,
ServerValues: Array.Empty<string>(),
LocalValues: Array.Empty<string>(),
RevServerValues: Array.Empty<string>(),
AddressValues: Array.Empty<string>(),
Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(),
@@ -19,17 +22,110 @@ public class EffectiveDnsmasqConfigTests
DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(),
DhcpOptionLines: Array.Empty<string>(),
DhcpMatchValues: Array.Empty<string>(),
DhcpBootValues: Array.Empty<string>(),
DhcpIgnoreValues: Array.Empty<string>(),
DhcpVendorclassValues: Array.Empty<string>(),
DhcpUserclassValues: Array.Empty<string>(),
RaParamValues: Array.Empty<string>(),
SlaacValues: Array.Empty<string>(),
PxeServiceValues: Array.Empty<string>(),
TrustAnchorValues: Array.Empty<string>(),
ResolvFiles: Array.Empty<string>(),
RebindDomainOkValues: Array.Empty<string>(),
BogusNxdomainValues: Array.Empty<string>(),
IgnoreAddressValues: Array.Empty<string>(),
AliasValues: Array.Empty<string>(),
FilterRrValues: Array.Empty<string>(),
CacheRrValues: Array.Empty<string>(),
AuthServerValues: Array.Empty<string>(),
NoDhcpInterfaceValues: Array.Empty<string>(),
NoDhcpv4InterfaceValues: Array.Empty<string>(),
NoDhcpv6InterfaceValues: Array.Empty<string>(),
DomainValues: Array.Empty<string>(), CnameValues: Array.Empty<string>(), MxHostValues: Array.Empty<string>(), SrvValues: Array.Empty<string>(),
PtrRecordValues: Array.Empty<string>(), TxtRecordValues: Array.Empty<string>(), NaptrRecordValues: Array.Empty<string>(),
HostRecordValues: Array.Empty<string>(), DynamicHostValues: Array.Empty<string>(), InterfaceNameValues: Array.Empty<string>(),
DhcpOptionForceLines: Array.Empty<string>(),
IpsetValues: Array.Empty<string>(),
NftsetValues: Array.Empty<string>(),
DhcpMacValues: Array.Empty<string>(),
DhcpNameMatchValues: Array.Empty<string>(),
DhcpIgnoreNamesValues: Array.Empty<string>(),
DhcpHostsfilePaths: Array.Empty<string>(),
DhcpOptsfilePaths: Array.Empty<string>(),
DhcpHostsdirPaths: Array.Empty<string>(),
DhcpOptsdirPaths: Array.Empty<string>(),
ConnmarkAllowlistValues: Array.Empty<string>(),
CaaRecordValues: Array.Empty<string>(),
DnsRrValues: Array.Empty<string>(),
SynthDomainValues: Array.Empty<string>(),
AuthZoneValues: Array.Empty<string>(),
AuthSoaValues: Array.Empty<string>(),
AuthSecServersValues: Array.Empty<string>(),
AuthPeerValues: Array.Empty<string>(),
DhcpRelayValues: Array.Empty<string>(),
DhcpCircuitidValues: Array.Empty<string>(),
DhcpRemoteidValues: Array.Empty<string>(),
DhcpSubscridValues: Array.Empty<string>(),
DhcpProxyValues: Array.Empty<string>(),
TagIfValues: Array.Empty<string>(),
BridgeInterfaceValues: Array.Empty<string>(),
SharedNetworkValues: Array.Empty<string>(),
DhcpOptionPxeValues: Array.Empty<string>(),
ExpandHosts: false,
BogusPriv: false,
StrictOrder: false,
AllServers: false,
NoResolv: false,
DomainNeeded: false,
NoPoll: false,
BindInterfaces: false,
BindDynamic: false,
NoNegcache: false,
DnsLoopDetect: false,
StopDnsRebind: false,
RebindLocalhostOk: false,
ClearOnReload: false,
Filterwin2k: false,
FilterA: false,
FilterAaaa: false,
LocaliseQueries: false,
LogDebug: false,
DhcpAuthoritative: false,
LeasefileRo: false,
EnableTftp: false,
TftpSecure: false,
TftpNoFail: false,
TftpNoBlocksize: false,
Dnssec: false,
DnssecCheckUnsigned: null,
ReadEthers: false,
DhcpRapidCommit: false,
Localmx: false,
Selfmx: false,
EnableRa: false,
LogDhcp: false,
KeepInForeground: false,
NoDaemon: false,
ProxyDnssec: false,
ConnmarkAllowlistEnable: null,
NoRoundRobin: false,
DnssecNoTimecheck: false,
DnssecDebug: false,
LeasequeryValues: Array.Empty<string>(),
DhcpGenerateNames: null,
DhcpBroadcast: null,
DhcpSequentialIp: false,
DhcpIgnoreClid: false,
BootpDynamic: null,
NoPing: false,
ScriptArp: false,
ScriptOnRenewal: false,
DhcpNoOverride: false,
QuietDhcp: false,
QuietDhcp6: false,
QuietRa: false,
QuietTftp: false,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
@@ -38,12 +134,47 @@ public class EffectiveDnsmasqConfigTests
User: null,
Group: null,
LogFacility: logFacility,
LogQueries: null,
AuthTtl: null,
EdnsPacketMax: null,
QueryPort: null,
PortLimit: null,
MinPort: null,
MaxPort: null,
LogAsync: null,
LocalService: null,
DhcpLeaseMax: null,
NegTtl: null,
MaxTtl: null,
MaxCacheTtl: null,
MinCacheTtl: null,
DhcpTtl: null
DhcpTtl: null,
TftpRootPath: null,
PxePrompt: null,
EnableDbus: null,
EnableUbus: null,
FastDnsRetry: null,
DhcpScriptPath: null,
MxTarget: null,
DnsForwardMax: null,
DumpfilePath: null,
Dumpmask: null,
AddCpeId: null,
DnssecTimestamp: null,
DnssecLimits: null,
DhcpAlternatePort: null,
DhcpDuid: null,
DhcpLuascriptPath: null,
DhcpScriptuser: null,
DhcpPxeVendor: null,
UseStaleCache: null,
AddMac: null,
StripMac: false,
AddSubnet: null,
StripSubnet: false,
Umbrella: null,
Do0x20EncodeState: ExplicitToggleState.Default,
Conntrack: false
);
[Fact]
@@ -0,0 +1,938 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for the central semantic validator (handler dispatch and generic kind validation).
/// </summary>
public class OptionSemanticValidatorTests
{
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
new LeasequerySemanticHandler(),
new ServerSemanticHandler(),
new LocalSemanticHandler(),
new RevServerSemanticHandler(),
new AddressSemanticHandler(),
new TrustAnchorSemanticHandler(),
new AliasSemanticHandler(),
new IpsetSemanticHandler(),
new NftsetSemanticHandler(),
new IgnoreAddressSemanticHandler(),
new ConnmarkAllowlistSemanticHandler(),
new DhcpRangeSemanticHandler(),
new DhcpHostSemanticHandler(),
new DhcpOptionSemanticHandler(),
new DhcpMatchSemanticHandler(),
new DhcpMacSemanticHandler(),
new DhcpRelaySemanticHandler(),
new DhcpProxySemanticHandler(),
new RaParamSemanticHandler(),
new DhcpNameMatchSemanticHandler(),
new DhcpIgnoreSemanticHandler(),
new DhcpVendorclassSemanticHandler(),
new DhcpUserclassSemanticHandler(),
new TagIfSemanticHandler(),
new BridgeInterfaceSemanticHandler(),
new SharedNetworkSemanticHandler(),
new DhcpOptionPxeSemanticHandler(),
new RebindDomainOkSemanticHandler(),
new BogusNxdomainSemanticHandler(),
new DhcpIgnoreNamesSemanticHandler(),
new DhcpBootSemanticHandler(),
new SlaacSemanticHandler(),
new PxeServiceSemanticHandler(),
new DhcpCircuitidSemanticHandler(),
new DhcpRemoteidSemanticHandler(),
new DhcpSubscridSemanticHandler(),
new FilterRrSemanticHandler(),
new CacheRrSemanticHandler(),
new InterfaceNameSemanticHandler(),
new AuthServerSemanticHandler(),
new CnameSemanticHandler(),
new MxHostSemanticHandler(),
new PtrRecordSemanticHandler(),
new InterfaceNameRecordSemanticHandler(),
new CaaRecordSemanticHandler(),
new SrvHostSemanticHandler(),
new NaptrRecordSemanticHandler(),
new DnsRrSemanticHandler(),
new DynamicHostSemanticHandler(),
new AuthSoaSemanticHandler(),
new AuthSecServersSemanticHandler(),
new AuthPeerSemanticHandler(),
new HostRecordSemanticHandler(),
new TxtRecordSemanticHandler(),
new DomainSemanticHandler(),
new SynthDomainSemanticHandler(),
new AuthZoneSemanticHandler(),
]);
[Fact]
public void ValidateMultiItem_LeasequeryHandler_InvalidIp_ReturnsError()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Leasequery, "not-an-ip", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Leasequery, "10.0.0.0/24", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Leasequery, "", semantics));
}
[Theory]
[InlineData("1.2.3.4", true)]
[InlineData("::1", true)]
[InlineData("", true)]
[InlineData("x.y.z", false)]
public void ValidateMultiItem_IpAddressKind_AcceptsValid_RejectsInvalid(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.IpAddress, allowEmpty: true);
var err = _validator.ValidateMultiItem("option", value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("", true, true)]
[InlineData("x", true, true)]
[InlineData("", false, false)]
public void ValidateMultiItem_StringKind_RespectsAllowEmpty(string value, bool allowEmpty, bool expectValid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.String, allowEmpty: allowEmpty);
var err = _validator.ValidateMultiItem("option", value, semantics);
Assert.Equal(expectValid, err is null);
}
[Theory]
[InlineData("8.8.8.8", true)]
[InlineData("dns.example.com", true)]
[InlineData("/internal.lan/192.168.2.1", true)]
[InlineData("/google.com/#", true)]
[InlineData("//", true)]
[InlineData("", false)]
[InlineData("http://bad", false)]
[InlineData("/internal$lan/192.168.2.1", false)]
[InlineData("/google.com/192.168.2.1#70000", false)]
[InlineData("/google.com/@eth0", false)]
public void ValidateMultiItem_Server_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Server, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("192.168.1.1", true)]
[InlineData("::1", true)]
[InlineData("", false)]
[InlineData("dns.example.com", false)]
public void ValidateMultiItem_ListenAddress_UsesIpAddressSemantics(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.IpAddress, allowEmpty: false);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.ListenAddress, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("1.2.3.0/24,192.168.1.1", true)]
[InlineData("2001:db8::/64,2001:4860:4860::8888", true)]
[InlineData("1.2.3.0/33,192.168.1.1", false)]
[InlineData("not-an-ip/24,192.168.1.1", false)]
[InlineData("1.2.3.0/x,192.168.1.1", false)]
[InlineData("1.2.3.0/24,", false)]
public void ValidateMultiItem_RevServer_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.RevServer, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("/example.local/192.168.1.10", true)]
[InlineData("/#/1.2.3.4", true)]
[InlineData("/example.local/#", true)]
[InlineData("/example.local/", true)]
[InlineData("example.local/192.168.1.10", false)]
[InlineData("//192.168.1.10", true)]
[InlineData("/example.local/not-an-ip", false)]
public void ValidateMultiItem_Address_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Address, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("/example.local/", true)]
[InlineData("//", true)]
[InlineData("/*.example.local/", true)]
[InlineData("/internal$lan/", false)]
[InlineData("/example.local/192.168.1.1", false)]
public void ValidateMultiItem_Local_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Local, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData(".,20326,8,2,abcdef", true)]
[InlineData("example.com", true)]
[InlineData("example.com,IN", true)]
[InlineData("example.com,IN,20326,8,2,abcdef", true)]
[InlineData("", false)]
[InlineData("example.com,BOGUS", false)]
[InlineData("example.com,IN,tag,8,2,abcdef", false)]
public void ValidateMultiItem_TrustAnchor_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.TrustAnchor, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("1.2.3.0,6.7.8.0,255.255.255.0", true)]
[InlineData("192.168.0.10-192.168.0.40,10.0.0.0,255.255.255.0", true)]
[InlineData("192.168.0.10-,10.0.0.1", false)]
[InlineData("not-an-ip,10.0.0.1", false)]
public void ValidateMultiItem_Alias_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Alias, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("/example.local/ipset1", true)]
[InlineData("/example.local/example.org/ipset1,ipset2", true)]
[InlineData("/internal$lan/ipset1", false)]
[InlineData("/example.local/", false)]
public void ValidateMultiItem_Ipset_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Ipset, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("/example.local/inet#filter#set1", true)]
[InlineData("/example.local/4#inet#filter#set1", true)]
[InlineData("/example.local/6#inet#filter#set1", true)]
[InlineData("/example.local/not#enough", false)]
[InlineData("/internal$lan/inet#filter#set1", false)]
public void ValidateMultiItem_Nftset_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Nftset, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("64.94.110.11", true)]
[InlineData("10.0.0.0/24", true)]
[InlineData("2001:db8::/64", true)]
[InlineData("not-an-ip", false)]
public void ValidateMultiItem_IgnoreAddress_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.IgnoreAddress, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("example.com", true)]
[InlineData("/domain1/domain2/", true)]
[InlineData("/domain1//", false)]
[InlineData("internal$lan", false)]
public void ValidateMultiItem_RebindDomainOk_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.RebindDomainOk, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("64.94.110.11", true)]
[InlineData("64.94.110.11/24", true)]
[InlineData("not-an-ip", false)]
public void ValidateMultiItem_BogusNxdomain_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.BogusNxdomain, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("0xff,example.com", true)]
[InlineData("0xff/0xff,*", true)]
[InlineData("0xff,*.example.com/api.example.com", true)]
[InlineData("not-a-mark,example.com", false)]
[InlineData("0xff,local", false)]
public void ValidateMultiItem_ConnmarkAllowlist_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.ConnmarkAllowlist, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("192.168.1.50,192.168.1.150", true)]
[InlineData("tag:guest,set:known,192.168.1.50,192.168.1.150,12h", true)]
[InlineData("constructor:eth0,::,static", true)]
[InlineData("172.28.0.10,", false)]
[InlineData("172.28.0.10", false)]
[InlineData("tag:guest,", false)]
[InlineData("not-an-ip,192.168.1.150", false)]
public void ValidateMultiItem_DhcpRange_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRange, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("00:20:e0:3b:13:af,wap,infinite", true)]
[InlineData("lap,192.168.0.199", true)]
[InlineData("id:clientid,set:known,192.168.1.10,host1,12h", true)]
[InlineData("ignore", false)]
[InlineData("00:20:e0:3b:13:af,,host1", false)]
public void ValidateMultiItem_DhcpHost_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHost, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("3,192.168.4.4", true)]
[InlineData("option:router,192.168.4.4", true)]
[InlineData("vendor:PXEClient,1,0.0.0.0", true)]
[InlineData("encap:175,190,iscsi-client0", true)]
[InlineData("option:", false)]
[InlineData("vendor:,", false)]
public void ValidateMultiItem_DhcpOption_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpOption, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("set:efi-ia32,option:client-arch,6", true)]
[InlineData("set:known,93", true)]
[InlineData("option:client-arch,6", false)]
[InlineData("set:,option:client-arch,6", false)]
public void ValidateMultiItem_DhcpMatch_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpMatch, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("set:3com,01:34:23:*:*:*", true)]
[InlineData("set:vendor,aa:bb:cc:dd:ee:ff", true)]
[InlineData("01:34:23:*:*:*", false)]
[InlineData("set:,01:34:23:*:*:*", false)]
public void ValidateMultiItem_DhcpMac_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpMac, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("set:tag,hostname*", true)]
[InlineData("set:tag,hostname", true)]
[InlineData("set:tag,*host*", false)]
[InlineData("hostname*", false)]
public void ValidateMultiItem_DhcpNameMatch_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpNameMatch, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("", true)]
[InlineData("tag:guest", true)]
[InlineData("tag:guest,tag:lab", true)]
[InlineData("guest", false)]
public void ValidateMultiItem_DhcpIgnoreNames_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpIgnoreNames, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("tag:blocked", true)]
[InlineData("tag:blocked,tag:!known", true)]
[InlineData("blocked", false)]
public void ValidateMultiItem_DhcpIgnore_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpIgnore, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("set:printers,Hewlett-Packard JetDirect", true)]
[InlineData("printers,enterprise:32473,VendorClass", true)]
[InlineData("set:printers,enterprise:notnum,VendorClass", false)]
public void ValidateMultiItem_DhcpVendorclass_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpVendorclass, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("set:userclass,ExampleClient", true)]
[InlineData("userclass,ExampleClient", true)]
[InlineData("set:,ExampleClient", false)]
public void ValidateMultiItem_DhcpUserclass_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpUserclass, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("set:ppp,tag:ppp*", true)]
[InlineData("set:guest,tag:!known", true)]
[InlineData("tag:known", false)]
public void ValidateMultiItem_TagIf_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.TagIf, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("192.168.1.1,192.168.2.1", true)]
[InlineData("192.168.1.1,192.168.2.1#1067,eth1", true)]
[InlineData("192.168.1.1,eth1", true)]
[InlineData("not-an-ip,192.168.2.1", false)]
[InlineData("192.168.1.1,server.example.com", false)]
public void ValidateMultiItem_DhcpRelay_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRelay, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("192.168.1.1", true)]
[InlineData("192.168.1.1,192.168.1.2", true)]
[InlineData("not-an-ip", false)]
public void ValidateMultiItem_DhcpProxy_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpProxy, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("br0,eth0", true)]
[InlineData("br0,tap*", true)]
[InlineData("br0", false)]
public void ValidateMultiItem_BridgeInterface_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.BridgeInterface, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("pxelinux.0", true)]
[InlineData("tag:pxe,pxelinux.0,,192.168.1.2", true)]
[InlineData("tag:,pxelinux.0", false)]
[InlineData("", false)]
public void ValidateMultiItem_DhcpBoot_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpBoot, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("sharednet,192.168.10.0,255.255.255.0", true)]
[InlineData("eth0,192.168.10.1", true)]
[InlineData("sharednet", false)]
public void ValidateMultiItem_SharedNetwork_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.SharedNetwork, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("eth0,::10", true)]
[InlineData("slaac", true)]
[InlineData("ra-names,eth0", true)]
[InlineData("bad value", false)]
public void ValidateMultiItem_Slaac_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Slaac, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("eth0,60", true)]
[InlineData("eth0,mtu:1280,low,60,1200", true)]
[InlineData("eth0,high", true)]
[InlineData("eth*,60", true)]
[InlineData("eth*0,60", false)]
[InlineData("*eth0,60", false)]
[InlineData(",60", false)]
[InlineData("eth0,mtu:", false)]
public void ValidateMultiItem_RaParam_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.RaParam, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("x86PC,\"PXE Boot\",pxelinux", true)]
[InlineData("tag:pxe,x86PC,\"PXE Boot\",pxelinux,192.168.1.2", true)]
[InlineData("x86PC", false)]
[InlineData("bad-csa,\"PXE Boot\",pxelinux", false)]
public void ValidateMultiItem_PxeService_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.PxeService, value, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("vendor:PXEClient,1,0.0.0.0", true)]
[InlineData("encap:175,190,iscsi-client0", true)]
[InlineData("option:router,192.168.1.1", false)]
[InlineData("vendor:,1,0.0.0.0", false)]
public void ValidateMultiItem_DhcpOptionPxe_UsesHandler(string value, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DhcpOptionPxe, value, semantics);
Assert.Equal(valid, err is null);
}
[Fact]
public void ValidateSingle_UseStaleCache_UsesEngineRule()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.UseStaleCache, "", semantics));
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.UseStaleCache, "0", semantics));
Assert.NotNull(_validator.ValidateSingle(DnsmasqConfKeys.UseStaleCache, "x", semantics));
}
[Fact]
public void ValidateSingle_AddMac_UsesEngineRule()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.AddMac, "", semantics));
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.AddMac, "base64", semantics));
Assert.NotNull(_validator.ValidateSingle(DnsmasqConfKeys.AddMac, "bogus", semantics));
}
[Theory]
[InlineData("")]
[InlineData("24,96")]
[InlineData("0/0")]
public void ValidateSingle_AddSubnet_RemainsIntentionallyPermissive(string input)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.AddSubnet, input, semantics));
}
[Theory]
[InlineData("")]
[InlineData("org-id,asset-id")]
[InlineData("device-123")]
public void ValidateSingle_Umbrella_RemainsIntentionallyPermissive(string input)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
Assert.Null(_validator.ValidateSingle(DnsmasqConfKeys.Umbrella, input, semantics));
}
[Theory]
[InlineData("", true)]
[InlineData("0xff", true)]
[InlineData("255", true)]
[InlineData("0xZZ", false)]
[InlineData("-1", false)]
public void ValidateSingle_ConnmarkAllowlistEnable_UsesEngineRule(string input, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
var err = _validator.ValidateSingle(DnsmasqConfKeys.ConnmarkAllowlistEnable, input, semantics);
Assert.Equal(valid, err is null);
}
[Theory]
[InlineData("", true)]
[InlineData("no", true)]
[InlineData("yes", false)]
public void ValidateSingle_DnssecCheckUnsigned_UsesEngineRule(string input, bool valid)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.KeyOnlyOrValue, allowEmpty: true);
var err = _validator.ValidateSingle(DnsmasqConfKeys.DnssecCheckUnsigned, input, semantics);
Assert.Equal(valid, err is null);
}
[Fact]
public void ValidateSingle_IntKind_AcceptsIntOrNumericString()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Int);
Assert.Null(_validator.ValidateSingle("option", 42, semantics));
Assert.Null(_validator.ValidateSingle("option", "99", semantics));
Assert.NotNull(_validator.ValidateSingle("option", "abc", semantics));
}
[Fact]
public void OptionValidationSemantics_PathPolicy_OnNonPathKind_Throws()
{
Assert.Throws<ArgumentException>(() =>
new OptionValidationSemantics(
OptionValidationKind.Int,
pathPolicy: PathExistencePolicy.MustExist));
}
[Fact]
public void ValidateMultiItem_PathFile_RejectsDirectoryPath()
{
var validator = _validator;
var semantics = new OptionValidationSemantics(
OptionValidationKind.PathFile,
allowEmpty: true,
pathPolicy: PathExistencePolicy.MustExist);
var dir = Directory.CreateTempSubdirectory("dnsmasq-webui-pathfile-");
try
{
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsfile, dir.FullName, semantics);
Assert.Equal("File does not exist.", err);
}
finally
{
dir.Delete(recursive: true);
}
}
[Fact]
public void ValidateMultiItem_PathDirectory_RejectsFilePath()
{
var validator = _validator;
var semantics = new OptionValidationSemantics(
OptionValidationKind.PathDirectory,
allowEmpty: true,
pathPolicy: PathExistencePolicy.MustExist);
var filePath = Path.Combine(Path.GetTempPath(), $"dnsmasq-webui-pathdir-{Guid.NewGuid():N}.txt");
File.WriteAllText(filePath, "test");
try
{
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsdir, filePath, semantics);
Assert.Equal("Directory does not exist.", err);
}
finally
{
File.Delete(filePath);
}
}
[Fact]
public void ValidateMultiItem_PathFile_AcceptsExistingFile()
{
var validator = _validator;
var semantics = new OptionValidationSemantics(
OptionValidationKind.PathFile,
allowEmpty: true,
pathPolicy: PathExistencePolicy.MustExist);
var filePath = Path.Combine(Path.GetTempPath(), $"dnsmasq-webui-pathfile-{Guid.NewGuid():N}.txt");
File.WriteAllText(filePath, "test");
try
{
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsfile, filePath, semantics);
Assert.Null(err);
}
finally
{
File.Delete(filePath);
}
}
[Fact]
public void ValidateMultiItem_PathDirectory_AcceptsExistingDirectory()
{
var validator = _validator;
var semantics = new OptionValidationSemantics(
OptionValidationKind.PathDirectory,
allowEmpty: true,
pathPolicy: PathExistencePolicy.MustExist);
var dir = Directory.CreateTempSubdirectory("dnsmasq-webui-pathdir-");
try
{
var err = validator.ValidateMultiItem(DnsmasqConfKeys.DhcpHostsdir, dir.FullName, semantics);
Assert.Null(err);
}
finally
{
dir.Delete(recursive: true);
}
}
[Fact]
public void ValidateMultiItem_DhcpCircuitid_RequiresSetTagValue()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "set:uplink,aa:bb:cc", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "set:mytag,simple-string", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "tag:uplink,id", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpCircuitid, "set:only", semantics));
}
[Fact]
public void ValidateMultiItem_DhcpRemoteid_RequiresSetTagValue()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRemoteid, "set:uplink,remote-id", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpRemoteid, "set:tag,", semantics));
}
[Fact]
public void ValidateMultiItem_DhcpSubscrid_RequiresSetTagValue()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpSubscrid, "set:sub,subscriber-id", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DhcpSubscrid, "bad", semantics));
}
[Fact]
public void ValidateMultiItem_FilterRr_AcceptsRrTypes()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "A", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "A,AAAA,TXT,MX", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "ANY", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "255", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.FilterRr, "A,,B", semantics));
}
[Fact]
public void ValidateMultiItem_CacheRr_AcceptsRrTypes()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "TXT", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "A,AAAA,CNAME,SRV", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "ANY", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CacheRr, "", semantics));
}
[Fact]
public void ValidateMultiItem_InterfaceNameOptions_AcceptInterfaceNameWithOptionalTrailingWildcard()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
foreach (var key in new[] { DnsmasqConfKeys.Interface, DnsmasqConfKeys.ExceptInterface, DnsmasqConfKeys.NoDhcpInterface, DnsmasqConfKeys.NoDhcpv4Interface, DnsmasqConfKeys.NoDhcpv6Interface })
{
Assert.Null(_validator.ValidateMultiItem(key, "eth0", semantics));
Assert.Null(_validator.ValidateMultiItem(key, "eth0*", semantics));
Assert.Null(_validator.ValidateMultiItem(key, "br-lan", semantics));
Assert.NotNull(_validator.ValidateMultiItem(key, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(key, "eth*0", semantics));
}
}
[Fact]
public void ValidateMultiItem_AuthServer_RequiresDomain_AcceptsInterfaceOrIp()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com,eth0", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com,192.168.1.1,eth1/4", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "ns.example.com,::1,eth0/6", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, ",eth0", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, "example.com,invalid@", semantics));
}
[Fact]
public void ValidateMultiItem_Cname_RequiresCnameAndTarget_AcceptsOptionalTtl()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "router.example.local,router", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "cname1,cname2,target", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "alias,host.example.com,60", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "only", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Cname, "bad..name,target", semantics));
}
[Fact]
public void ValidateMultiItem_MxHost_AcceptsMxNameWithOptionalHostnameAndPreference()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "example.local", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "example.local,mail.example.local,10", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "mx.local,5", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "bad..name", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.MxHost, "mx.local,mail,99999", semantics));
}
[Fact]
public void ValidateMultiItem_PtrRecord_NameAndOptionalTarget()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "1.0.168.192.in-addr.arpa,host.example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "name.only", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.PtrRecord, "bad..name,target", semantics));
}
[Fact]
public void ValidateMultiItem_InterfaceNameRecord_NameAndInterface()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "router.example.local,eth0", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "host.local,eth1/4", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "name", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.InterfaceName, "name,", semantics));
}
[Fact]
public void ValidateMultiItem_CaaRecord_FourParts()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "example.com,0,issue,letsencrypt.org", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "a,b", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "x,y,z,w", semantics)); // 4 but invalid name possible - "x" is valid DNS name
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.CaaRecord, "x,256,tag,val", semantics));
}
[Fact]
public void ValidateMultiItem_SrvHost_ServiceNameAndOptionalTargetPortPriorityWeight()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "_sip._udp.example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "_sip._udp,sip.example.com,5060,0,0", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Srv, "_sip._udp,host,70000", semantics));
}
[Fact]
public void ValidateMultiItem_NaptrRecord_SixOrSevenParts()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "name.example.com,0,0,s,a,x,replacement", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "n.zone,1,10,flags,svc,regex", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "a,b,c", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, "bad..name,0,0,f,s,r", semantics));
}
[Fact]
public void ValidateMultiItem_DnsRr_NameAndRrNumber_OptionalHex()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "example.com,255", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "name.example,1,01:02:03", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "x", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, "x,99999", semantics));
}
[Fact]
public void ValidateMultiItem_DynamicHost_FourFields_NameAndInterface()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "example.com,0.0.0.8,eth0", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "example.com,,,eth0", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "host.local,0.0.0.8,,eth0", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "x,y", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.DynamicHost, "x,1.2.3.4,::1,bad if", semantics));
}
[Fact]
public void ValidateMultiItem_AuthSoa_SerialAndOptionalFields()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "1", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "1,hostmaster.example.com,3600,600,86400", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSoa, "x", semantics));
}
[Fact]
public void ValidateMultiItem_AuthSecServers_OneOrMoreDomains()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "ns1.example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "a.com,b.com", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthSecServers, "bad..name", semantics));
}
[Fact]
public void ValidateMultiItem_AuthPeer_OneOrMoreIps()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "192.168.1.1", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "::1,10.0.0.1", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthPeer, "not-an-ip", semantics));
}
[Fact]
public void ValidateMultiItem_HostRecord_AtLeastOneName_OptionalIpv4Ipv6Ttl()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "laptop,192.168.0.1", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "host.example.com,192.168.0.1,::1,60", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.HostRecord, "bad..name,1.2.3.4", semantics));
}
[Fact]
public void ValidateMultiItem_TxtRecord_NameRequired()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "name.example.com,some text", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, "bad..name,text", semantics));
}
[Fact]
public void ValidateMultiItem_Domain_DomainOrHash_OptionalRangeOrInterface()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "thekelleys.org.uk", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "#", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "lan,192.168.0.0/24,local", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "local.zone,eth0", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "bad..name", semantics));
}
[Fact]
public void ValidateMultiItem_SynthDomain_DomainAndRange_OptionalPrefix()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "zone.example.com,192.168.0.50,192.168.0.70", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "zone,192.168.0.0/24,internal-*", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "only", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "bad..name,1.2.3.4", semantics));
}
[Fact]
public void ValidateMultiItem_AuthZone_DomainRequired_OptionalSubnets()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex);
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "example.com", semantics));
Assert.Null(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "zone,192.168.0.0/24", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "bad..name", semantics));
}
}
+14 -10
View File
@@ -1,3 +1,4 @@
@using System.Reflection
@inject IOptions<ApplicationOptions> AppOptions
<!DOCTYPE html>
<html lang="en">
@@ -6,26 +7,29 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="DnsmasqWebUI.styles.css" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png" />
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css?v=@_cacheBust" />
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css?v=@_cacheBust" />
<link rel="stylesheet" href="app.css?v=@_cacheBust" />
<link rel="stylesheet" href="DnsmasqWebUI.styles.css?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png?v=@_cacheBust" />
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png?v=@_cacheBust" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png?v=@_cacheBust" />
<title>@_appTitle</title>
<HeadOutlet />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="js/copy-to-clipboard.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@code {
private static readonly string _cacheBust = typeof(App).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0";
private string? _appTitle => AppOptions?.Value?.EffectiveTitle;
}
@@ -1,4 +1,4 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs
@inject ILoggingClient LoggingClient
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@@ -16,7 +16,7 @@
<div class="input-group input-group-sm mb-2">
<input type="text" class="form-control" placeholder="e.g. Microsoft.AspNetCore."
@bind="_newPrefix" @bind:event="oninput" @onkeydown="OnNewPrefixKeydown" />
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)">Add</button>
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)"><i class="bi bi-plus me-1" aria-hidden="true"></i>Add</button>
</div>
<ul class="list-group list-group-flush" style="max-height: 12rem; overflow-y: auto;">
@foreach (var prefix in _prefixes)
@@ -35,10 +35,10 @@
</ul>
</div>
<div class="modal-footer d-flex justify-content-between align-items-center">
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving">Restore defaults</button>
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore defaults</button>
<div>
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@(_saving ? "Saving…" : "Save")</button>
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@if (_saving) { <span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> } else { <i class="bi bi-floppy-fill me-1" aria-hidden="true"></i> }@(_saving ? "Saving…" : "Save")</button>
</div>
</div>
</div>
@@ -1,9 +1,10 @@
using DnsmasqWebUI.Extensions.Interop;
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Components.Dnsmasq;
namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs;
public partial class AppLogsFiltersModal : IAsyncDisposable
{
@@ -53,7 +54,7 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
{
try
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
_dotNetRef = DotNetObjectReference.Create(this);
_moduleLoaded = true;
}
@@ -66,17 +67,17 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
{
if (!_dialogInitialized)
{
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
await _jsModule.InvokeVoidAsyncSafe("initDialog", _dialogRef, _dotNetRef);
_dialogInitialized = true;
}
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
await _jsModule.InvokeVoidAsyncSafe("showModal", _dialogRef);
}
}
private async Task Close()
{
if (_jsModule != null)
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef);
await OnClose.InvokeAsync();
}
@@ -145,10 +146,6 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
_cts.Cancel();
_cts.Dispose();
_dotNetRef?.Dispose();
if (_jsModule != null)
{
try { await _jsModule.DisposeAsync(); }
catch { /* ignore */ }
}
await _jsModule.DisposeAsyncSafe();
}
}
@@ -1,4 +1,4 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs
@* App logs — live view via SignalR from ILogger *@
@using DnsmasqWebUI.Models.Logs
@using Microsoft.AspNetCore.SignalR.Client
@@ -30,4 +30,8 @@
</span>
</p>
<AppLogsFiltersModal IsVisible="_filtersModalVisible" OnClose="CloseFiltersModal" />
<pre id="app-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
<LogsPreView Id="app-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
<a href="api/logging/logs/download" download="app.log" class="btn btn-primary btn-sm"><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</a>
</div>
@@ -1,11 +1,11 @@
using DnsmasqWebUI.Extensions.Interop;
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Logs;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Components.Dnsmasq;
namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs;
/// <summary>
/// App logs block: connects to LogsHub, receives AppLogsUpdate pushes, polls at interval as fallback.
@@ -18,6 +18,8 @@ public partial class AppLogsSection : IAsyncDisposable
[Inject] private ILoggingClient LoggingClient { get; set; } = null!;
private bool _justUpdated;
private bool _logsContentReceived;
private bool _refreshing;
private int _intervalSeconds;
private string _logLevel = "Information";
private bool _logLevelDisabled;
@@ -42,16 +44,15 @@ public partial class AppLogsSection : IAsyncDisposable
protected override void OnParametersSet()
{
var next = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
if (RefreshIntervalSeconds == _intervalSeconds) return;
_intervalSeconds = RefreshIntervalSeconds;
RestartPollTimer();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_intervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
_intervalSeconds = RefreshIntervalSeconds;
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
_hubConnection = new HubConnectionBuilder()
@@ -91,6 +92,25 @@ public partial class AppLogsSection : IAsyncDisposable
private void OpenFiltersModal() => _filtersModalVisible = true;
private void CloseFiltersModal() => _filtersModalVisible = false;
private async Task RequestRefreshAsync()
{
if (_hubConnection?.State != HubConnectionState.Connected)
return;
_refreshing = true;
StateHasChanged();
try
{
await _hubConnection.InvokeAsync("RequestAppLogsSnapshot", _cts.Token);
}
catch (OperationCanceledException) { }
catch (Exception) { /* ignore */ }
finally
{
_refreshing = false;
StateHasChanged();
}
}
private async Task OnLogLevelChanged()
{
if (string.IsNullOrEmpty(_logLevel)) return;
@@ -138,18 +158,13 @@ public partial class AppLogsSection : IAsyncDisposable
await InvokeAsync(async () =>
{
if (_logsJs == null) return;
try
{
if (payload.Mode == "replace")
await _logsJs.InvokeVoidAsync("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
else
await _logsJs.InvokeVoidAsync("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
SetJustUpdated();
StateHasChanged();
}
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
catch (JSException) { /* JS error; ignore */ }
if (payload.Mode == "replace")
await _logsJs.InvokeVoidAsyncSafe("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
else
await _logsJs.InvokeVoidAsyncSafe("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
_logsContentReceived = true;
SetJustUpdated();
StateHasChanged();
});
}
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
@@ -183,13 +198,7 @@ public partial class AppLogsSection : IAsyncDisposable
await _hubConnection.DisposeAsync();
_hubConnection = null;
}
if (_logsJs != null)
{
try { await _logsJs.DisposeAsync(); }
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (prerender)"); }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (circuit disconnected)"); }
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync failed"); }
_logsJs = null;
}
await _logsJs.DisposeAsyncSafe();
_logsJs = null;
}
}
@@ -0,0 +1,74 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<div class="config-file-list">
@{
var configFileList = Status.ConfigFiles?.Count > 0 ? Status.ConfigFiles : ConfigSet?.Files;
var hasList = configFileList != null && configFileList.Count > 0;
var mainEntry = hasList ? configFileList![0] : null;
var hasMoreThanOne = hasList && configFileList!.Count > 1;
var fileCount = hasList ? configFileList!.Count : 2;
var showToggle = hasMoreThanOne || (!hasList && Status.ManagedFilePath != null);
}
<div class="config-file-list-header">
<p class="config-file-list-title mb-0">Config files (load order)</p>
@if (showToggle)
{
<button type="button"
class="config-file-list-toggle"
@onclick="ToggleExpanded"
aria-expanded="@_expanded"
aria-label="@(_expanded ? "Show only main config" : "Show all config files")">
<i class="bi @(_expanded ? "bi-chevron-up" : "bi-chevron-down")" aria-hidden="true"></i>
<span>@(_expanded ? "Show less" : $"Show all ({fileCount} files)")</span>
</button>
}
</div>
<div class="config-file-list-content-row">
<div class="config-file-list-list">
<ul class="list-unstyled config-file-list-main">
@if (hasList)
{
<ConfigFileListItem Entry="@mainEntry" Status="Status" />
}
else
{
<li><strong>Main config:</strong> @(Status.MainConfigPath ?? "—") @(Status.MainConfigPathExists ? "✓" : "(missing)")</li>
}
</ul>
@if (showToggle)
{
<div class="config-file-list-expandable @(_expanded ? "expanded" : null)">
<div class="config-file-list-expandable-inner">
<ul class="list-unstyled">
@if (hasList)
{
@foreach (var f in configFileList!.Skip(1))
{
<ConfigFileListItem Entry="f" Status="Status" />
}
}
else
{
<li><strong>Managed file:</strong> @(Status.ManagedFilePath ?? "—") @(Status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
}
</ul>
</div>
</div>
}
</div>
</div>
</div>
@code {
[Parameter, EditorRequired]
public DnsmasqServiceStatus Status { get; set; } = null!;
[Parameter]
public DnsmasqConfigSet? ConfigSet { get; set; }
private bool _expanded;
private void ToggleExpanded() => _expanded = !_expanded;
}
@@ -0,0 +1,106 @@
/* Config file list: card-style block with expandable list */
.config-file-list {
--cfl-radius: var(--app-radius, 0.375rem);
--cfl-spacing: var(--app-spacing-md, 1rem);
--cfl-border: 1px solid #e9ecef;
--cfl-bg: #f8f9fa;
--cfl-bg-hover: #e9ecef;
}
.config-file-list {
border-radius: var(--cfl-radius);
border: var(--cfl-border);
background: var(--cfl-bg);
padding: var(--cfl-spacing);
margin-top: 0.5rem;
}
.config-file-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.config-file-list .config-file-list-title {
font-size: 0.9375rem;
font-weight: 600;
color: #212529;
margin-bottom: 0;
}
.config-file-list ul {
margin: 0;
padding-left: 1.25rem;
font-size: 0.875rem;
line-height: 1.6;
color: #495057;
}
.config-file-list ul li {
padding: 0.2rem 0;
}
/* List content row (toggle is in header now) */
.config-file-list-content-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.config-file-list-list {
flex: 1;
min-width: 0;
}
.config-file-list .config-file-list-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex-shrink: 0;
padding: 0.35rem 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
color: #0d6efd;
background: transparent;
border: none;
border-radius: var(--cfl-radius);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.config-file-list .config-file-list-toggle:hover {
background: var(--cfl-bg-hover);
color: #0a58ca;
}
.config-file-list .config-file-list-toggle:focus-visible {
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
.config-file-list .config-file-list-toggle .bi {
font-size: 0.875rem;
transition: transform 0.2s ease;
}
/* Expandable section: animate height with grid 0fr → 1fr */
.config-file-list-expandable {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease-out;
}
.config-file-list-expandable.expanded {
grid-template-rows: 1fr;
}
.config-file-list-expandable-inner {
min-height: 0;
overflow: hidden;
}
.config-file-list-expandable-inner ul {
padding-top: 0.15rem;
}
@@ -0,0 +1,30 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<li>
@if (Entry.IsManaged)
{
<StatusBadge Kind="StatusBadgeKind.Managed" Text="managed" Class="me-1" />
}
else
{
<StatusBadge Kind="StatusBadgeKind.SourceMain" Text="@(Entry.Source.ToString().ToLowerInvariant())" Class="me-1" />
}
@Entry.Path
@{
var isMain = string.Equals(Entry.Path, Status.MainConfigPath, StringComparison.Ordinal);
var isManaged = Entry.IsManaged;
var exists = isMain ? Status.MainConfigPathExists : (isManaged ? Status.ManagedFilePathExists : (bool?)null);
}
@if (exists == true) { <text> ✓</text> }
@if (exists == false) { <text> @(isManaged ? "(not created yet)" : "(missing)")</text> }
</li>
@code {
[Parameter, EditorRequired]
public DnsmasqConfigSetEntry Entry { get; set; } = null!;
[Parameter, EditorRequired]
public DnsmasqServiceStatus Status { get; set; } = null!;
}
@@ -1,24 +0,0 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@* Reusable value-only display for boolean/flag options. Edit mode: checkbox (unless readonly); View: "Enabled" / "Disabled". *@
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
{
var isSet = Descriptor.GetValue() is bool b && b;
<input type="checkbox" class="form-check-input" checked="@isSet" @onchange="OnCheckedChange" />
}
else
{
@(Descriptor.GetValue() is bool isSet && isSet ? "Enabled" : "Disabled")
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<bool> CheckedChanged { get; set; }
private async Task OnCheckedChange(ChangeEventArgs e)
{
await CheckedChanged.InvokeAsync(e.Value is true);
}
}
@@ -1,27 +0,0 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@* Value-only display for the port option. Edit mode: number input (unless readonly); View: text with "(default DNS port)". *@
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
{
var port = Descriptor.GetValue() is int p ? p : 53;
<input type="number" class="form-control form-control-sm" style="max-width: 6rem;" min="1" max="65535" value="@port" @oninput="OnPortInput" />
}
else
{
@(Descriptor.GetValue() is int port ? $"{port} (default DNS port)" : "(not set)")
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<int?> PortChanged { get; set; }
private async Task OnPortInput(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535)
await PortChanged.InvokeAsync(port);
else
await PortChanged.InvokeAsync(null);
}
}
@@ -1,44 +0,0 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Config
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. *@
@if (_customFragment is { } fragment)
{
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<span class="ec-field-label" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></span>
<span class="ec-field-value">@fragment(Descriptor)</span>
@if (Descriptor.GetSource()?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
</li>
}
else if (Descriptor.IsMultiValue)
{
var items = Descriptor.GetItems();
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" />
}
else
{
var value = Descriptor.GetValue();
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" />
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
private string? _labelTooltip;
protected override void OnParametersSet()
{
_customFragment = RenderFragmentRegistry.GetDisplayFragment(Descriptor.SectionId, Descriptor.OptionName);
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
}
}
@@ -1,15 +0,0 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
@Value
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
</span>
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
}
@@ -1,56 +0,0 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
<li class="ec-field">
<span class="ec-field-label" title="@LabelTooltip"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit)
{
<span class="ec-field-value ec-field-value-multi">
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea>
</span>
}
else
{
<span class="ec-field-value ec-field-value-multi">
@if (Items?.Count > 0)
{
<ol class="ec-field-value-list">
@foreach (var item in Items!)
{
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" /></li>
}
</ol>
}
else
{
<span class="text-muted">(none)</span>
}
</span>
}
</li>
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
[Parameter] public string? LabelTooltip { get; set; }
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
private string _editLines = "";
protected override void OnParametersSet()
{
if (Items != null && Items.Count > 0)
_editLines = string.Join("\n", Items.Select(i => i.Value));
else
_editLines = "";
}
private async Task OnLinesInput(ChangeEventArgs e)
{
var text = e.Value?.ToString() ?? "";
_editLines = text;
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
await ItemsChanged.InvokeAsync(lines);
}
}
@@ -1,37 +0,0 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
<li class="ec-field" title="@(Source?.GetReadOnlyTooltip())">
<span class="ec-field-label" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Source?.IsReadOnly != true)
{
<span class="ec-field-value">
<input type="text" class="form-control form-control-sm" value="@DisplayValue" @oninput="OnValueInput" />
</span>
}
else
{
<span class="ec-field-value">@DisplayValue</span>
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
}
</li>
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public string DisplayValue { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
[Parameter] public string? LabelTooltip { get; set; }
/// <summary>When Edit and not readonly: raised when the user changes the value. Not yet wired to save.</summary>
[Parameter] public EventCallback<string> DisplayValueChanged { get; set; }
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
private async Task OnValueInput(ChangeEventArgs e)
{
var value = e.Value?.ToString() ?? "";
await DisplayValueChanged.InvokeAsync(value);
}
}
@@ -1,45 +0,0 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@if (Status != null)
{
<div class="ec-sections small">
@{
var descriptors = EffectiveConfigFieldBuilder.BuildFieldDescriptors(Status);
var sections = new[]
{
(EffectiveConfigFieldBuilder.SectionHosts, "Hosts"),
(EffectiveConfigFieldBuilder.SectionResolver, "Resolver / DNS"),
(EffectiveConfigFieldBuilder.SectionDhcp, "DHCP"),
(EffectiveConfigFieldBuilder.SectionCache, "Cache"),
(EffectiveConfigFieldBuilder.SectionProcess, "Process & networking")
};
}
@foreach (var (sectionId, title) in sections)
{
var sectionDescriptors = descriptors.Where(d => d.SectionId == sectionId).ToList();
if (sectionDescriptors.Count > 0)
{
<EffectiveConfigSectionPanel SectionId="@sectionId" Title="@title" Descriptors="@sectionDescriptors" IsOpen="@IsOpen(sectionId)" OnToggle="@OnSectionToggle" DisplayMode="@DisplayMode" />
}
}
</div>
}
@code {
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
/// <summary>View = read-only; Edit = editable (future). Cascaded to field displays.</summary>
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
private HashSet<string> _openPanels = new();
private bool IsOpen(string id) => _openPanels.Contains(id);
private void OnSectionToggle(string id)
{
if (_openPanels.Contains(id))
_openPanels.Remove(id);
else
_openPanels.Add(id);
}
}
@@ -1,162 +0,0 @@
/* Effective config sections – mobile-first, touch-friendly.
Use ::deep so styles apply to EffectiveConfigSectionPanel (child component) markup. */
.ec-sections {
display: flex;
flex-direction: column;
gap: 0;
}
::deep .ec-section {
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
}
::deep .ec-section:last-child {
border-bottom: none;
}
/* Touch-friendly header: min 44px tap target (WCAG 2.5.5), breathing room from edges */
::deep .ec-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 2.75rem;
padding: 0.625rem 1rem;
margin: 0;
border: none;
border-radius: 0.25rem;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
font-size: inherit;
line-height: 1.3;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.15s ease, color 0.15s ease;
}
::deep .ec-section-header:hover {
color: var(--bs-primary, #0d6efd);
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
}
::deep .ec-section-header:focus-visible {
outline: 2px solid var(--bs-primary, #0d6efd);
outline-offset: 2px;
}
/* Open state: header reads as "active" and clearly separates from body */
::deep .ec-section-open .ec-section-header {
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.05));
color: var(--bs-emphasis-color, inherit);
font-weight: 600;
}
::deep .ec-section-open .ec-section-header:hover {
background-color: var(--bs-tertiary-bg, rgba(0, 0, 0, 0.08));
color: var(--bs-primary, #0d6efd);
}
::deep .ec-section-title {
flex: 1;
min-width: 0;
}
::deep .ec-section-chevron {
flex-shrink: 0;
font-size: 0.8rem;
opacity: 0.8;
margin-left: 0.35rem;
transition: opacity 0.2s ease;
}
::deep .ec-section-open .ec-section-chevron {
opacity: 1;
}
::deep .ec-section-body {
padding: 0.25rem 1rem 0.5rem 1rem;
padding-left: 1rem;
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
margin-left: 0;
overflow-wrap: break-word;
word-break: break-word;
}
/* Long paths/values wrap on small screens; badges stay inline */
::deep .ec-section-body ul {
overflow-wrap: break-word;
word-break: break-word;
}
::deep .ec-section-body li {
overflow-wrap: break-word;
word-break: break-word;
}
/* Field row: label + value + optional badge. Same structure for custom and default displays. */
::deep .ec-field {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.5rem;
margin-bottom: 0.35rem;
}
::deep .ec-field:last-child {
margin-bottom: 0;
}
::deep .ec-field-label {
flex-shrink: 0;
min-width: 0;
}
::deep .ec-field-value {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
}
/* Multi-value: full width below label, indented ordered list */
::deep .ec-field-value-multi {
flex-basis: 100%;
padding-left: 1.25rem;
margin-top: 0.15rem;
}
::deep .ec-field-value-list {
margin: 0;
padding-left: 1.25rem;
list-style-type: decimal;
}
::deep .ec-field-value-list li {
margin-bottom: 0.2rem;
overflow-wrap: break-word;
word-break: break-word;
}
::deep .ec-field-value-list li:last-child {
margin-bottom: 0;
}
::deep .ec-field-value-item {
display: inline;
}
::deep .ec-field-value-sep {
margin-right: 0.25rem;
}
@media (max-width: 767.98px) {
::deep .ec-section-header {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
::deep .ec-section-body {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
}
@@ -0,0 +1,11 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.Logs
@* Renders a pre element for logs. Once Freeze is true, stops re-rendering so Blazor does not overwrite JS-owned content. *@
<pre id="@Id" class="pre-block mb-0" style="max-height: 24rem;">@Placeholder</pre>
@code {
[Parameter] public string Id { get; set; } = "";
[Parameter] public string Placeholder { get; set; } = "Connecting…";
[Parameter] public bool Freeze { get; set; }
protected override bool ShouldRender() => !Freeze;
}
@@ -1,4 +1,4 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@namespace DnsmasqWebUI.Components.Dnsmasq.Logs
@* Recent logs — live view via SignalR from LogsCommand; client polls at interval, server pushes deltas *@
@using DnsmasqWebUI.Models.Logs
@using Microsoft.AspNetCore.SignalR.Client
@@ -24,17 +24,17 @@
}
@if (_status.LogsConfigured)
{
<pre id="dnsmasq-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
<LogsPreView Id="dnsmasq-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
}
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing">Refresh</button>
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
@if (_status.LogsPath != null)
{
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm"><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</a>
}
else
{
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download.">Download logs</button>
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download."><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</button>
}
</div>
}
@@ -1,11 +1,11 @@
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Extensions.Interop;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Logs;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Components.Dnsmasq;
namespace DnsmasqWebUI.Components.Dnsmasq.Logs;
/// <summary>
/// Recent logs block: connects to LogsHub, polls at interval via RequestDnsmasqLogs,
@@ -18,6 +18,7 @@ public partial class LogsSection : IAsyncDisposable
private DnsmasqServiceStatus? _status;
private bool _refreshing;
private bool _justUpdated;
private bool _logsContentReceived;
private int _intervalSeconds;
private HubConnection? _hubConnection;
private IJSObjectReference? _logsJs;
@@ -42,16 +43,15 @@ public partial class LogsSection : IAsyncDisposable
protected override void OnParametersSet()
{
_status = Status;
var next = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
if (RefreshIntervalSeconds == _intervalSeconds) return;
_intervalSeconds = RefreshIntervalSeconds;
RestartPollTimer();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_intervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
_intervalSeconds = RefreshIntervalSeconds;
_status = Status;
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
@@ -124,18 +124,13 @@ public partial class LogsSection : IAsyncDisposable
await InvokeAsync(async () =>
{
if (_logsJs == null) return;
try
{
if (payload.Mode == "replace")
await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions);
else
await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions);
SetJustUpdated();
StateHasChanged();
}
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
catch (JSException) { /* JS error; ignore */ }
if (payload.Mode == "replace")
await _logsJs.InvokeVoidAsyncSafe("replaceLogs", LogsPreId, payload.Content, LogsOptions);
else
await _logsJs.InvokeVoidAsyncSafe("appendLogs", LogsPreId, payload.Content, LogsOptions);
_logsContentReceived = true;
SetJustUpdated();
StateHasChanged();
});
}
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
@@ -169,13 +164,7 @@ public partial class LogsSection : IAsyncDisposable
await _hubConnection.DisposeAsync();
_hubConnection = null;
}
if (_logsJs != null)
{
try { await _logsJs.DisposeAsync(); }
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (prerender)"); }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (circuit disconnected)"); }
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync failed"); }
_logsJs = null;
}
await _logsJs.DisposeAsyncSafe();
_logsJs = null;
}
}
@@ -1,4 +1,4 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@namespace DnsmasqWebUI.Components.Dnsmasq.Status
@* Service status — output of StatusShowCommand; only this component re-renders on its timer *@
@if (_status != null && _status.StatusShowConfigured)
{
@@ -24,7 +24,21 @@
{
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
}
<div class="mt-2">
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
@if (ReloadCommandConfigured && OnReloadRequested.HasDelegate)
{
<button class="btn btn-primary btn-sm" @onclick="InvokeReloadAsync" disabled="@IsReloading" title="Reload dnsmasq config (e.g. SIGHUP)">
@if (IsReloading)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
}
else
{
<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>
}
Reload
</button>
}
</div>
}
@@ -1,9 +1,8 @@
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Dnsmasq;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Dnsmasq;
namespace DnsmasqWebUI.Components.Dnsmasq.Status;
/// <summary>
/// Service status block: polls StatusShowCommand output at its own interval and re-renders only itself.
@@ -20,13 +19,18 @@ public partial class StatusSection : IDisposable
[Parameter] public bool IsReloading { get; set; }
/// <summary>When set and reload is configured, show a "Reload config" button next to Refresh.</summary>
[Parameter] public EventCallback OnReloadRequested { get; set; }
[Parameter] public bool ReloadCommandConfigured { get; set; }
[Parameter] public EventCallback OnOpenSettings { get; set; }
[Inject] private IStatusClient StatusClient { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
_intervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
_intervalSeconds = RefreshIntervalSeconds;
await RefreshAsync();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
@@ -37,9 +41,8 @@ public partial class StatusSection : IDisposable
protected override void OnParametersSet()
{
var next = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
if (RefreshIntervalSeconds == _intervalSeconds) return;
_intervalSeconds = RefreshIntervalSeconds;
_timer?.Dispose();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
@@ -57,6 +60,8 @@ public partial class StatusSection : IDisposable
private static readonly TimeSpan MinUpdatingDuration = TimeSpan.FromSeconds(1);
private Task InvokeReloadAsync() => OnReloadRequested.InvokeAsync();
private async Task RefreshAsync()
{
var started = DateTime.UtcNow;
@@ -0,0 +1,27 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@inject IJSRuntime JSRuntime
<div class="d-flex align-items-center gap-1 @CssClass">
<code class="flex-grow-1 text-break @CodeCssClass">@Text</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyAsync" title="@CopyTitle"><i class="bi bi-clipboard me-1" aria-hidden="true"></i>Copy</button>
</div>
@code {
[Parameter] public string Text { get; set; } = "";
[Parameter] public string? CssClass { get; set; }
[Parameter] public string? CodeCssClass { get; set; }
[Parameter] public string CopyTitle { get; set; } = "Copy";
private async Task CopyAsync()
{
if (string.IsNullOrEmpty(Text)) return;
try
{
await JSRuntime.InvokeVoidAsync("copyToClipboard", Text);
}
catch
{
// Clipboard not available or failed
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
namespace DnsmasqWebUI.Components.EffectiveConfig;
/// <summary>
/// Base for effective-config custom value displays. Provides shared parameters,
/// <see cref="NotifyValueChanged"/>, and <see cref="GetEffectiveDisplayValue"/> so pending changes are shown before save.
/// </summary>
public abstract class EffectiveConfigValueDisplayBase : ComponentBase
{
[Parameter]
public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter]
public bool IsActiveEditor { get; set; }
[CascadingParameter]
public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
[Parameter]
public EventCallback<object?> ValueChanged { get; set; }
[CascadingParameter]
public IEffectiveConfigEditSession? Session { get; set; }
protected Task NotifyValueChanged(object? value) => ValueChanged.InvokeAsync(value);
/// <summary>Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk).</summary>
protected object? GetEffectiveDisplayValue()
{
var pending = Session?.PendingChanges.FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
return pending != null ? pending.NewValue : Descriptor.GetValue();
}
}
@@ -0,0 +1,47 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
<div class="ec-sections small">
@{
var views = EffectiveConfigViews.GetViewsForContext(Context);
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry);
var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm);
var term = SearchTerm?.Trim() ?? "";
}
@foreach (var view in views)
{
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
continue;
var sectionDescriptors = hasSearch
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
: contextDescriptors.ToList();
if (sectionDescriptors.Count == 0)
continue;
var isOpen = hasSearch || IsSectionOpen(view.SectionId);
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle"
DisplayMode="@( IsEditMode ? EffectiveConfigDisplayMode.Edit : EffectiveConfigDisplayMode.View )"
IsEditMode="@IsEditMode" ActiveFieldKey="@ActiveFieldKey" />
}
</div>
@code {
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
[Parameter] public string SearchTerm { get; set; } = "";
[Parameter] public HashSet<string> OpenSectionIds { get; set; } = new();
[Parameter] public EventCallback<string> OnSectionToggle { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ActiveFieldKey { get; set; }
private bool IsSectionOpen(string id) => OpenSectionIds.Contains(id);
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
{
if (string.IsNullOrEmpty(term)) return true;
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,233 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@inject IEffectiveConfigEditSession Session
@inject IJSRuntime JSRuntime
@if (Status != null)
{
<div class="ec-container" @onclick="DeactivateActiveFieldIfClicked">
<OptionHelpController @ref="_optionHelpControllerRef" Visible="@_showOptionHelp" HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Title="@_optionHelpTitle" OnCloseRequested="CloseOptionHelp" />
<ReadonlyPopoverController @ref="_readonlyPopoverControllerRef" Visible="@_showReadonlyPopover" AnchorId="@_readonlyPopoverAnchorId" Source="@_readonlyPopoverSource" OptionName="@(_readonlyPopoverOptionName ?? "")" Value="@_readonlyPopoverValue" ManagedFilePath="@(Status?.ManagedFilePath)" OnCloseRequested="CloseReadonlyPopover" />
@{
var _sectionViews = EffectiveConfigViews.GetViewsForContext(Context);
var _allSectionsExpanded = _sectionViews.Count > 0 && _sectionViews.All(v => _openPanels.Contains(v.SectionId));
}
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
ValidationSummary="@Session.GetValidationSummary()"
AllSectionsExpanded="@_allSectionsExpanded"
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
<CascadingValue Value="@Session">
<CascadingValue Value="@_uiContext">
<EffectiveConfigPanels Status="@Status" Context="@Context" SearchTerm="@_searchTerm" OpenSectionIds="@_openPanels"
OnSectionToggle="OnSectionToggle" IsEditMode="@Session.IsEditMode" ActiveFieldKey="@Session.ActiveFieldKey" />
</CascadingValue>
</CascadingValue>
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.PendingChanges" Status="@Status" Session="@Session"
OnClose="CloseSaveModal" OnRevertedNotify="OnRevertedFromSaveModal" OnSaveCompleted="OnSaveCompleted" OnContinueEditingAfterSavedFailure="ContinueEditingAfterSavedFailureAsync" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
</div>
}
@code {
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
[Parameter] public bool ShowSearchBox { get; set; } = true;
[Parameter] public EventCallback OnSaveCompleted { get; set; }
private string _searchTerm = "";
private HashSet<string> _openPanels = new();
private bool _showSaveModal;
private string? _optionHelpKey;
private string? _optionHelpAnchorId;
private string _optionHelpTitle = "Option help";
private bool _showOptionHelp;
private OptionHelpController? _optionHelpControllerRef;
private bool _showReadonlyPopover;
private ReadonlyPopoverController? _readonlyPopoverControllerRef;
private string? _readonlyPopoverAnchorId;
private ConfigValueSource? _readonlyPopoverSource;
private string? _readonlyPopoverOptionName;
private object? _readonlyPopoverValue;
private EffectiveConfigUiContext? _uiContext;
protected override void OnInitialized()
{
_uiContext = new EffectiveConfigUiContext
{
RequestOptionHelpAsync = HandleOptionHelpRequested,
RequestReadonlyPopoverAsync = HandleReadonlyBadgeClicked,
ScheduleReadonlyPopoverCloseAsync = () =>
{
_readonlyPopoverControllerRef?.ScheduleCloseFromBadgeLeave();
return Task.CompletedTask;
},
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); },
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; },
RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); }
};
}
private async Task OnSearchTermChanged(string value)
{
_searchTerm = value ?? "";
await InvokeAsync(StateHasChanged);
}
private void CloseOptionHelp()
{
_showOptionHelp = false;
StateHasChanged();
}
private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args)
{
if (args.IsLabelMouseLeave)
{
if (args.AnchorId == _optionHelpAnchorId)
_optionHelpControllerRef?.ScheduleCloseFromLabelLeave();
await InvokeAsync(StateHasChanged);
return;
}
_optionHelpControllerRef?.CancelCloseFromLabelEnter();
_optionHelpKey = args.HelpKey;
_optionHelpTitle = args.OptionLabel;
_optionHelpAnchorId = args.AnchorId;
_showOptionHelp = true;
await InvokeAsync(StateHasChanged);
}
private void CloseReadonlyPopover()
{
_showReadonlyPopover = false;
StateHasChanged();
}
private async Task HandleReadonlyBadgeClicked(ReadonlyBadgeClickedEventArgs args)
{
_readonlyPopoverControllerRef?.CancelCloseFromBadgeEnter();
_readonlyPopoverAnchorId = args.AnchorId;
_readonlyPopoverSource = args.Source;
_readonlyPopoverOptionName = args.OptionName;
_readonlyPopoverValue = args.Value;
_showReadonlyPopover = true;
await InvokeAsync(StateHasChanged);
}
private void OnSectionToggle(string id)
{
if (_openPanels.Contains(id))
_openPanels.Remove(id);
else
_openPanels.Add(id);
StateHasChanged();
}
private void ExpandAll()
{
foreach (var view in EffectiveConfigViews.GetViewsForContext(Context))
_openPanels.Add(view.SectionId);
StateHasChanged();
}
private void CollapseAll()
{
_openPanels.Clear();
StateHasChanged();
}
private void EnterEditMode()
{
Session.EnterEditMode();
StateHasChanged();
}
private async Task ExitEditModeWithConfirmAsync()
{
if (Session.PendingChanges.Count > 0)
{
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
try
{
var confirmed = await module.InvokeAsync<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
if (!confirmed) return;
}
finally
{
await module.DisposeAsync();
}
}
Session.ExitEditModeDiscard();
StateHasChanged();
}
private void DeactivateActiveFieldIfClicked()
{
if (Session.IsEditMode && Session.ActiveFieldKey != null)
{
Session.DeactivateField();
StateHasChanged();
}
}
private void ActivateField(string fieldKey)
{
Session.ActivateField(fieldKey);
StateHasChanged();
}
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
{
Session.TrackCommit(args);
RunCrossOptionEvaluator();
StateHasChanged();
}
private void RunCrossOptionEvaluator()
{
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, Session.PendingChanges);
Session.SetCrossOptionIssues(issues);
}
private void OnRevertedFromSaveModal()
{
RunCrossOptionEvaluator();
StateHasChanged();
}
private void OpenSaveModal()
{
if (Session.PendingChanges.Count == 0) return;
_showSaveModal = true;
StateHasChanged();
}
private void CloseSaveModal()
{
_showSaveModal = false;
StateHasChanged();
}
private async Task ContinueEditingAfterSavedFailureAsync()
{
_showSaveModal = false;
Session.ExitEditModeDiscard();
await OnSaveCompleted.InvokeAsync();
Session.EnterEditMode();
StateHasChanged();
}
private void CloseSaveModalAndEndEdit()
{
_showSaveModal = false;
Session.ExitEditModeDiscard();
StateHasChanged();
}
}
@@ -0,0 +1,308 @@
/* Effective config sections – mobile-first, touch-friendly.
Use ::deep so styles apply to EffectiveConfigSectionPanel (child component) markup. */
/* Wrapper: border and grouping around all sections. Margin-top separates from config files list above. */
.ec-container {
margin-top: 0.75rem;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.5rem;
padding: 1rem;
background-color: var(--bs-secondary-bg, #f8f9fa);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
min-width: 0;
overflow: visible;
}
/* Toolbar: single row, search left and actions right. Use ::deep so styles apply to EffectiveConfigToolbar (child) markup. */
::deep .ec-toolbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
min-width: 0;
}
::deep .ec-toolbar-search {
flex: 1 1 auto;
min-width: 0;
max-width: 24rem;
}
::deep .ec-toolbar-actions {
display: flex;
flex-wrap: nowrap;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
::deep .ec-toolbar-changes-link {
cursor: pointer;
}
::deep .ec-toolbar-changes-link:hover {
text-decoration: underline;
}
.ec-sections {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
::deep .ec-section {
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
}
::deep .ec-section:last-child {
border-bottom: none;
}
/* Section header: clear separation from background, touch-friendly (min 44px), distinct so search jumps are obvious */
::deep .ec-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 2.75rem;
padding: 0.625rem 1rem;
margin: 0;
border: none;
border-left: 3px solid var(--bs-border-color, #dee2e6);
border-radius: 0.25rem;
background-color: var(--bs-body-bg, #fff);
color: inherit;
text-align: left;
cursor: pointer;
font-size: inherit;
line-height: 1.3;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
::deep .ec-section-header:hover {
color: var(--bs-primary, #0d6efd);
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
border-left-color: var(--bs-primary, #0d6efd);
}
::deep .ec-section-header:focus-visible {
outline: 2px solid var(--bs-primary, #0d6efd);
outline-offset: 2px;
}
/* Open state: header reads as "active", left border accent */
::deep .ec-section-open .ec-section-header {
background-color: var(--bs-body-bg, #fff);
color: var(--bs-emphasis-color, inherit);
font-weight: 600;
border-left-color: var(--bs-primary, #0d6efd);
box-shadow: 0 1px 0 var(--bs-border-color, #dee2e6);
}
::deep .ec-section-open .ec-section-header:hover {
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
color: var(--bs-primary, #0d6efd);
}
::deep .ec-section-title {
flex: 1;
min-width: 0;
}
::deep .ec-section-chevron {
flex-shrink: 0;
font-size: 0.8rem;
opacity: 0.8;
margin-left: 0.35rem;
transition: opacity 0.2s ease;
}
::deep .ec-section-open .ec-section-chevron {
opacity: 1;
}
/* Animated expand/collapse: grid 0fr → 1fr */
::deep .ec-section-collapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease-out;
}
::deep .ec-section-collapse-open {
grid-template-rows: 1fr;
}
::deep .ec-section-collapse-inner {
min-height: 0;
overflow: hidden;
}
::deep .ec-section-body {
padding: 0.25rem 1rem 0.5rem 1rem;
padding-left: 1rem;
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
margin-left: 0;
min-width: 0;
overflow: visible;
overflow-wrap: break-word;
word-break: break-word;
}
/* Long paths/values wrap on small screens; badges stay inline */
::deep .ec-section-body ul {
overflow-wrap: break-word;
word-break: break-word;
}
::deep .ec-section-body li {
overflow-wrap: break-word;
word-break: break-word;
}
/* Field row: label + value + optional badge. Same structure for custom and default displays. */
::deep .ec-field {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.5rem;
margin-bottom: 0.35rem;
}
::deep .ec-field:last-child {
margin-bottom: 0;
}
::deep .ec-field-label {
flex-shrink: 0;
min-width: 0;
}
/* Clickable label: open full option help on click (hover tooltip unchanged) */
::deep .ec-field-label-help {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
::deep .ec-field-label-help:hover {
color: var(--bs-primary, #0d6efd);
}
::deep .ec-field-value {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 1 1 auto;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
gap: 0.25rem;
}
/* When a field is active: value + badges in one block so any click inside (checkbox, input, ok/cancel) doesn't deactivate */
::deep .ec-field-value-when-editing {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
min-width: 0;
max-width: 100%;
}
/* Edit wrapper (checkbox/input) shrinks to content */
::deep .ec-field-edit-wrapper {
flex: 0 1 auto;
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
}
/* Input/select in value area can shrink so the editable badge stays on the same line */
::deep .ec-field-value > .form-control,
::deep .ec-field-value > .form-select {
flex: 1 1 auto;
min-width: 0;
}
/* Multi-value: full width below label, indented ordered list. min-width: 0 so flex doesn't prevent wrapping. */
::deep .ec-field-value-multi {
flex-basis: 100%;
min-width: 0;
padding-left: 1.25rem;
margin-top: 0.15rem;
overflow-wrap: break-word;
word-break: break-word;
}
/* Row-level ok/cancel (and revert) for multi-value: right-align on the row below the list */
::deep .ec-field .ec-field-value-multi + .status-badge-inline {
margin-left: auto;
}
::deep .ec-field-value-list {
margin: 0;
padding-left: 1.25rem;
list-style-type: decimal;
min-width: 0;
}
::deep .ec-field-value-list li {
margin-bottom: 0.2rem;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
}
::deep .ec-field-value-list li:last-child {
margin-bottom: 0;
}
/* Long paths/URLs wrap; allow break anywhere so nothing is clipped */
::deep .ec-field-value-item {
display: inline;
overflow-wrap: break-word;
word-break: break-word;
}
::deep .ec-field-value-sep {
margin-right: 0.25rem;
}
/* StatusBadge inside field rows: alignment; spacing comes from StatusBadge .status-badge-inline */
::deep .ec-field .status-badge,
::deep .ec-field-value-item .status-badge {
display: inline-flex;
align-items: center;
line-height: 1;
}
/* Only when field is in edit mode: push ok/cancel/revert to end and allow them to wrap first on narrow widths */
::deep .ec-field-value-when-editing > .status-badge-inline {
flex: 0 0 auto;
white-space: nowrap;
margin-left: auto;
}
@media (max-width: 991.98px) {
::deep .ec-field-value-when-editing > .status-badge-inline {
flex-basis: 100%;
margin-left: 0;
margin-top: 0.15rem;
}
}
@media (max-width: 767.98px) {
::deep .ec-section-header {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
::deep .ec-section-body {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
}
@@ -1,5 +1,6 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<CascadingValue Value="DisplayMode">
<div class="ec-section @(IsOpen ? "ec-section-open" : "")">
@@ -7,14 +8,18 @@
<span class="ec-section-title fw-bold">@Title</span>
<i class="bi @(IsOpen ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
</button>
<div class="collapse @(IsOpen ? "show" : "")">
<div class="ec-section-body">
<div class="ec-section-collapse @(IsOpen ? "ec-section-collapse-open" : "")">
<div class="ec-section-collapse-inner">
<div class="ec-section-body">
<ul class="list-unstyled mb-0">
@foreach (var f in Descriptors)
{
<EffectiveConfigFieldDisplay Descriptor="@f" />
var fieldKey = $"{SectionId}:{f.OptionName}";
<EffectiveConfigFieldDisplay Descriptor="@f" FieldKey="@fieldKey"
IsEditMode="@IsEditMode" IsActive="@(ActiveFieldKey == fieldKey)" />
}
</ul>
</div>
</div>
</div>
</div>
@@ -26,8 +31,10 @@
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<string> OnToggle { get; set; }
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable (future).</summary>
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable.</summary>
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ActiveFieldKey { get; set; }
private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId);
}
@@ -0,0 +1,111 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
<div class="ec-toolbar @(IsEditMode ? "ec-edit-toolbar" : "")">
@if (ShowSearchBox)
{
<input type="search" id="ec-search-input" class="form-control form-control-sm ec-toolbar-search" placeholder="Search..." autocomplete="off"
value="@SearchTerm" @oninput="OnSearchInput" />
}
<div class="ec-toolbar-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnToggleExpandCollapse"
title="@(AllSectionsExpanded ? "Collapse all sections" : "Expand all sections")"
aria-label="@(AllSectionsExpanded ? "Collapse all sections" : "Expand all sections")">
<i class="bi @(AllSectionsExpanded ? "bi-arrows-collapse" : "bi-arrows-expand")" aria-hidden="true"></i>
</button>
@if (IsEditMode)
{
<button type="button" class="btn btn-sm btn-primary" @onclick="OnOpenSaveModal" disabled="@(PendingChangesCount == 0)" title="Save changes to managed config" aria-label="Save">
<i class="bi bi-floppy-fill" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnExitEditModeWithConfirm" title="Exit edit mode" aria-label="Exit edit mode">
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</button>
@if (PendingChangesCount > 0)
{
<span class="text-muted small ec-toolbar-changes-link" role="button" tabindex="0"
@onclick="OnOpenSaveModal"
@onkeydown="OnChangesLinkKeyDown"
title="Open save dialog"
aria-label="Open save dialog">
@PendingChangesCount change(s)
</span>
}
@if (ValidationErrorCount > 0 || ValidationWarningCount > 0)
{
<span class="small ec-toolbar-validation-count" aria-live="polite">
@if (ValidationErrorCount > 0)
{
<span class="text-danger fw-medium ec-toolbar-changes-link" role="button" tabindex="0"
title="@ValidationErrorTooltip"
aria-label="Open save dialog"
@onclick="OnOpenSaveModal"
@onkeydown="OnValidationLinkKeyDown">
@ValidationErrorCount error(s)
</span>
}
@if (ValidationErrorCount > 0 && ValidationWarningCount > 0) { <span class="text-muted">·</span> }
@if (ValidationWarningCount > 0)
{
<span class="text-warning ec-toolbar-changes-link" role="button" tabindex="0"
title="@ValidationWarningTooltip"
aria-label="Open save dialog"
@onclick="OnOpenSaveModal"
@onkeydown="OnValidationLinkKeyDown">
@ValidationWarningCount warning(s)
</span>
}
</span>
}
}
else
{
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnEnterEditMode" title="Edit effective config" aria-label="Edit">
<i class="bi bi-pencil" aria-hidden="true"></i>
</button>
}
</div>
</div>
@code {
[Parameter] public bool ShowSearchBox { get; set; } = true;
[Parameter] public string SearchTerm { get; set; } = "";
[Parameter] public EventCallback<string> SearchTermChanged { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public int PendingChangesCount { get; set; }
[Parameter] public IReadOnlyList<FieldIssue>? ValidationSummary { get; set; }
private int ValidationErrorCount => ValidationSummary?.Count(i => i.Severity == FieldIssueSeverity.Error) ?? 0;
private int ValidationWarningCount => ValidationSummary?.Count(i => i.Severity == FieldIssueSeverity.Warning) ?? 0;
private string ValidationErrorTooltip => ValidationSummary == null ? "" : string.Join("\n", ValidationSummary.Where(i => i.Severity == FieldIssueSeverity.Error).Select(i => i.Message));
private string ValidationWarningTooltip => ValidationSummary == null ? "" : string.Join("\n", ValidationSummary.Where(i => i.Severity == FieldIssueSeverity.Warning).Select(i => i.Message));
[Parameter] public bool AllSectionsExpanded { get; set; }
[Parameter] public EventCallback OnExpandAll { get; set; }
[Parameter] public EventCallback OnCollapseAll { get; set; }
private async Task OnToggleExpandCollapse()
{
if (AllSectionsExpanded)
await OnCollapseAll.InvokeAsync();
else
await OnExpandAll.InvokeAsync();
}
[Parameter] public EventCallback OnEnterEditMode { get; set; }
[Parameter] public EventCallback OnExitEditModeWithConfirm { get; set; }
[Parameter] public EventCallback OnOpenSaveModal { get; set; }
private async Task OnSearchInput(ChangeEventArgs e)
{
await SearchTermChanged.InvokeAsync(e.Value?.ToString() ?? "");
}
private async Task OnChangesLinkKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter" || e.Key == " ")
await OnOpenSaveModal.InvokeAsync();
}
private async Task OnValidationLinkKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter" || e.Key == " ")
await OnOpenSaveModal.InvokeAsync();
}
}
@@ -0,0 +1,68 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@inherits EffectiveConfigValueDisplayBase
@* do-0x20-encode / no-0x20-encode: tri-state. Switch shows Enabled vs not-Enabled; uncheck maps back to Default when that was the value at activation, else Disabled. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
var isOn = GetState() == ExplicitToggleState.Enabled;
<div class="form-check form-switch ec-effective-switch">
<input type="checkbox"
class="form-check-input"
checked="@isOn"
aria-label="Enable @Descriptor.OptionName"
title="Set @Descriptor.OptionName"
@onchange="OnCheckedChange"
@onfocusout="OnCommitRequested" />
</div>
}
else
{
@* Intentionally show both Default and Disabled as "Disabled" (product choice: collapse unset and off). Do not "fix" to show "Default" for unset. *@
@(GetState() == ExplicitToggleState.Enabled ? "Enabled" : "Disabled")
}
@code {
private ExplicitToggleState _stateAtActivation;
private bool _wasActiveEditor;
private ExplicitToggleState GetState()
{
var v = GetEffectiveDisplayValue();
if (v is ExplicitToggleState s) return s;
return ExplicitToggleState.Default;
}
protected override void OnParametersSet()
{
if (!IsActiveEditor)
_stateAtActivation = GetState();
else if (!_wasActiveEditor)
_stateAtActivation = GetState();
_wasActiveEditor = IsActiveEditor;
}
private async Task OnCheckedChange(ChangeEventArgs e)
{
var isChecked = e.Value is true;
if (isChecked)
{
await NotifyValueChanged(ExplicitToggleState.Enabled);
return;
}
var pending = Session?.PendingChanges.FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
if (pending?.OldValue is ExplicitToggleState previousState)
{
await NotifyValueChanged(previousState);
return;
}
// No existing pending edit for this field: map unchecked based on the state at activation.
// Default and Disabled both display as "Disabled" in view mode, so we preserve true intent here.
await NotifyValueChanged(_stateAtActivation == ExplicitToggleState.Default ? ExplicitToggleState.Default : ExplicitToggleState.Disabled);
}
}
@@ -0,0 +1,29 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@* Reusable value-only display for boolean/flag options. When active editor: switch (unless readonly); View: "Enabled" / "Disabled". Pending value shown until save. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
var isSet = GetEffectiveDisplayValue() is bool b && b;
<div class="form-check form-switch ec-effective-switch">
<input type="checkbox"
class="form-check-input"
checked="@isSet"
aria-label="Enable @Descriptor.OptionName"
title="Set @Descriptor.OptionName"
@onchange="OnCheckedChange"
@onfocusout="OnCommitRequested" />
</div>
}
else
{
@(GetEffectiveDisplayValue() is bool isSet && isSet ? "Enabled" : "Disabled")
}
@code {
private async Task OnCheckedChange(ChangeEventArgs e)
{
await NotifyValueChanged(e.Value is true);
}
}
@@ -0,0 +1,53 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@* Value-only display for int? options. Pending value shown until save. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
var current = GetEffectiveDisplayValue() is int i ? i : DefaultValue;
<input type="number" class="form-control form-control-sm" style="max-width: 8rem;"
min="@Min" max="@Max" value="@(current?.ToString() ?? "")" @oninput="OnInput" @onfocusout="OnCommitRequested" />
}
else
{
var v = GetEffectiveDisplayValue() is int n ? n : (int?)null;
if (v is null)
{
@("(not set)")
}
else
{
@v
@if (!string.IsNullOrEmpty(ViewSuffix)) { <text> @ViewSuffix</text> }
@if (!string.IsNullOrEmpty(Unit)) { <text> @Unit</text> }
}
}
@code {
private EffectiveIntegerConfigFieldDescriptor? IntDescriptor => Descriptor as EffectiveIntegerConfigFieldDescriptor;
private string? ViewSuffix => IntDescriptor?.ViewSuffix;
private string? Unit => IntDescriptor?.Unit;
private int Min => IntDescriptor?.Min ?? 0;
private int Max => IntDescriptor?.Max ?? int.MaxValue;
private int? DefaultValue => IntDescriptor?.DefaultValue;
private async Task OnInput(ChangeEventArgs e)
{
var raw = e.Value?.ToString();
object? value = null;
if (string.IsNullOrWhiteSpace(raw))
{
value = null;
}
else if (int.TryParse(raw, out var num))
{
value = num;
}
else
{
value = raw;
}
await NotifyValueChanged(value);
}
}
@@ -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;
}
@@ -0,0 +1,27 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@* local-service: net | host. Pending value shown until save. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
<select class="form-select form-select-sm" style="max-width: 10rem;" value="@(GetEffectiveDisplayValue() as string ?? "")" @onchange="OnChange" @onfocusout="OnCommitRequested">
<option value="">(not set)</option>
<option value="net">net</option>
<option value="host">host</option>
</select>
}
else
{
var v = GetEffectiveDisplayValue() as string;
@(string.IsNullOrEmpty(v) ? "(not set)" : (v.Trim().ToLowerInvariant() is "net" or "host" ? v.Trim() : v))
}
@code {
private async Task OnChange(ChangeEventArgs e)
{
var v = e.Value?.ToString()?.Trim();
var value = string.IsNullOrEmpty(v) ? null : (v == "net" || v == "host" ? v : null);
await NotifyValueChanged(value);
}
}
@@ -0,0 +1,59 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@* log-queries: optional value extra|proto|auth. View: Off / On / On (extra|proto|auth). When active editor: dropdown. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
<select class="form-select form-select-sm" style="max-width: 12rem;" value="@GetEditValue()" @onchange="OnChange" @onfocusout="OnCommitRequested">
<option value="">Off</option>
<option value="on">On</option>
<option value="extra">On (extra)</option>
<option value="proto">On (proto)</option>
<option value="auth">On (auth)</option>
</select>
}
else
{
@GetViewText()
}
@code {
private string GetEditValue()
{
var v = GetEffectiveDisplayValue() as string;
if (string.IsNullOrEmpty(v)) return "";
var lower = v.Trim().ToLowerInvariant();
if (lower is "extra" or "proto" or "auth") return lower;
return "on";
}
private string GetViewText()
{
var v = GetEffectiveDisplayValue() as string;
if (string.IsNullOrEmpty(v)) return "Off";
var lower = v.Trim().ToLowerInvariant();
return lower switch
{
"extra" => "On (extra)",
"proto" => "On (proto)",
"auth" => "On (auth)",
_ => "On"
};
}
private async Task OnChange(ChangeEventArgs e)
{
var sel = e.Value?.ToString()?.Trim().ToLowerInvariant();
string? newValue = sel switch
{
"" or "off" => null,
"on" => "",
"extra" => "extra",
"proto" => "proto",
"auth" => "auth",
_ => null
};
await NotifyValueChanged(newValue);
}
}
@@ -0,0 +1,24 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@* Value-only display for the port option. Pending value shown until save. *@
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
{
var port = GetEffectiveDisplayValue() is int p ? p : 53;
<input type="number" class="form-control form-control-sm" style="max-width: 6rem;" min="1" max="65535" value="@port" @oninput="OnPortInput" @onfocusout="OnCommitRequested" />
}
else
{
@(GetEffectiveDisplayValue() is int port ? $"{port} (default DNS port)" : "(not set)")
}
@code {
private async Task OnPortInput(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535)
await NotifyValueChanged(port);
else
await NotifyValueChanged(null);
}
}
@@ -0,0 +1,330 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Config
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@inject INotificationService NotificationService
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. Pending value shown until save. *@
@if (_customFragment is { } fragment)
{
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
var showEditableBadge = !isReadonly && !Descriptor.IsCapabilityDisabled;
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
var fragmentWithCallback = isActiveEditor ? _fragmentWithCallback : null;
var effectiveValue = _effectiveValue;
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
<span class="ec-field-value">
@if (isActiveEditor)
{
<div class="ec-field-value-when-editing" @onclick:stopPropagation="true">
<div class="ec-field-edit-wrapper">
<CascadingValue Value="@true">
<CascadingValue Value="@(EventCallback.Factory.Create<FocusEventArgs>(this, OnBlur))">
@((fragmentWithCallback ?? fragment)(Descriptor))
</CascadingValue>
</CascadingValue>
</div>
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
</div>
}
else
{
<CascadingValue Value="@false">
@fragment(Descriptor)
</CascadingValue>
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
</span>
</li>
}
else if (Descriptor.IsMultiValue)
{
var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<IReadOnlyList<string>>(this, HandleMultiValueChanged));
if (customMulti is not null)
{
@* Custom multi editor (e.g. server): same row shell as generic multi, value slot uses custom fragment. *@
<li class="ec-field @(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled ? "ec-field-editing" : "")">
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
<span class="ec-field-value">
<CascadingValue Value="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)">
<CascadingValue Value="@IsEditMode">
@customMulti(Descriptor)
</CascadingValue>
</CascadingValue>
</span>
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
</li>
}
else
{
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
<EffectiveConfigMultiValueRow Descriptor="@Descriptor" Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
Source="@_multiValueRowSource" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
}
else
{
var value = _effectiveValue;
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
var editValue = value?.ToString() ?? "";
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
var showReadonlyHint = IsEditMode && isReadonly;
var isActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
var showEditableForDefault = !Descriptor.IsCapabilityDisabled;
@if (showReadonlyHint)
{
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
FieldKey="@FieldKey" ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange" />
</li>
}
else
{
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
EditValue="@editValue"
ShowEditableBadge="@showEditableForDefault" FieldKey="@FieldKey" IsActive="@IsActive"
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
DisplayValueChanged="@HandleDefaultValueChanged" OnBlur="@HandleDefaultBlur" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
</li>
}
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[Parameter] public string FieldKey { get; set; } = "";
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public bool IsActive { get; set; }
[CascadingParameter] public IEffectiveConfigEditSession? Session { get; set; }
private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8];
private readonly string _readonlyBadgeAnchorId = "ec-readonly-" + Guid.NewGuid().ToString("N")[..8];
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
private RenderFragment<EffectiveConfigFieldDescriptor>? _fragmentWithCallback;
private string? _labelTooltip;
private string? _optionHelpKey;
private object? _effectiveValue;
private bool _hasPendingChange;
private object? _valueAtActivation;
private object? _draftValue;
private bool _hasDraftValue;
private bool _wasActive;
private ExitAction _exitAction = ExitAction.None;
/// <summary>Cached for multi-value: items to pass to the row (with sources preserved for draft merge).</summary>
private IReadOnlyList<ValueWithSource>? _multiValueItems;
/// <summary>Cached for multi-value: true when there is an uncommitted draft list.</summary>
private bool _multiValueHasDraft;
/// <summary>Cached for multi-value: row-level source for badge (readonly when all items readonly and not edit mode).</summary>
private ConfigValueSource? _multiValueRowSource;
/// <summary>Cached for multi-value: whether row should show ok/cancel (session pending or local draft).</summary>
private bool _multiValueHasPendingOrDraft;
protected override void OnParametersSet()
{
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
_optionHelpKey = Descriptor.OptionName;
var pending = Session?.PendingChanges.FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
var descriptorValue = Descriptor.IsMultiValue
? (Descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>())
: Descriptor.GetValue();
_hasPendingChange = pending != null;
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
if (Descriptor.IsMultiValue)
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
if (!_wasActive && IsActive)
{
// Use pending.OldValue when present so "revert to original" clears the pending change (e.g. do-0x20-encode: Disabled → Enable → Disable).
_valueAtActivation = pending != null ? pending.OldValue : descriptorValue;
_hasDraftValue = false;
_draftValue = null;
}
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
_ = InvokeAsync(CommitIfChangedAsync);
if (_wasActive && !IsActive)
_exitAction = ExitAction.None;
_wasActive = IsActive;
}
private void ComputeMultiValueState(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string>? pendingList, IReadOnlyList<string>? draftList)
{
var descriptorValues = descriptorItems?.Select(i => i.Value).ToList() ?? new List<string>();
_multiValueHasDraft = draftList != null && !ValuesEqual(descriptorValues, draftList);
if (_multiValueHasDraft && draftList != null)
{
_multiValueItems = BuildItemsWithSource(descriptorItems, draftList);
}
else if (_hasPendingChange && pendingList != null)
{
_multiValueItems = BuildItemsWithSource(descriptorItems, pendingList);
}
else
{
_multiValueItems = descriptorItems;
}
var allReadonly = _multiValueItems != null && _multiValueItems.Count > 0 && _multiValueItems.All(i => i.Source?.IsReadOnly == true);
_multiValueRowSource = allReadonly && !IsEditMode ? _multiValueItems?.FirstOrDefault()?.Source : null;
_multiValueHasPendingOrDraft = _hasPendingChange || _multiValueHasDraft;
}
private static IReadOnlyList<ValueWithSource> BuildItemsWithSource(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string> values)
{
var pool = descriptorItems?.ToList() ?? new List<ValueWithSource>();
var list = new List<ValueWithSource>(values.Count);
foreach (var s in values)
{
var idx = pool.FindIndex(x => string.Equals(x.Value, s, StringComparison.Ordinal));
if (idx >= 0)
{
list.Add(pool[idx]);
pool.RemoveAt(idx);
}
else
list.Add(new ValueWithSource(s, null));
}
return list;
}
private void HandleValueChanged(object? value)
{
_draftValue = value;
_hasDraftValue = true;
}
private async Task OnBlur(FocusEventArgs _)
{
if (!IsActive) return;
await CommitIfChangedAsync();
}
private async Task<bool> CommitIfChangedAsync()
{
var newValue = _hasDraftValue ? _draftValue : _effectiveValue;
if (IsDefaultSingleValueField)
newValue = NormalizeDefaultSingleCommitValue(newValue);
if (ValuesEqual(_valueAtActivation, newValue))
{
_draftValue = null;
_hasDraftValue = false;
if (_hasPendingChange && Ui?.RevertFieldAsync != null)
await Ui.RevertFieldAsync(Descriptor.SectionId, Descriptor.OptionName);
return true;
}
var validationError = Descriptor.Validate(newValue);
if (validationError != null)
{
NotificationService.ShowError(validationError);
return false;
}
var source = Descriptor.GetSource();
object? valueToCommit = newValue;
if (newValue is IReadOnlyList<string> list)
{
var deduped = list.Distinct(StringComparer.Ordinal).ToList();
valueToCommit = deduped;
}
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, valueToCommit, source?.FilePath);
if (Ui != null)
await Ui.CommitFieldAsync(args);
_draftValue = null;
_hasDraftValue = false;
return true;
}
private bool IsDefaultSingleValueField =>
!Descriptor.IsMultiValue && _customFragment is null;
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
private void HandleDefaultValueChanged(string value)
{
_draftValue = value;
_hasDraftValue = true;
}
private async Task HandleDefaultBlur(string? value)
{
if (!IsActive) return;
_draftValue = NormalizeDefaultSingleCommitValue(value);
_hasDraftValue = true;
if (!await CommitIfChangedAsync() && Ui != null)
await Ui.ActivateFieldAsync(FieldKey);
}
private static object? NormalizeDefaultSingleCommitValue(object? value)
{
if (value is string s && string.IsNullOrWhiteSpace(s))
return null;
return value;
}
private void HandleMultiValueChanged(IReadOnlyList<string> values)
{
_draftValue = values.ToList();
_hasDraftValue = true;
if (Descriptor.IsMultiValue)
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
StateHasChanged();
}
private async Task HandleConfirmEdit()
{
if (!await CommitIfChangedAsync())
return;
_exitAction = ExitAction.Confirm;
if (Ui != null)
await Ui.DeactivateFieldAsync();
}
private async Task HandleCancelEdit()
{
_draftValue = null;
_hasDraftValue = false;
_exitAction = ExitAction.Cancel;
if (Ui != null)
await Ui.DeactivateFieldAsync();
}
private async Task HandleRevertEdit()
{
_draftValue = null;
_hasDraftValue = false;
Session?.RevertChange(Descriptor.SectionId, Descriptor.OptionName);
_exitAction = ExitAction.Revert;
if (Ui != null)
await Ui.DeactivateFieldAsync();
}
private static IReadOnlyList<string>? AsStringList(object? value)
{
return value as IReadOnlyList<string>;
}
private static bool ValuesEqual(object? oldValue, object? newValue)
{
var oldList = AsStringList(oldValue);
var newList = AsStringList(newValue);
if (oldList != null || newList != null)
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
return Equals(oldValue, newValue);
}
private enum ExitAction { None, Confirm, Cancel, Revert }
}
@@ -0,0 +1,22 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<span class="ec-field-value-item" title="@(ReadonlyClickActivatesField ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip())">
@Value
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value"
IsInteractive="@(IsEditMode && !ReadonlyClickActivatesField && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))"
ShowEditableBadge="@(Source?.IsReadOnly != true)" EditableAsButton="true"
FieldKey="@FieldKey" ReadonlyClickActivatesField="@ReadonlyClickActivatesField" ReadonlyTitleOverride="@ReadonlyTitleOverride" />
</span>
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
[Parameter] public string? FieldKey { get; set; }
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
[Parameter] public string? ReadonlyTitleOverride { get; set; }
}
@@ -0,0 +1,276 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using Microsoft.AspNetCore.Components.Web
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject IJSRuntime JSRuntime
@inject INotificationService NotificationService
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
<ol class="ec-field-value-list">
@if (_displayItems.Count > 0)
{
@for (var i = 0; i < _displayItems.Count; i++)
{
var item = _displayItems[i];
var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
var itemReadonly = item.Source?.IsReadOnly == true;
var rowIndex = i;
var rowValue = item.Value;
<li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<div class="ec-multi-list-row-controls">
<div class="ec-multi-list-input-group">
<input class="form-control form-control-sm ec-multi-list-input"
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
@bind="_draftValue" @bind:event="oninput" />
</div>
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
Title="Apply change" AriaLabel="Apply change"
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard edit" AriaLabel="Cancel edit"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</span>
</div>
}
else
{
<span class="ec-field-value-item">@rowValue</span>
<span class="status-badge-inline">
<OptionStateBadge
AnchorId="@anchorId"
Source="@item.Source"
OptionName="@OptionName"
Value="@rowValue"
IsInteractive="@IsEditMode"
ShowEditableBadge="@(!itemReadonly)"
EditableAsButton="@(!itemReadonly)"
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
@if (!itemReadonly)
{
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
Title="Remove this value" AriaLabel="Remove"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
}
</span>
}
</li>
}
}
else if (!AllowAddManagedValue)
{
<li class="ec-multi-list-row ec-multi-list-empty-hint">
<span class="text-muted">(none)</span>
</li>
}
@if (AllowAddManagedValue)
{
<li class="ec-multi-list-row ec-multi-list-add-item @(_addingNew ? "ec-multi-list-row-with-controls" : "")" @key="AddItemKey">
@if (_addingNew)
{
<div class="ec-multi-list-row-controls">
<div class="ec-multi-list-input-group">
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input"
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
@bind="_draftValue" @bind:event="oninput" />
</div>
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
Title="Add this value" AriaLabel="Add"
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard" AriaLabel="Cancel"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</span>
</div>
}
else
{
<StatusBadge Kind="StatusBadgeKind.Editable" Text="add item" Clickable="true"
Title="Add a new value" AriaLabel="Add item"
OnClick="BeginAdd" Class="status-badge-inline ec-multi-list-add-btn" />
}
</li>
}
</ol>
</div>
@code {
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public string ReadonlyBadgeAnchorIdBase { get; set; } = "";
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public bool AllowAddManagedValue { get; set; } = true;
[Parameter] public IMultiValueEditBehavior? Behavior { get; set; }
[Parameter] public IMultiValueOptionValidator? Validator { get; set; }
[Parameter] public string? FieldKey { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
private const string AddItemKey = "add-item";
private static readonly TimeSpan ValidationToastDedupeWindow = TimeSpan.FromSeconds(2);
private int? _editingIndex;
private bool _addingNew;
private string _draftValue = "";
private bool _didAutoOpenAdd;
private bool _shouldFocusAddInput;
private ElementReference _addNewInputRef;
private string? _lastValidationErrorMessage;
private DateTime _lastValidationErrorUtc;
private List<ValueWithSource> _displayItems = new();
private IReadOnlyList<string> _currentValues => _displayItems.Select(x => x.Value).ToList();
protected override void OnParametersSet()
{
_displayItems = (SourceItems ?? Array.Empty<ValueWithSource>()).ToList();
if (!_didAutoOpenAdd && (SourceItems?.Count ?? 0) == 0 && AllowAddManagedValue)
{
_didAutoOpenAdd = true;
_addingNew = true;
_shouldFocusAddInput = true;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_shouldFocusAddInput && _addingNew)
{
_shouldFocusAddInput = false;
try
{
await _addNewInputRef.FocusAsync();
}
catch
{
// Focus can fail if the element is not yet in the DOM (e.g. prerender)
}
}
}
private void BeginEdit(int index, string current)
{
_addingNew = false;
_editingIndex = index;
_draftValue = current;
}
private void BeginAdd()
{
_editingIndex = null;
_addingNew = true;
_draftValue = "";
_shouldFocusAddInput = true;
}
private void CancelInline()
{
_editingIndex = null;
_addingNew = false;
_draftValue = "";
_lastValidationErrorMessage = null;
}
private void ShowValidationError(string message)
{
var now = DateTime.UtcNow;
if (string.Equals(_lastValidationErrorMessage, message, StringComparison.Ordinal) &&
(now - _lastValidationErrorUtc) < ValidationToastDedupeWindow)
return;
_lastValidationErrorMessage = message;
_lastValidationErrorUtc = now;
NotificationService.ShowError(message);
}
private async Task SaveEdit(int index)
{
var current = _currentValues;
var next = current.ToList();
if (index < 0 || index >= next.Count) return;
var raw = _draftValue ?? "";
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
if (normalized.Length == 0)
{
ShowValidationError("Value cannot be empty.");
return;
}
var validationError = Validator?.ValidateItem(normalized, current, index);
if (validationError != null)
{
ShowValidationError(validationError);
return;
}
if (Behavior?.AllowDuplicates == false && next.Where((v, i) => i != index).Any(v => string.Equals(v, normalized, StringComparison.Ordinal)))
{
ShowValidationError("Duplicate values are not allowed.");
return;
}
next[index] = normalized;
_displayItems[index] = _displayItems[index] with { Value = normalized };
await OnValuesChanged.InvokeAsync(next);
CancelInline();
}
private async Task SaveNew()
{
var raw = _draftValue ?? "";
var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
if (normalized.Length == 0)
{
ShowValidationError("Value cannot be empty.");
return;
}
var current = _currentValues;
var validationError = Validator?.ValidateItem(normalized, current, null);
if (validationError != null)
{
ShowValidationError(validationError);
return;
}
if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal))
{
ShowValidationError("Duplicate values are not allowed.");
return;
}
var next = current.ToList();
next.Add(normalized);
_displayItems.Add(new ValueWithSource(normalized, null));
await OnValuesChanged.InvokeAsync(next);
CancelInline();
}
private async Task RemoveAt(int index)
{
if (index < 0 || index >= _displayItems.Count) return;
var item = _displayItems[index];
if (item?.Source?.IsReadOnly == true) return;
var next = _displayItems.Select(x => x.Value).ToList();
if (index < 0 || index >= next.Count) return;
next.RemoveAt(index);
_displayItems.RemoveAt(index);
await OnValuesChanged.InvokeAsync(next);
StateHasChanged();
}
}
@@ -0,0 +1,95 @@
/* Editor block: distinct from display mode (border + background) */
.ec-multi-list-editor {
display: block;
padding: 0.75rem 1rem;
margin-top: 0.25rem;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
background-color: var(--bs-body-bg, #fff);
}
.ec-multi-list-editor .ec-field-value-list {
margin-bottom: 0;
}
.ec-multi-list-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2rem;
}
.ec-multi-list-row.ec-multi-list-row-with-controls {
align-items: flex-start;
}
.ec-multi-list-row-controls {
display: flex;
align-items: flex-start;
gap: 0.5rem;
flex-wrap: nowrap;
min-width: 0;
width: 100%;
}
.ec-multi-list-input-group {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
max-width: 28rem;
}
.ec-multi-list-invalid-feedback {
margin-top: 0.25rem;
margin-bottom: 0;
width: 100%;
}
.ec-multi-list-row .ec-field-value-item {
min-width: 0;
}
.ec-multi-list-row .status-badge-inline {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.ec-multi-list-row .form-control.ec-multi-list-input {
max-width: none;
flex-shrink: 0;
width: 100%;
}
.ec-multi-list-empty-hint {
list-style: none;
margin-left: -1.25rem;
}
.ec-multi-list-add-item {
list-style: none;
margin-left: -1.25rem;
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px dashed var(--bs-border-color, #dee2e6);
flex-wrap: nowrap;
}
.ec-multi-list-add-item .form-control.ec-multi-list-input {
min-width: 12rem;
}
.ec-multi-list-add-item .ec-multi-list-input-group {
max-width: none;
}
.ec-multi-list-add-item .status-badge-inline {
margin-left: auto;
}
.ec-multi-list-add-btn {
font-size: 0.875rem;
}
@@ -0,0 +1,68 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<li class="ec-field">
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
@if (IsActiveEditor)
{
<EffectiveConfigMultiValueListEditor
SourceItems="@(Items ?? Array.Empty<ValueWithSource>())"
OptionName="@Label"
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
IsEditMode="@IsEditMode"
AllowAddManagedValue="@IsEditMode"
FieldKey="@FieldKey"
Behavior="@(_multiDesc?.Behavior)"
Validator="@(_multiDesc?.Validator)"
OnValuesChanged="@ItemsChanged" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
}
else
{
<div class="ec-field-value ec-field-value-multi">
@if (Items?.Count > 0)
{
<ol class="ec-field-value-list">
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
{
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : "";
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" OptionName="@Label" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" FieldKey="@FieldKey" ReadonlyClickActivatesField="true" ReadonlyTitleOverride="New values can still be added. Click to edit." /></li>
}
</ol>
}
else
{
<span class="text-muted">(none)</span>
<OptionStateBadge OptionName="@Label" ShowEditableBadge="@(!(Descriptor?.IsCapabilityDisabled == true))" EditableAsButton="true"
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
}
</div>
}
</li>
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public EffectiveConfigFieldDescriptor? Descriptor { get; set; }
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
[Parameter] public string? LabelTooltip { get; set; }
[Parameter] public string? OptionHelpKey { get; set; }
[Parameter] public string? OptionHelpAnchorId { get; set; }
[Parameter] public bool IsActiveEditor { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public bool ShowEditableBadge { get; set; }
[Parameter] public bool HasPendingChange { get; set; }
[Parameter] public string FieldKey { get; set; } = "";
[Parameter] public EventCallback OnConfirmRequested { get; set; }
[Parameter] public EventCallback OnCancelRequested { get; set; }
[Parameter] public EventCallback OnRevertRequested { get; set; }
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor;
}
@@ -0,0 +1,84 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><strong>@Label:</strong></OptionHelpLabel>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true && !IsCapabilityDisabled)
{
<span class="ec-field-value" @onclick:stopPropagation="true">
<input type="text"
class="form-control form-control-sm"
placeholder="@(DnsmasqOptionPlaceholders.Get(Label) ?? "Value")"
value="@(_editValue ?? EditValue)"
@oninput="OnValueInput"
@onfocusout="OnInputBlur" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="true" IsCapabilityDisabled="@IsCapabilityDisabled" CapabilityDisabledReason="@CapabilityDisabledReason" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
</span>
}
else
{
<span class="ec-field-value">
@DisplayValue
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="false" IsCapabilityDisabled="@IsCapabilityDisabled" CapabilityDisabledReason="@CapabilityDisabledReason" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
</span>
}
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public string DisplayValue { get; set; } = "";
[Parameter] public string EditValue { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
[Parameter] public string? LabelTooltip { get; set; }
[Parameter] public string? OptionHelpKey { get; set; }
[Parameter] public string? OptionHelpAnchorId { get; set; }
/// <summary>When set in edit mode, the readonly badge is rendered as a button with this id so the popover can anchor to it.</summary>
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
/// <summary>Raw value for the readonly popover override line (e.g. from descriptor); falls back to DisplayValue.</summary>
[Parameter] public object? RawValue { get; set; }
/// <summary>When true, show an 'editable' badge next to non-readonly fields; clicking it activates edit for this field.</summary>
[Parameter] public bool ShowEditableBadge { get; set; }
/// <summary>When true, badge shows "pending" and warning style to indicate this field has an unsaved change.</summary>
[Parameter] public bool HasPendingChange { get; set; }
/// <summary>Field key for activation via context when editable badge is clicked.</summary>
[Parameter] public string FieldKey { get; set; } = "";
/// <summary>When true, add ec-field-editing class and show input when DisplayMode is Edit.</summary>
[Parameter] public bool IsActive { get; set; }
/// <summary>When true, option is disabled because dnsmasq build does not support it; show disabled badge and no editor.</summary>
[Parameter] public bool IsCapabilityDisabled { get; set; }
/// <summary>Tooltip when <see cref="IsCapabilityDisabled"/> is true.</summary>
[Parameter] public string? CapabilityDisabledReason { get; set; }
/// <summary>When Edit and not readonly: raised when the user changes the value. Not yet wired to save.</summary>
[Parameter] public EventCallback<string> DisplayValueChanged { get; set; }
/// <summary>When Edit and not readonly: raised when the input loses focus; argument is the current value.</summary>
[Parameter] public EventCallback<string?> OnBlur { get; set; }
/// <summary>When user clicks OK; parent should commit and deactivate.</summary>
[Parameter] public EventCallback OnConfirmRequested { get; set; }
/// <summary>When user clicks Cancel; parent should clear draft and deactivate.</summary>
[Parameter] public EventCallback OnCancelRequested { get; set; }
/// <summary>When user clicks Revert; parent should revert pending, clear draft, and deactivate.</summary>
[Parameter] public EventCallback OnRevertRequested { get; set; }
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
private string? _editValue;
protected override void OnParametersSet()
{
if (DisplayMode != EffectiveConfigDisplayMode.Edit)
_editValue = null;
}
private async Task OnValueInput(ChangeEventArgs e)
{
var value = e.Value?.ToString() ?? "";
_editValue = value;
await DisplayValueChanged.InvokeAsync(value);
}
private async Task OnInputBlur(FocusEventArgs _)
{
if (OnBlur.HasDelegate)
await OnBlur.InvokeAsync(_editValue ?? EditValue);
}
}
@@ -0,0 +1,186 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Extensions.DependencyInjection
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using System.Text.Json.Serialization
@inject IHttpClientFactory HttpFactory
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@if (Visible)
{
<div class="config-option-help-popover" id="@_popoverId" style="@_positionStyle" @onmouseenter="OnModalMouseEnter" @onmouseleave="OnModalMouseLeave">
<div class="config-option-help-popover-content">
<div class="config-option-help-popover-header">
<span class="config-option-help-popover-title">@Title</span>
</div>
<div class="config-option-help-popover-body">
@if (_loading)
{
<p class="text-muted small mb-0">Loading…</p>
}
else if (_error != null)
{
<p class="text-danger small mb-0">@_error</p>
}
else if (_helpHtml != null)
{
<div class="option-help">@((MarkupString)_helpHtml)</div>
}
</div>
</div>
</div>
}
@code {
[Parameter] public string? HelpKey { get; set; }
[Parameter] public string? AnchorId { get; set; }
[Parameter] public bool Visible { get; set; }
[Parameter] public string Title { get; set; } = "Option help";
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnMouseEnterModal { get; set; }
[Parameter] public EventCallback OnMouseLeaveModal { get; set; }
private string? _helpHtml;
private string? _error;
private bool _loading;
private string? _lastKey;
private string _positionStyle = "";
private readonly string _popoverId = "config-option-help-popover-" + Guid.NewGuid().ToString("N")[..8];
private DotNetObjectReference<ConfigOptionHelpModal>? _dotNetRef;
private bool _scrollListenerAdded;
private bool _mouseOverModal;
private IJSObjectReference? _jsModule;
private bool _moduleLoaded;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
_moduleLoaded = true;
}
catch (InvalidOperationException) { /* prerender */ }
catch (JSDisconnectedException) { /* circuit disconnected */ }
catch (JSException) { /* import failed */ }
}
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
{
_scrollListenerAdded = true;
_dotNetRef = DotNetObjectReference.Create(this);
await _jsModule.InvokeVoidAsyncSafe("addScrollCloseListener", _dotNetRef);
}
else if (!Visible && _scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
}
}
[JSInvokable]
public async Task OnScrollClose()
{
await PositionUnderAnchorAsync();
StateHasChanged();
if (!_mouseOverModal)
await OnClose.InvokeAsync();
}
public async ValueTask DisposeAsync()
{
if (_scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
}
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
protected override async Task OnParametersSetAsync()
{
if (!Visible || string.IsNullOrEmpty(HelpKey))
{
_helpHtml = null;
_error = null;
_loading = false;
_positionStyle = "";
_mouseOverModal = false;
return;
}
if (HelpKey == _lastKey && _helpHtml != null)
{
await PositionUnderAnchorAsync();
return;
}
_lastKey = HelpKey;
_loading = true;
_error = null;
_helpHtml = null;
_positionStyle = "visibility: hidden;"; // avoid flash before position is set
StateHasChanged();
try
{
var http = HttpFactory.CreateClient(ServiceCollectionExtensions.DnsmasqApiClientName);
var response = await http.GetAsync($"option-help/{HelpKey}.html");
if (response.IsSuccessStatusCode)
_helpHtml = await response.Content.ReadAsStringAsync();
else
_error = "Help not available for this option.";
}
catch
{
_error = "Could not load help.";
}
finally
{
_loading = false;
await PositionUnderAnchorAsync();
StateHasChanged();
}
}
private async Task PositionUnderAnchorAsync()
{
if (string.IsNullOrEmpty(AnchorId) || _jsModule == null) return;
var rect = await _jsModule.InvokeAsyncSafe<BoundingRect?>("getAnchorRect", AnchorId);
if (rect is { } r)
{
var gap = 4;
var topPx = r.Bottom + gap;
var leftPx = r.Left;
_positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;";
}
else
{
_positionStyle = "visibility: visible;";
}
}
private async Task OnModalMouseEnter()
{
_mouseOverModal = true;
await OnMouseEnterModal.InvokeAsync();
}
private async Task OnModalMouseLeave()
{
_mouseOverModal = false;
await OnMouseLeaveModal.InvokeAsync();
}
private sealed record BoundingRect(
[property: JsonPropertyName("top")] double Top,
[property: JsonPropertyName("left")] double Left,
[property: JsonPropertyName("bottom")] double Bottom,
[property: JsonPropertyName("width")] double Width,
[property: JsonPropertyName("height")] double Height);
}
@@ -0,0 +1,66 @@
/* Popover anchored under the label (position set in code from anchor rect) */
.config-option-help-popover {
position: fixed;
z-index: 1060;
max-width: min(420px, calc(100vw - 1rem));
max-height: min(70vh, 400px);
overflow: auto;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
background: var(--bs-body-bg, #fff);
}
.config-option-help-popover-content {
display: flex;
flex-direction: column;
min-height: 0;
}
.config-option-help-popover-header {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
flex-shrink: 0;
}
.config-option-help-popover-title {
font-weight: 600;
font-size: 0.9rem;
}
.config-option-help-popover-body {
padding: 0.75rem;
overflow: auto;
flex: 1;
min-height: 0;
}
/* Option help content: man-page fragments (dt/dd, b, p, a) */
.config-option-help-popover-body .option-help dt {
font-weight: 600;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.config-option-help-popover-body .option-help dt:first-child {
margin-top: 0;
}
.config-option-help-popover-body .option-help dd {
margin-left: 0;
margin-bottom: 0.5rem;
padding-left: 0.75rem;
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.1));
font-size: 0.8rem;
}
.config-option-help-popover-body .option-help dd p {
margin-bottom: 0.35rem;
}
.config-option-help-popover-body .option-help dd p:last-child {
margin-bottom: 0;
}
.config-option-help-popover-body .option-help b {
font-weight: 600;
}
.config-option-help-popover-body .option-help a {
color: var(--bs-link-color, #0d6efd);
text-decoration: underline;
}
@@ -0,0 +1,155 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject IEffectiveConfigSaveService SaveService
@inject INotificationService NotificationService
@if (ShowModal)
{
<EffectiveConfigSaveModal
PendingChanges="@PendingChanges"
Status="@Status"
Session="@Session"
SaveState="@_saveState"
SaveResult="@_saveResult"
RestoreResult="@_restoreResult"
OnClose="@OnClose"
OnContinueEditing="@(EventCallback.Factory.Create(this, HandleContinueEditing))"
OnSave="@HandleSave"
OnRestore="@HandleRestore"
OnReverted="@HandleReverted"
OnCloseAndEndEdit="@HandleCloseAndEndEdit" />
}
@code {
public enum SaveUiState
{
Review,
Saving,
WriteFailed,
ValidateFailed,
ReloadFailed,
RestoreInProgress,
RestoreFailed
}
[Parameter] public bool ShowModal { get; set; }
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
/// <summary>Invoked when the modal reverts a change so the parent can re-run validation (e.g. cross-option evaluator).</summary>
[Parameter] public EventCallback OnRevertedNotify { get; set; }
[Parameter] public EventCallback OnSaveCompleted { get; set; }
[Parameter] public EventCallback OnContinueEditingAfterSavedFailure { get; set; }
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
private SaveUiState _saveState = SaveUiState.Review;
private EffectiveConfigSaveResult? _saveResult;
private EffectiveConfigRestoreResult? _restoreResult;
protected override void OnParametersSet()
{
if (!ShowModal)
{
_saveState = SaveUiState.Review;
_saveResult = null;
_restoreResult = null;
}
}
private async Task HandleReverted()
{
if (OnRevertedNotify.HasDelegate)
await OnRevertedNotify.InvokeAsync();
await InvokeAsync(StateHasChanged);
}
private async Task HandleSave()
{
if (Session.PendingChanges.Count == 0) return;
if (Session.HasBlockingValidationErrors())
{
NotificationService.ShowError("Fix validation errors before saving.");
return;
}
_saveState = SaveUiState.Saving;
_saveResult = null;
_restoreResult = null;
await InvokeAsync(StateHasChanged);
var result = await Session.ApplyAsync();
_saveResult = result;
if (result.Saved && result.Restarted)
{
var message = result.UserMessage ?? "Config saved and dnsmasq restarted.";
NotificationService.ShowSuccess(message);
_saveState = SaveUiState.Review;
_saveResult = null;
await OnClose.InvokeAsync();
await OnSaveCompleted.InvokeAsync();
return;
}
if (result.Saved && result.IsValidateFailed)
_saveState = SaveUiState.ValidateFailed;
else if (result.Saved && !result.Restarted)
_saveState = SaveUiState.ReloadFailed;
else if (!result.Saved)
_saveState = SaveUiState.WriteFailed;
else
_saveState = SaveUiState.Review;
await InvokeAsync(StateHasChanged);
}
private async Task HandleRestore(string backupPath)
{
if (string.IsNullOrEmpty(backupPath)) return;
_saveState = SaveUiState.RestoreInProgress;
_restoreResult = null;
await InvokeAsync(StateHasChanged);
var result = await SaveService.RestoreAsync(backupPath);
_restoreResult = result;
if (result.Restored && result.Restarted)
{
var message = result.UserMessage ?? "Backup restored and dnsmasq restarted.";
NotificationService.ShowSuccess(message);
_saveState = SaveUiState.Review;
_saveResult = null;
_restoreResult = null;
await OnCloseAndEndEdit.InvokeAsync();
await OnSaveCompleted.InvokeAsync();
return;
}
_saveState = SaveUiState.RestoreFailed;
await InvokeAsync(StateHasChanged);
}
private async Task HandleContinueEditing()
{
if (_saveState == SaveUiState.ValidateFailed || _saveState == SaveUiState.ReloadFailed)
{
await OnContinueEditingAfterSavedFailure.InvokeAsync();
return;
}
await OnClose.InvokeAsync();
}
private async Task HandleCloseAndEndEdit()
{
await OnCloseAndEndEdit.InvokeAsync();
if (_saveState == SaveUiState.ValidateFailed || _saveState == SaveUiState.ReloadFailed)
await OnSaveCompleted.InvokeAsync();
}
}
@@ -0,0 +1,343 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Helpers.Config
<div class="modal fade show" style="display: block;" tabindex="-1" role="dialog" aria-labelledby="ec-save-modal-title" aria-modal="true">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ec-save-modal-title">Save effective config changes</h5>
<button type="button" class="btn-close" @onclick="Close" aria-label="Close" disabled="@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)"></button>
</div>
<div class="modal-body ec-save-modal-body">
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
{
<div class="ec-save-modal-loading text-center py-4">
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div>
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving, validating and restarting…" : "Restoring backup and restarting…")</p>
</div>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed && SaveResult != null)
{
<div class="ec-save-modal-reload-failed">
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
<div class="ec-save-modal-alert-text">
<strong>Config was saved, but validation failed.</strong> Restart was not attempted. You can restore the backup or keep editing.
</div>
</div>
@if (SaveResult.BackupPath != null)
{
<p class="ec-save-modal-backup-path mb-2">
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
</p>
}
<div class="ec-save-modal-stderr mb-3">
<p class="text-muted small mb-1"><strong>Validation output</strong></p>
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetValidationFailedOutput(SaveResult)</pre>
</div>
</div>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
{
<div class="alert alert-danger mb-0">
<strong>Save failed.</strong> @SaveResult.UserMessage
@if (!string.IsNullOrEmpty(SaveResult.RestartStdErr))
{
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.RestartStdErr</pre>
}
</div>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult != null)
{
<div class="ec-save-modal-reload-failed">
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
<div class="ec-save-modal-alert-text">
<strong>Config was saved, but the restart command failed.</strong> You can restore the backup or keep editing.
</div>
</div>
@if (SaveResult.BackupPath != null)
{
<p class="ec-save-modal-backup-path mb-2">
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
</p>
}
<div class="ec-save-modal-stderr mb-3">
<p class="text-muted small mb-1"><strong>Command output</strong></p>
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestartFailedOutput(SaveResult)</pre>
</div>
</div>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed && RestoreResult != null)
{
<div class="ec-save-modal-restore-failed">
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
<div class="ec-save-modal-alert-text">@RestoreResult.UserMessage</div>
</div>
<div class="ec-save-modal-stderr mb-3">
<p class="text-muted small mb-1"><strong>Command output</strong></p>
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestoreFailedOutput(RestoreResult)</pre>
</div>
</div>
}
else
{
@if (ValidationErrors.Count > 0 || ValidationWarnings.Count > 0)
{
<div class="ec-save-modal-validation mb-3">
@if (ValidationErrors.Count > 0)
{
<div class="alert alert-danger py-2 mb-2">
<strong>Validation errors</strong>
<ul class="mb-0 mt-1 ps-3">
@foreach (var issue in ValidationErrors)
{
<li>@issue.Message</li>
}
</ul>
</div>
}
@if (ValidationWarnings.Count > 0)
{
<div class="alert alert-warning py-2 mb-2">
<strong>Validation warnings</strong>
<ul class="mb-0 mt-1 ps-3">
@foreach (var issue in ValidationWarnings)
{
<li>@issue.Message</li>
}
</ul>
</div>
}
</div>
}
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
@if (ManagedFilePath != null)
{
<p class="ec-save-modal-target mb-3">
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
</p>
}
@if (PendingChanges.Count == 0)
{
<div class="ec-save-modal-empty alert alert-light border text-center text-muted py-4 mb-0">
No pending changes. Close to continue editing.
</div>
}
else
{
<div class="ec-save-modal-changes list-group list-group-flush">
@foreach (var c in PendingChanges)
{
var change = c;
var oldList = AsStringList(change.OldValue);
var newList = AsStringList(change.NewValue);
var isMultiValue = oldList != null || newList != null;
<div class="list-group-item ec-save-modal-item d-flex align-items-start gap-3 py-3 px-0 border-0 border-bottom">
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
<div class="d-flex align-items-center gap-2">
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="@change.OptionName" Class="ec-save-modal-option me-2" />
@if (!isMultiValue)
{
<span class="ec-save-modal-diff text-body">
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue, change.OptionName)</span>
<span class="text-muted mx-1" aria-hidden="true">→</span>
<span>@FormatValue(change.NewValue, change.OptionName)</span>
</span>
}
</div>
@if (isMultiValue)
{
var oldSeq = oldList ?? Array.Empty<string>();
var newSeq = newList ?? Array.Empty<string>();
var added = newSeq.Except(oldSeq).ToList();
var removed = oldSeq.Except(newSeq).ToList();
var orderOrDuplicatesChanged = added.Count == 0 && removed.Count == 0 && !oldSeq.SequenceEqual(newSeq, StringComparer.Ordinal);
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
@if (orderOrDuplicatesChanged)
{
<div class="text-muted">Order or duplicates changed:</div>
<div class="text-muted text-decoration-line-through">Before: @string.Join(", ", oldSeq)</div>
<div class="text-success">After: @string.Join(", ", newSeq)</div>
}
else
{
@foreach (var val in removed)
{
<div class="text-muted text-decoration-line-through">− @val</div>
}
@foreach (var val in added)
{
<div class="text-success">+ @val</div>
}
}
</div>
}
@if (change.CurrentSourceFilePath != null)
{
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
}
</div>
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="() => Revert(change)" title="Revert this change" aria-label="Revert @change.OptionName">
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
</button>
</div>
}
</div>
}
}
</div>
<div class="modal-footer ec-save-modal-footer">
@if ((SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed) && SaveResult?.BackupPath != null)
{
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backup</button>
<button type="button" class="btn btn-outline-warning" @onclick="ContinueEditing"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
{
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
{
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
}
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
{
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0 || HasBlockingValidationErrors)"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
}
</div>
</div>
</div>
<div class="modal-backdrop fade show" style="display: block;" @onclick="OnBackdropClick"></div>
</div>
@code {
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
[Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; }
[Parameter] public EffectiveConfigSaveResult? SaveResult { get; set; }
[Parameter] public EffectiveConfigRestoreResult? RestoreResult { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnContinueEditing { get; set; }
[Parameter] public EventCallback OnSave { get; set; }
[Parameter] public EventCallback<string> OnRestore { get; set; }
[Parameter] public EventCallback OnReverted { get; set; }
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress;
private string? ManagedFilePath => Status?.ManagedFilePath;
private IReadOnlyList<FieldIssue> ValidationErrors => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
private IReadOnlyList<FieldIssue> ValidationWarnings => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Warning).ToList();
private bool HasBlockingValidationErrors => Session.HasBlockingValidationErrors();
private static string GetValidationFailedOutput(EffectiveConfigSaveResult result)
{
var err = (result.ValidationStdErr ?? "").Trim();
var outp = (result.ValidationStdOut ?? "").Trim();
if (err.Length > 0 && outp.Length > 0)
return err + "\n\n" + outp;
if (err.Length > 0) return err;
if (outp.Length > 0) return outp;
var exitInfo = result.ValidationExitCode >= 0 ? $"Exit code: {result.ValidationExitCode}." : "";
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
}
private static string GetRestartFailedOutput(EffectiveConfigSaveResult result)
{
var err = (result.RestartStdErr ?? "").Trim();
var outp = (result.RestartStdOut ?? "").Trim();
if (err.Length > 0 && outp.Length > 0)
return err + "\n\n" + outp;
if (err.Length > 0) return err;
if (outp.Length > 0) return outp;
var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : "";
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
}
private static string GetRestoreFailedOutput(EffectiveConfigRestoreResult result)
{
var err = (result.RestartStdErr ?? "").Trim();
if (err.Length > 0) return err;
var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : "";
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
}
/// <summary>If the value is a list of strings (multi-value option), return it; otherwise null.</summary>
private static IReadOnlyList<string>? AsStringList(object? v)
{
if (v == null) return null;
if (v is IReadOnlyList<string> ro) return ro;
if (v is List<string> list) return list;
if (v is string[] arr) return arr;
return null;
}
private static string FormatValue(object? v, string? optionName = null)
{
if (optionName != null && EffectiveConfigWriteSemantics.GetBehavior(optionName) == EffectiveConfigWriteBehavior.KeyOnlyOrValue)
return FormatKeyOnlyOrValue(v);
if (v == null) return "(not set)";
if (v is bool b) return b ? "on" : "off";
var list = AsStringList(v);
if (list != null) return list.Count == 0 ? "(none)" : string.Join(", ", list);
return v.ToString() ?? "(not set)";
}
private static string FormatKeyOnlyOrValue(object? v)
{
if (v == null) return "(not set)";
var s = v.ToString();
if (string.IsNullOrEmpty(s)) return "(set)";
return s;
}
private async Task Revert(PendingEffectiveConfigChange change)
{
Session.RevertChange(change.SectionId, change.OptionName);
await OnReverted.InvokeAsync();
}
private async Task OnBackdropClick()
{
if (!AllowClose) return;
await Close();
}
private async Task Close()
{
if (!AllowClose) return;
await OnClose.InvokeAsync();
}
private async Task ContinueEditing()
{
if (!AllowClose) return;
await OnContinueEditing.InvokeAsync();
}
private async Task Save()
{
await OnSave.InvokeAsync();
}
private async Task Restore(string backupPath)
{
await OnRestore.InvokeAsync(backupPath);
}
private async Task CloseAndEndEdit()
{
if (!AllowClose) return;
await OnCloseAndEndEdit.InvokeAsync();
}
}
@@ -0,0 +1,87 @@
/* Keep footer buttons on one line */
.ec-save-modal-footer {
flex-wrap: nowrap;
}
/* Ensure the dialog stacks above the backdrop when both are siblings inside .modal */
.modal-dialog {
position: relative;
z-index: 1056;
}
.modal-backdrop {
z-index: 1050;
}
.ec-save-modal-body {
padding-top: 0.25rem;
}
.ec-save-modal-target {
font-size: 0.9rem;
}
.ec-save-modal-changes {
margin: 0 -1rem;
padding: 0 1rem;
}
.ec-save-modal-item:last-child {
border-bottom: 0 !important;
}
.ec-save-modal-option {
font-weight: 500;
font-size: 0.75rem;
}
.ec-save-modal-diff {
font-size: 0.9375rem;
}
.ec-save-modal-override {
font-size: 0.8125rem;
}
.ec-save-modal-empty {
font-size: 0.9rem;
}
.ec-save-modal-loading {
min-height: 6rem;
}
.ec-save-modal-backup-path {
font-size: 0.9rem;
}
.ec-save-modal-backup-code {
word-break: break-all;
}
/* Command output: ensure text is always visible (avoid inherited color/visibility issues) */
.ec-save-modal-pre {
font-size: 0.8125rem;
max-height: 12rem;
overflow: auto;
color: var(--bs-body-color, #212529);
white-space: pre-wrap;
word-break: break-word;
min-height: 2.5rem;
}
/* Alert icon in reload/restore failed states: larger, spacing, aligned with text */
.ec-save-modal-alert {
--ec-save-modal-alert-icon-gap: 0.5rem;
}
.ec-save-modal-alert-icon {
flex-shrink: 0;
font-size: 1.25rem;
margin-right: var(--ec-save-modal-alert-icon-gap);
line-height: 1;
}
.ec-save-modal-alert-text {
min-width: 0;
}
@@ -0,0 +1,57 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<ConfigOptionHelpModal HelpKey="@HelpKey" AnchorId="@AnchorId" Visible="@Visible" Title="@Title"
OnClose="RequestClose" OnMouseEnterModal="CancelCloseTimer" OnMouseLeaveModal="ScheduleClose" />
@code {
[Parameter] public bool Visible { get; set; }
[Parameter] public string? HelpKey { get; set; }
[Parameter] public string? AnchorId { get; set; }
[Parameter] public string Title { get; set; } = "Option help";
[Parameter] public EventCallback OnCloseRequested { get; set; }
private CancellationTokenSource? _closeCts;
private void CancelCloseTimer()
{
_closeCts?.Cancel();
_closeCts = null;
}
private void ScheduleClose()
{
_closeCts?.Cancel();
_closeCts = new CancellationTokenSource();
var token = _closeCts.Token;
_ = Task.Run(async () =>
{
try { await Task.Delay(300, token); }
catch (OperationCanceledException) { return; }
await InvokeAsync(async () =>
{
if (token.IsCancellationRequested) return;
_closeCts = null;
await OnCloseRequested.InvokeAsync();
});
}, token);
}
private async Task RequestClose()
{
CancelCloseTimer();
await OnCloseRequested.InvokeAsync();
}
/// <summary>Called by the section when the label loses mouse (so we schedule close; if user moves to modal, OnMouseEnterModal cancels).</summary>
public void ScheduleCloseFromLabelLeave()
{
ScheduleClose();
}
/// <summary>Called when opening or switching help (e.g. user clicked another label); cancels any pending 300ms close so the timer from a previous label leave does not dismiss the new help.</summary>
public void CancelCloseFromLabelEnter()
{
CancelCloseTimer();
}
}
@@ -0,0 +1,40 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<span id="@AnchorId" class="@EffectiveCssClass" title="@Title" @onclick="OnClick" @onmouseleave="OnMouseLeave">
@ChildContent
</span>
@code {
[Parameter] public string AnchorId { get; set; } = "";
[Parameter] public string? HelpKey { get; set; }
[Parameter] public string OptionLabel { get; set; } = "";
[Parameter] public string? CssClass { get; set; }
private string EffectiveCssClass =>
CssClass ?? (!string.IsNullOrEmpty(HelpKey) ? "ec-field-label ec-field-label-help" : "ec-field-label");
[Parameter] public string? Title { get; set; }
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
private Task OnClick()
{
if (Ui == null || string.IsNullOrEmpty(HelpKey)) return Task.CompletedTask;
return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs
{
HelpKey = HelpKey,
OptionLabel = OptionLabel,
AnchorId = AnchorId
});
}
private Task OnMouseLeave()
{
if (Ui == null) return Task.CompletedTask;
return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs
{
AnchorId = AnchorId,
IsLabelMouseLeave = true
});
}
}
@@ -0,0 +1,143 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@if (IsCapabilityDisabled)
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly"
Text="disabled"
Title="@CapabilityDisabledReason"
Class="status-badge-inline" />
}
else if (Source?.IsReadOnly == true)
{
var readonlyTitle = !string.IsNullOrEmpty(ReadonlyTitleOverride) ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip();
if (ReadonlyClickActivatesField && Ui != null && !string.IsNullOrEmpty(FieldKey))
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
Id="@AnchorId" Title="@readonlyTitle"
OnClick="OnEditableClick"
Class="status-badge-inline" />
}
else if (IsInteractive && Ui != null)
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
Id="@AnchorId" Title="@readonlyTitle"
OnClick="OnReadonlyClick" OnMouseLeave="OnReadonlyMouseLeave"
Class="status-badge-inline" />
}
else
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly"
Title="@readonlyTitle"
Class="status-badge-inline" />
}
}
else if (ShowEditableBadge)
{
@if (EditableAsButton)
{
@if (IsActiveEditor)
{
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="ok" Clickable="true"
Title="Apply this field change" AriaLabel="Apply change"
PreventBlurOnMouseDown="true" OnClick="OnConfirmClick" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard in-progress edits for this field" AriaLabel="Cancel edit"
PreventBlurOnMouseDown="true" OnClick="OnCancelClick" />
@if (HasPendingChange)
{
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="revert" Clickable="true"
Title="Remove this field from pending changes" AriaLabel="Revert pending change"
PreventBlurOnMouseDown="true" OnClick="OnRevertClick" />
}
</span>
}
else
{
<StatusBadge Kind="@(HasPendingChange ? StatusBadgeKind.Pending : StatusBadgeKind.Editable)"
Text="@(HasPendingChange ? "pending" : "editable")"
Clickable="true"
Title="@(HasPendingChange ? "Pending change" : "Edit this field")"
AriaLabel="Edit"
OnClick="OnEditableClick"
Class="status-badge-inline" />
}
}
else
{
<StatusBadge Kind="StatusBadgeKind.Editable" Text="editable"
Class="status-badge-inline" />
}
}
@code {
[Parameter] public string AnchorId { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public object? Value { get; set; }
[Parameter] public bool IsInteractive { get; set; }
[Parameter] public bool ShowEditableBadge { get; set; }
[Parameter] public bool HasPendingChange { get; set; }
[Parameter] public bool EditableAsButton { get; set; } = true;
[Parameter] public bool IsActiveEditor { get; set; }
[Parameter] public EventCallback OnConfirmRequested { get; set; }
[Parameter] public EventCallback OnCancelRequested { get; set; }
[Parameter] public EventCallback OnRevertRequested { get; set; }
[Parameter] public string? FieldKey { get; set; }
/// <summary>Optional: when set, editable-badge click invokes this instead of activating the field by key (e.g. list editor per-item edit).</summary>
[Parameter] public EventCallback OnEditableClickRequested { get; set; }
/// <summary>When true, readonly badge click activates the field for editing instead of showing the readonly popover (e.g. for multi-value where new items can still be added).</summary>
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
/// <summary>Optional tooltip for readonly badge when <see cref="ReadonlyClickActivatesField"/> is true (e.g. "New values can still be added. Click to edit.").</summary>
[Parameter] public string? ReadonlyTitleOverride { get; set; }
/// <summary>When true, option is disabled because dnsmasq build does not support it (e.g. no DNSSEC).</summary>
[Parameter] public bool IsCapabilityDisabled { get; set; }
/// <summary>Tooltip shown when <see cref="IsCapabilityDisabled"/> is true (e.g. "This dnsmasq build does not support DNSSEC; option not written.").</summary>
[Parameter] public string? CapabilityDisabledReason { get; set; }
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
private async Task OnConfirmClick()
{
if (OnConfirmRequested.HasDelegate)
await OnConfirmRequested.InvokeAsync();
}
private async Task OnCancelClick()
{
if (OnCancelRequested.HasDelegate)
await OnCancelRequested.InvokeAsync();
else if (Ui != null)
await Ui.DeactivateFieldAsync();
}
private async Task OnRevertClick()
{
if (OnRevertRequested.HasDelegate)
await OnRevertRequested.InvokeAsync();
}
private async Task OnEditableClick()
{
if (OnEditableClickRequested.HasDelegate)
{
await OnEditableClickRequested.InvokeAsync();
return;
}
if (Ui != null && !string.IsNullOrEmpty(FieldKey))
await Ui.ActivateFieldAsync(FieldKey);
}
private Task OnReadonlyClick()
{
if (Ui == null || Source == null) return Task.CompletedTask;
return Ui.RequestReadonlyPopoverAsync(new ReadonlyBadgeClickedEventArgs(AnchorId, Source, OptionName, Value));
}
private Task OnReadonlyMouseLeave()
{
if (Ui == null) return Task.CompletedTask;
return Ui.ScheduleReadonlyPopoverCloseAsync();
}
}
@@ -0,0 +1,47 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@if (Source is { IsReadOnly: true })
{
<div class="ec-readonly-hint small text-muted mt-1">
<span class="d-block">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</span>
@if (!string.IsNullOrEmpty(_removeCommand))
{
<div class="mt-1">
<CopyableCodeLine Text="@_removeCommand" CssClass="small" CodeCssClass="small text-break" CopyTitle="Copy command" />
</div>
}
@if (!string.IsNullOrEmpty(_overrideLine))
{
<span class="d-block mt-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</span>
<div class="mt-1">
<CopyableCodeLine Text="@_overrideLine" CodeCssClass="small" CopyTitle="Copy line" />
</div>
}
</div>
}
@code {
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public object? Value { get; set; }
[Parameter] public string? ManagedFilePath { get; set; }
private string _removeCommand = "";
private string _overrideLine = "";
protected override void OnParametersSet()
{
if (Source is { IsReadOnly: true })
{
_removeCommand = ReadonlyConfigCommandGenerator.GetRemoveLineCommand(Source, OptionName);
_overrideLine = ReadonlyConfigCommandGenerator.GetOverrideLine(OptionName, Value, ManagedFilePath);
}
else
{
_removeCommand = "";
_overrideLine = "";
}
}
}
@@ -0,0 +1,171 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Extensions
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using System.Text.Json.Serialization
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@if (Visible && Source != null)
{
<div class="ec-readonly-popover" id="@_popoverId" style="@_positionStyle" @onmouseenter="OnPopoverMouseEnter" @onmouseleave="OnPopoverMouseLeave">
<div class="ec-readonly-popover-content">
<div class="ec-readonly-popover-header">
<span class="ec-readonly-popover-title">Read-only: @OptionName</span>
</div>
<div class="ec-readonly-popover-body small">
<p class="text-muted mb-2">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</p>
@if (!string.IsNullOrEmpty(_removeCommand))
{
<div class="mb-2">
<CopyableCodeLine Text="@_removeCommand" CodeCssClass="small" CopyTitle="Copy command" />
</div>
}
@if (!string.IsNullOrEmpty(_overrideLine))
{
<p class="text-muted mb-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</p>
<CopyableCodeLine Text="@_overrideLine" CodeCssClass="small" CopyTitle="Copy line" />
}
</div>
</div>
</div>
}
@code {
[Parameter] public bool Visible { get; set; }
[Parameter] public string? AnchorId { get; set; }
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public object? Value { get; set; }
[Parameter] public string? ManagedFilePath { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnMouseEnterPopover { get; set; }
[Parameter] public EventCallback OnMouseLeavePopover { get; set; }
private const string HiddenPositionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
private const string VisiblePositionStyleFormat = "position: fixed; top: {0}px; left: {1}px; visibility: visible;";
private string _positionStyle = "";
private readonly string _popoverId = "ec-readonly-popover-" + Guid.NewGuid().ToString("N")[..8];
private DotNetObjectReference<ReadonlyEditPopover>? _dotNetRef;
private bool _scrollListenerAdded;
private bool _mouseOverPopover;
private IJSObjectReference? _jsModule;
private bool _moduleLoaded;
private string _removeCommand = "";
private string _overrideLine = "";
protected override void OnParametersSet()
{
if (Source is { IsReadOnly: true })
{
_removeCommand = ReadonlyConfigCommandGenerator.GetRemoveLineCommand(Source, OptionName);
_overrideLine = ReadonlyConfigCommandGenerator.GetOverrideLine(OptionName, Value, ManagedFilePath);
}
else
{
_removeCommand = "";
_overrideLine = "";
}
if (Visible && string.IsNullOrEmpty(_positionStyle))
_positionStyle = HiddenPositionStyle;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
_moduleLoaded = true;
}
catch (InvalidOperationException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
}
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
{
_scrollListenerAdded = true;
_dotNetRef = DotNetObjectReference.Create(this);
await _jsModule.InvokeVoidAsyncSafe("addScrollCloseListener", _dotNetRef);
}
else if (!Visible && _scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
}
if (!Visible)
_positionStyle = "";
if (Visible && _moduleLoaded && _jsModule != null)
{
await PositionUnderAnchorAsync();
StateHasChanged();
}
}
[JSInvokable]
public async Task OnScrollClose()
{
await PositionUnderAnchorAsync();
StateHasChanged();
if (!_mouseOverPopover)
await OnClose.InvokeAsync();
}
public async ValueTask DisposeAsync()
{
if (_scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
}
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
private async Task PositionUnderAnchorAsync()
{
if (string.IsNullOrEmpty(AnchorId) || _jsModule == null) return;
var rect = await _jsModule.InvokeAsyncSafe<BoundingRect?>("getAnchorRect", AnchorId);
if (rect is { } r)
{
var gap = 4;
var topPx = r.Bottom + gap;
var leftPx = r.Left;
_positionStyle = string.Format(VisiblePositionStyleFormat, topPx, leftPx);
}
else
{
_positionStyle = HiddenPositionStyle;
}
}
private async Task OnPopoverMouseEnter()
{
_mouseOverPopover = true;
await OnMouseEnterPopover.InvokeAsync();
}
private async Task OnPopoverMouseLeave()
{
_mouseOverPopover = false;
await OnMouseLeavePopover.InvokeAsync();
}
private sealed record BoundingRect(
[property: JsonPropertyName("top")] double Top,
[property: JsonPropertyName("left")] double Left,
[property: JsonPropertyName("bottom")] double Bottom,
[property: JsonPropertyName("width")] double Width,
[property: JsonPropertyName("height")] double Height);
}
@@ -0,0 +1,36 @@
/* Readonly edit popover: anchored under the badge; same look as option help popover */
.ec-readonly-popover {
position: fixed;
z-index: 1060;
max-width: min(420px, calc(100vw - 1rem));
max-height: min(70vh, 400px);
overflow: auto;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
background: var(--bs-body-bg, #fff);
}
.ec-readonly-popover-content {
display: flex;
flex-direction: column;
min-height: 0;
}
.ec-readonly-popover-header {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
flex-shrink: 0;
}
.ec-readonly-popover-title {
font-weight: 600;
font-size: 0.9rem;
}
.ec-readonly-popover-body {
padding: 0.75rem;
overflow: auto;
flex: 1;
min-height: 0;
}
@@ -0,0 +1,64 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<ReadonlyEditPopover Visible="@Visible" AnchorId="@AnchorId" Source="@Source" OptionName="@OptionName" Value="@Value" ManagedFilePath="@ManagedFilePath"
OnClose="RequestClose" OnMouseEnterPopover="CancelCloseTimer" OnMouseLeavePopover="ScheduleClose" />
@code {
[Parameter] public bool Visible { get; set; }
[Parameter] public string? AnchorId { get; set; }
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public object? Value { get; set; }
[Parameter] public string? ManagedFilePath { get; set; }
[Parameter] public EventCallback OnCloseRequested { get; set; }
private CancellationTokenSource? _closeCts;
private void CancelCloseTimer()
{
_closeCts?.Cancel();
_closeCts = null;
}
private void ScheduleClose()
{
_closeCts?.Cancel();
_closeCts = new CancellationTokenSource();
var token = _closeCts.Token;
_ = Task.Run(async () =>
{
try { await Task.Delay(300, token); }
catch (OperationCanceledException) { return; }
await InvokeAsync(async () =>
{
if (token.IsCancellationRequested) return;
_closeCts = null;
await OnCloseRequested.InvokeAsync();
});
}, token);
}
private async Task RequestClose()
{
CancelCloseTimer();
await OnCloseRequested.InvokeAsync();
}
/// <summary>
/// Called when mouse leaves readonly badge; close unless pointer enters popover quickly.
/// </summary>
public void ScheduleCloseFromBadgeLeave()
{
ScheduleClose();
}
/// <summary>
/// Called when readonly badge is interacted with to keep popover open.
/// </summary>
public void CancelCloseFromBadgeEnter()
{
CancelCloseTimer();
}
}
@@ -4,11 +4,11 @@
@Path
@if (IsEditable)
{
<span class="badge bg-primary ms-1">editable</span>
<StatusBadge Kind="StatusBadgeKind.Editable" Text="editable" Class="ms-1" />
}
else
{
<span class="badge bg-secondary ms-1"> read-only</span>
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="read-only" Class="ms-1" />
}
</h3>
<div class="table-responsive-cards">
@@ -1,5 +1,6 @@
@inherits LayoutComponentBase
@namespace DnsmasqWebUI.Components.Layout
@using DnsmasqWebUI.Components.Shared
@inject ISettingsModalService SettingsModalService
@implements IDisposable
@@ -17,6 +18,7 @@
</div>
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
<NotificationHost />
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
@@ -29,6 +29,8 @@ main.main {
}
.sidebar {
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #052767 0%, #3a0647 70%);
}
@@ -1,6 +1,11 @@
@namespace DnsmasqWebUI.Components.Layout
@using System.Reflection
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject IOptions<ApplicationOptions> AppOptions
@inject ISettingsModalService SettingsModalService
@inject IUpdateCheckService UpdateCheckService
@inject INotificationService NotificationService
@implements IDisposable
<div class="top-row ps-3 navbar navbar-dark">
<div class="top-row-inner">
@@ -34,11 +39,96 @@
<i class="bi bi-hdd-network nav-icon" aria-hidden="true"></i> DHCP
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="dns-records">
<i class="bi bi-diagram-3-fill nav-icon" aria-hidden="true"></i> DNS records
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="filters">
<i class="bi bi-funnel-fill nav-icon" aria-hidden="true"></i> Filters
</NavLink>
</div>
</nav>
<div class="nav-footer px-3 py-2" aria-label="Links and version">
<a class="nav-footer-link" href="https://github.com/alexhopeoconnor/dnsmasq-webui" target="_blank" rel="noopener noreferrer" title="dnsmasq-webui project on GitHub">Project</a>
<span class="nav-footer-sep" aria-hidden="true">·</span>
<a class="nav-footer-link" href="https://thekelleys.org.uk/dnsmasq/doc.html" target="_blank" rel="noopener noreferrer" title="Official dnsmasq documentation">Docs</a>
<span class="nav-footer-sep" aria-hidden="true">·</span>
<a class="nav-footer-link" href="@ReleaseUrl" target="_blank" rel="noopener noreferrer" title="View release on GitHub">v@(BaseVersion(Version))</a>
<span class="nav-footer-version-actions" aria-hidden="true">[
@if (UpdateCheckService.NewerVersionAvailable)
{
<a class="nav-footer-link nav-footer-new-release" href="@UpdateCheckService.NewerVersionUrl" target="_blank" rel="noopener noreferrer" title="New version @(UpdateCheckService.NewerVersionTag ?? "") is available" aria-label="New version @(UpdateCheckService.NewerVersionTag ?? "") is available">
<i class="bi bi-download" aria-hidden="true"></i>
</a>
}
else
{
<button type="button" class="nav-footer-link nav-footer-check-btn" @onclick="ForceCheckAsync" disabled="@UpdateCheckService.CheckInProgress" title="@GetCheckButtonTooltip()" aria-label="@GetCheckButtonTooltip()">
<i class="bi bi-arrow-repeat nav-footer-check-icon @(UpdateCheckService.CheckInProgress ? "nav-footer-spin" : null)" aria-hidden="true"></i>
</button>
}
]</span>
</div>
</div>
@code {
private const string ReleasesBaseUrl = "https://github.com/alexhopeoconnor/dnsmasq-webui/releases";
private static readonly string Version = typeof(NavMenu).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "?";
/// <summary>Base version without build metadata (e.g. 0.0.4+abc123 -> 0.0.4) for GitHub tag URLs.</summary>
private static string BaseVersion(string v)
{
if (string.IsNullOrEmpty(v) || v == "?") return v;
var i = v.IndexOf('+');
return i >= 0 ? v[..i] : v;
}
private string ReleaseUrl => Version != "?"
? $"{ReleasesBaseUrl}/tag/v{BaseVersion(Version)}"
: ReleasesBaseUrl;
private string? AppTitle => AppOptions?.Value?.EffectiveTitle;
protected override void OnInitialized()
{
UpdateCheckService.ResultChanged += OnUpdateCheckResultChanged;
}
public void Dispose()
{
UpdateCheckService.ResultChanged -= OnUpdateCheckResultChanged;
}
private void OnUpdateCheckResultChanged(object? sender, EventArgs e)
{
_ = InvokeAsync(StateHasChanged);
}
private string GetCheckButtonTooltip()
{
if (UpdateCheckService.CheckInProgress)
return "Checking…";
var last = UpdateCheckService.LastCheckTime;
if (last.HasValue)
{
var local = last.Value.ToLocalTime();
var format = local.Date == DateTime.Now.Date ? "t" : "g"; // time only today, short date+time otherwise
return $"No new update available. Checked at {local.ToString(format)}. Check again.";
}
return "Check for updates";
}
private async Task ForceCheckAsync()
{
await UpdateCheckService.CheckNowAsync();
if (UpdateCheckService.LastCheckFailed)
NotificationService.ShowError("Update check failed: " + (UpdateCheckService.LastCheckErrorMessage ?? "Unknown error."));
else if (UpdateCheckService.NewerVersionAvailable)
NotificationService.ShowSuccess($"New version {UpdateCheckService.NewerVersionTag} is available.");
else
NotificationService.ShowInfo("You're up to date.");
}
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
}
@@ -131,13 +131,86 @@
color: white;
}
.nav-footer {
margin-top: auto;
flex-shrink: 0;
font-size: 0.8125rem;
text-align: right;
}
.nav-footer-link {
color: rgba(255, 255, 255, 0.65) !important;
text-decoration: none;
}
.nav-footer-link:hover {
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-footer-sep {
color: rgba(255, 255, 255, 0.45);
margin: 0 0.35rem;
user-select: none;
}
.nav-footer-version-actions {
margin-left: 0.15rem;
color: rgba(255, 255, 255, 0.65);
}
.nav-footer-version-actions .nav-footer-link,
.nav-footer-version-actions .nav-footer-check-btn {
color: inherit;
}
.nav-footer-new-release {
display: inline-flex;
align-items: center;
}
.nav-footer-new-release:hover {
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-footer-check-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
color: inherit;
}
.nav-footer-check-btn:disabled {
cursor: default;
opacity: 0.8;
}
.nav-footer-check-btn:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-footer-check-icon {
display: inline-block; /* required for transform animation */
}
.nav-footer-spin {
animation: nav-footer-spin 0.8s linear infinite;
}
@keyframes nav-footer-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.nav-scrollable {
display: none;
flex-direction: column;
}
/* Toggler is inside top-row; nav-scrollable is sibling of top-row */
.top-row:has(.navbar-toggler:checked) ~ .nav-scrollable {
display: block;
display: flex;
}
@media (min-width: 641px) {
@@ -146,11 +219,10 @@
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
/* Never collapse the sidebar for wide screens; flex to fill remaining sidebar height */
display: flex;
flex: 1;
min-height: 0;
overflow-y: auto;
}
}
@@ -28,87 +28,126 @@
@if (SettingsContext == SettingsModalContext.All)
{
<div class="mb-3">
<label class="form-label">Search settings</label>
<div class="d-flex align-items-center gap-2 mb-1">
<label class="form-label mb-0">Search settings</label>
<div class="d-flex gap-1 ms-auto" role="group" aria-label="Expand or collapse all sections">
<button type="button" class="btn btn-sm btn-outline-secondary settings-expand-collapse-btn" title="Expand all sections" aria-label="Expand all sections" @onclick="ExpandAllGroups">
<i class="bi bi-arrows-expand" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary settings-expand-collapse-btn" title="Collapse all sections" aria-label="Collapse all sections" @onclick="CollapseAllGroups">
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
</button>
</div>
</div>
<input type="search" class="form-control" placeholder="Search..." autocomplete="off"
@bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
</div>
}
@if (ShouldShowSection(SettingsModalSections.ServiceStatus))
@foreach (var group in GetVisibleGroups())
{
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
<input id="status-interval" type="number" class="form-control" min="@ClientSettingsFields.ServiceStatusPollingInterval.Min" max="@ClientSettingsFields.ServiceStatusPollingInterval.Max" step="1"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.Logs))
{
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="logs-interval" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsPollingInterval.Min" max="@ClientSettingsFields.RecentLogsPollingInterval.Max" step="1"
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.AppLogs))
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="app-logs-interval" type="number" class="form-control" min="@ClientSettingsFields.AppLogsPollingInterval.Min" max="@ClientSettingsFields.AppLogsPollingInterval.Max" step="1"
@bind="_editingSettings.AppLogsPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.Leases))
{
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
<input id="leases-interval" type="number" class="form-control" min="@ClientSettingsFields.LeasesPollingInterval.Min" max="@ClientSettingsFields.LeasesPollingInterval.Max" step="1"
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.RecentLogsDisplay))
{
var meta = SettingsModalSections.All[SettingsModalSections.RecentLogsDisplay];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<div class="mb-2">
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
<input id="recent-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsMaxLines.Min" max="@ClientSettingsFields.RecentLogsMaxLines.Max" step="1"
@bind="_editingSettings.RecentLogsMaxLines" />
var isAllSections = SettingsContext == SettingsModalContext.All;
var isExpanded = isAllSections ? IsGroupExpanded(group) : true;
<div class="settings-group">
@if (isAllSections)
{
<button type="button" class="settings-group-header @(isExpanded ? "expanded" : "")"
@onclick="() => ToggleGroup(group.Id)"
aria-expanded="@isExpanded"
aria-controls="group-@group.Id">
<span class="settings-group-title">@group.DisplayName</span>
<span class="settings-group-chevron" aria-hidden="true"></span>
</button>
}
else
{
<h6 class="settings-group-title settings-group-title-static mb-2 mt-3">@group.DisplayName</h6>
}
<div id="group-@group.Id" class="settings-group-content @(isExpanded ? "expanded" : "")"
role="region" aria-label="@group.DisplayName">
@foreach (var key in group.SectionKeys)
{
@if (ShouldShowSection(key))
{
<div class="settings-section">
@switch (key)
{
case SettingsModalSections.ServiceStatus:
{
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
var field = ClientSettingsFields.ServiceStatusPollingInterval;
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
<input id="status-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.Logs:
{
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
var field = ClientSettingsFields.RecentLogsPollingInterval;
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="logs-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.AppLogs:
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
var field = ClientSettingsFields.AppLogsPollingInterval;
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="app-logs-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.Leases:
{
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
var field = ClientSettingsFields.LeasesPollingInterval;
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
<input id="leases-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.RecentLogsDisplay:
{
var maxLinesField = ClientSettingsFields.RecentLogsMaxLines;
var autoScrollField = ClientSettingsFields.RecentLogsAutoScroll;
<div class="mb-2">
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
<input id="recent-logs-max-lines" type="number" class="form-control" min="@maxLinesField.Min" max="@maxLinesField.Max" step="1"
@bind="maxLinesField.Value" />
</div>
<div class="form-check">
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="autoScrollField.Value" />
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
break;
}
case SettingsModalSections.AppLogsDisplay:
{
var maxLinesField = ClientSettingsFields.AppLogsMaxLines;
var autoScrollField = ClientSettingsFields.AppLogsAutoScroll;
<div class="mb-2">
<label for="app-logs-max-lines" class="form-label">Max lines</label>
<input id="app-logs-max-lines" type="number" class="form-control" min="@maxLinesField.Min" max="@maxLinesField.Max" step="1"
@bind="maxLinesField.Value" />
</div>
<div class="form-check">
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="autoScrollField.Value" />
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
break;
}
}
</div>
}
}
</div>
<div class="form-check">
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.RecentLogsAutoScroll" />
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
</section>
}
@if (ShouldShowSection(SettingsModalSections.AppLogsDisplay))
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogsDisplay];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<div class="mb-2">
<label for="app-logs-max-lines" class="form-label">Max lines</label>
<input id="app-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.AppLogsMaxLines.Min" max="@ClientSettingsFields.AppLogsMaxLines.Max" step="1"
@bind="_editingSettings.AppLogsMaxLines" />
</div>
<div class="form-check">
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.AppLogsAutoScroll" />
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
</section>
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save">Save</button>
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
</div>
</div>
</dialog>
@@ -1,5 +1,6 @@
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Extensions.Interop;
using DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
@@ -15,9 +16,9 @@ public partial class SettingsModal : IAsyncDisposable
private ElementReference _dialogRef;
private IJSObjectReference? _jsModule;
private DotNetObjectReference<SettingsModal>? _dotNetRef;
private ClientSettings _editingSettings = new();
private List<string> _validationErrors = new();
private string _searchTerm = string.Empty;
private HashSet<string> _expandedGroupIds = new(StringComparer.OrdinalIgnoreCase);
private bool _moduleLoaded;
private bool _dialogInitialized;
private bool _wasVisible;
@@ -40,19 +41,12 @@ public partial class SettingsModal : IAsyncDisposable
if (justOpened)
{
_searchTerm = string.Empty;
_expandedGroupIds.Clear();
var visible = SettingsModalSections.Groups.Where(g => SettingsModalSections.GroupMatchesContextOrSearch(g, SettingsContext, _searchTerm)).ToList();
if (visible.Count > 0)
_expandedGroupIds.Add(visible[0].Id);
_validationErrors.Clear();
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings
{
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds,
AppLogsPollingIntervalSeconds = _editingSettings.AppLogsPollingIntervalSeconds,
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds,
RecentLogsMaxLines = _editingSettings.RecentLogsMaxLines,
RecentLogsAutoScroll = _editingSettings.RecentLogsAutoScroll,
AppLogsMaxLines = _editingSettings.AppLogsMaxLines,
AppLogsAutoScroll = _editingSettings.AppLogsAutoScroll
};
await ClientSettingsService.LoadSettingsAsync();
}
}
@@ -62,7 +56,7 @@ public partial class SettingsModal : IAsyncDisposable
{
try
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
_dotNetRef = DotNetObjectReference.Create(this);
_moduleLoaded = true;
}
@@ -75,15 +69,48 @@ public partial class SettingsModal : IAsyncDisposable
{
if (!_dialogInitialized)
{
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
await _jsModule.InvokeVoidAsyncSafe("initDialog", _dialogRef, _dotNetRef);
_dialogInitialized = true;
}
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
await _jsModule.InvokeVoidAsyncSafe("showModal", _dialogRef);
}
}
private void FilterSettings() => StateHasChanged();
private IEnumerable<SettingsModalSections.CollapsibleGroup> GetVisibleGroups() =>
SettingsModalSections.Groups.Where(g => SettingsModalSections.GroupMatchesContextOrSearch(g, SettingsContext, _searchTerm));
private bool IsGroupExpanded(SettingsModalSections.CollapsibleGroup group)
{
var hasActiveFilter = SettingsContext == SettingsModalContext.All && !string.IsNullOrWhiteSpace(_searchTerm);
if (hasActiveFilter)
return true;
return _expandedGroupIds.Contains(group.Id);
}
private void ToggleGroup(string groupId)
{
if (_expandedGroupIds.Contains(groupId))
_expandedGroupIds.Remove(groupId);
else
_expandedGroupIds.Add(groupId);
StateHasChanged();
}
private void ExpandAllGroups()
{
foreach (var g in GetVisibleGroups())
_expandedGroupIds.Add(g.Id);
StateHasChanged();
}
private void CollapseAllGroups()
{
_expandedGroupIds.Clear();
StateHasChanged();
}
private bool ShouldShowSection(string key)
{
var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext);
@@ -95,18 +122,9 @@ public partial class SettingsModal : IAsyncDisposable
private void RunValidation()
{
_validationErrors.Clear();
var checks = new (string Section, int Value, ClientSettingsFields.FieldBounds Bounds)[]
foreach (var (section, field) in ClientSettingsFields.ValidationChecks)
{
(SettingsModalSections.ServiceStatus, _editingSettings.ServiceStatusPollingIntervalSeconds, ClientSettingsFields.ServiceStatusPollingInterval),
(SettingsModalSections.Logs, _editingSettings.RecentLogsPollingIntervalSeconds, ClientSettingsFields.RecentLogsPollingInterval),
(SettingsModalSections.AppLogs, _editingSettings.AppLogsPollingIntervalSeconds, ClientSettingsFields.AppLogsPollingInterval),
(SettingsModalSections.Leases, _editingSettings.LeasesPollingIntervalSeconds, ClientSettingsFields.LeasesPollingInterval),
(SettingsModalSections.RecentLogsDisplay, _editingSettings.RecentLogsMaxLines, ClientSettingsFields.RecentLogsMaxLines),
(SettingsModalSections.AppLogsDisplay, _editingSettings.AppLogsMaxLines, ClientSettingsFields.AppLogsMaxLines),
};
foreach (var (section, value, bounds) in checks)
{
if (ShouldShowSection(section) && bounds.Validate(value) is { } err)
if (ShouldShowSection(section) && field.Validate() is { } err)
_validationErrors.Add(err);
}
}
@@ -120,14 +138,16 @@ public partial class SettingsModal : IAsyncDisposable
return;
}
await ClientSettingsService.SaveSettingsAsync(_editingSettings);
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
await ClientSettingsService.SaveSettingsAsync(ClientSettingsFields.ToDto());
if (_jsModule != null)
await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef);
await OnClose.InvokeAsync();
}
private async Task Close()
{
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
if (_jsModule != null)
await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef);
await OnClose.InvokeAsync();
}
@@ -143,14 +163,8 @@ public partial class SettingsModal : IAsyncDisposable
/// </summary>
public async ValueTask DisposeAsync()
{
if (_jsModule != null)
{
try { await _jsModule.DisposeAsync(); }
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (prerender)"); }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (circuit disconnected)"); }
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync failed"); }
_jsModule = null;
}
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
_dotNetRef?.Dispose();
_dotNetRef = null;
}
@@ -28,8 +28,98 @@
padding: 1.25rem;
}
.settings-expand-collapse-btn {
padding: 0.25rem 0.4rem;
line-height: 1;
}
.settings-expand-collapse-btn .bi {
font-size: 1rem;
}
.settings-modal-dialog .modal-footer {
padding: 1rem 1.25rem;
border-top: 1px solid rgba(0, 0, 0, 0.125);
gap: 0.5rem;
}
/* Collapsible section groups */
.settings-group {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.375rem;
margin-bottom: 0.5rem;
overflow: hidden;
}
.settings-group:last-child {
margin-bottom: 0;
}
.settings-group-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.625rem 0.75rem;
font-size: 0.9375rem;
font-weight: 500;
color: #212529;
background: rgba(0, 0, 0, 0.03);
border: none;
cursor: pointer;
text-align: left;
transition: background-color 0.15s ease;
}
.settings-group-header:hover {
background: rgba(0, 0, 0, 0.06);
}
.settings-group-header.expanded {
background: rgba(0, 0, 0, 0.05);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.settings-group-chevron {
flex-shrink: 0;
width: 0.75rem;
height: 0.75rem;
margin-left: 0.5rem;
border-right: 2px solid #6c757d;
border-bottom: 2px solid #6c757d;
transform: rotate(-45deg);
transition: transform 0.2s ease;
}
.settings-group-header.expanded .settings-group-chevron {
transform: rotate(135deg);
}
.settings-group-title-static {
margin-left: 0.75rem;
}
.settings-group-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease-out;
}
.settings-group-content.expanded {
max-height: 500px;
transition: max-height 0.35s ease-in;
}
.settings-group-content > * {
padding: 1rem 0.75rem;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.settings-section + .settings-section {
border-top: 1px solid rgba(0, 0, 0, 0.06);
}

Some files were not shown because too many files have changed in this diff Show More