71 Commits
Author SHA1 Message Date
alex 36fba947e1 Fix log download race; stream live log file (v0.0.10)
Replace ReadAllTextAsync with FileStream + FileShare.ReadWrite so
concurrent dnsmasq writes cannot trigger StringBuilder capacity errors.
Release as 0.0.10.
2026-05-07 01:59:28 +10:00
alex 350df71b4f Stop log background loops cleanly during shutdown.
Cancel the hosted-service watcher loops before the service provider is disposed so file-change and app-log pushes do not try to create scopes after shutdown begins.
2026-04-11 21:53:45 +10:00
alex 43ba5116ac Make specialized DNS and resolver views draft-aware for release v0.0.9.
Project current effective multi-value edits back onto source-aware rows, add occurrence-based targeting for duplicate records, and bump the app version so the curated pages stay consistent with Effective configuration during editing.
2026-04-11 21:35:52 +10:00
alex b24ca17779 Add structured DNS, DHCP, and resolver policy editing flows.
Unify the pages around the effective-config save workflow, source-aware projections, and hosts-style table UX so draft changes are easier to understand and the specialized editors stay consistent and better covered by tests.
2026-04-11 20:25:28 +10:00
alex e86a2b6bd9 Extract hosts file filter into GroupedSelectDropdown and builder
Add shared Models.Ui grouped-select model, IGroupedSelectBuilder, optional
trigger summary API, and GroupedSelectDropdown component. Hosts page uses
HostsFileFilterBuilder and hosts-specific option row styling for full paths.

Remove HostsSourcePathDisplay and file filter menu records in favor of the
builder. Bump version to 0.0.8.
2026-04-07 18:15:23 +10:00
alex 002e9a5db5 Polish multi-value editors and hosts inline editing.
Align the domain custom editor with the default multi-value layout, keep item numbering in edit mode, and restore unaffected single-value editor styling. Refine hosts inline editing with safer blur handling, explicit cancel actions, and better behavior when blank edits are abandoned.
2026-04-07 17:36:52 +10:00
alex 02a840799b Hosts page: grouped file filter with counts and source-path projection
Add SourcePathFilter to HostsPageQueryState and apply it in HostsPageProjectionService.
Replace the native select with a grouped menu (managed/system/additional/hostsdir),
per-file non-comment counts, toolbar layout, and full-path labels with end-anchored
ellipsis plus title tooltips. Include HostsSourcePathDisplay helpers, menu models,
and tests for grouping and projection filtering.
2026-04-07 16:00:25 +10:00
alex 2e713f0e3c Hosts UX, effective names, JS interop, domain UI
- Hosts: delete confirmation modal, shift range select, keyboard shortcuts,
  effective-name suffix tooltips, conditional modern header actions
- Align EffectiveNames on rows with scoped domain= + pending preview;
  unified hosts API uses full DomainValues from config snapshot
- Harden Blazor JS interop (safe helpers, IAsyncDisposable, disposal order)
- Effective config: domain multi-value display/editor, parsers, tests
- Misc: docs/testdata/docker script updates
2026-04-05 14:46:17 +10:00
alex 9627926a6a Hosts page UX, effective names, and draft sync
- Managed hosts: add slot row, selection with Ctrl/Cmd, Edit toggles to Save
  while editing, Remove, double-click to edit; header toolbar layout
- Column order: Address, Names, Effective names (when expand-hosts + domains),
  Comment; scroll cue radius and table styling tweaks
- Effective names: resolve domain suffix per row IPv4 from scoped domain=
  rules (CIDR/range) with dnsmasq-style precedence; IPv6 uses default;
  preview uses pending domain option when present
- Enter edit mode on Remove; clear stale inline edit when row disappears;
  deactivate effective-config field when interacting with hosts table
- Remove hosts-ui.js; effective names help uses notifications always
- Tests for HostsEffectiveNames domain rule resolution
2026-04-01 23:50:21 +10:00
alex eae29de045 Hosts: inline file comments (hosts(5)) and Comment column
- HostEntry.InlineComment; parse at first # on data lines; ToLine + SanitizeInlineComment
- HostsPageRow + HostsCache; pending change equality and save diff include comments
- Hosts table: dedicated Comment column; edit in own cell; empty shown as em dash
- Parser and pending-change tests
2026-04-01 11:52:21 +10:00
alex f0b4ea97a4 Hosts page UX, draft/UI split, and managed hosts save summary
- Split effective-config session into Draft + Ui (IEffectiveConfigDraft, IEffectiveConfigUiState)
- Hosts: managed overlay, edit mode from table, validation deferred to blur/Escape/Enter
- Managed hosts save modal: line-level diff (add/remove/modify/order)
- Hosts group headers: editable/read-only badges, empty-group row, Effective names help
- hosts-ui.js for touch vs hover help; table inline bind oninput for live preview
- HostsEntryEditValidator and ManagedHostsPendingChangeDiff with tests
2026-04-01 11:27:55 +10:00
alex 1bbab1c8c3 Fix address/local rule: skip NXDOMAIN address=/domain/ form
GetDomainsFromAddressDirective only considers lines with a non-empty
target after the final slash; address=/domain/ applies to all RR types
and should not require local=. Add tests for trailing-slash and # forms.
2026-03-30 19:41:08 +10:00
alex 5702844cea Cross-option rules: rev-server+bogus-priv, DHCP, port 0 netboot
- Extend bogus-priv private-reverse check to rev-server= RFC1918 IPv4
  targets (CIDR overlap); separate FieldIssues for server vs rev-server.
- Add no-ping + dhcp-sequential-ip warning (man/2.80).
- Add port=0 + enable-tftp/pxe-service warning.
- Clarify address/local 2.86+ message; comment deferring dhcp-authoritative
  DHCPv6 REBIND rule until doc/heuristics justify it.
- Tests for new behavior, pending rev-server overlay, field keys.
2026-03-30 19:29:30 +10:00
alex 84c58ccec7 Cross-option validation: field keys, bogus-priv zones, edit UX
- Add FieldKeyForOption() using EffectiveConfigSections.GetSectionId so
  cross-option issues match UI field keys; tighten RFC1918 in-addr.arpa
  detection for bogus-priv vs server=.
- GetMulti: pending change always wins; empty list when NewValue is not
  a string list (with tests).
- EffectiveConfigSection: re-run cross-option when entering edit mode or
  continuing after save failure; allow opening save modal when only
  validation summary is present.
- Toolbar: validation link aria-label describes opening the summary.
2026-03-30 11:31:02 +10:00
alex a3dc5ac45c EffectiveConfig: replace cross-option evaluator with rule service
Move cross-option checks to DI-backed rules and shared context so editor and quick-add flows use one validation pipeline. Improve warning copy and save-modal messaging to clarify that validation reflects both on-disk config and pending edits.
2026-03-29 21:35:30 +10:00
alex 31c0f65c86 Hosts page UX: simpler table, projection, and effective-config tweaks
Remove the Status column and row-level badges/actions; keep source-level
Editable on group headers. Double-click managed rows to edit; commit staged
drafts when focus leaves the row via isFocusOutsideElement in the shared
scroll-cue module. Hide inactive system-host rows when dnsmasq ignores them
and move system-hosts / expand-hosts notes below the content area.

Memoize VisibleGroups so BuildGroups runs once per render during search.
Move read-ethers metadata to the DHCP section; adjust Hosts page state copy
and related effective-config tooltips/builders where needed.
2026-03-29 09:31:29 +10:00
alex c7e7844ce7 Update hosts workflow and align DHCP metadata.
Redesign the Hosts page around source-aware grouped rows with shared table shell styling, fix effective-config save session synchronization, and move read-ethers into the DHCP model where dnsmasq actually applies it.
2026-03-26 00:24:21 +10:00
alex 333566554c Update hosts file section 2026-03-18 12:41:34 +10:00
alex 1b3a09996f Effective config: unified save pipeline, descriptors, serialization, WriteFailed rollback
- EffectiveConfigFieldRef as value type; PendingDnsmasqChange/PendingOptionChange/PendingManagedHostsChange
- Multi-file save plan (DnsmasqSaveWritePlan, DnsmasqManagedBackup) and backup/restore in save flow
- Hosts integrated into effective-config save; Hosts.razor uses shared save modal
- IEffectiveConfigDescriptorProvider + IEffectiveConfigFieldBuilder (DI); descriptors from EffectiveConfigSections
- IEffectiveConfigPageEditor, shared descriptor resolution; addn-hosts/dhcp-host descriptor fix
- DnsmasqConfText, IEffectiveConfigDirectiveSerializer; DnsmasqConfigService uses serializer for writes
- IStructuredOptionValueHandler/Registry, DhcpHostOptionValueHandler; Dhcp.razor index-based DeleteRow fix
- SaveService: roll back partial writes on failure; SaveModal: WriteFailed shows backups and Restore button
2026-03-12 01:48:19 +10:00
alex 3ca935e551 Tests: add real-world config coverage and prune brittle cases
Reduce presentation-focused test churn while adding corpus-based parser, validator, capability-guard, and round-trip save coverage to better protect future refactors.
2026-03-11 11:44:01 +10:00
alex e33402467f Reorganise codebase: EffectiveConfig, parsers, tests; move tooltips to Metadata
Infrastructure:
- Parsers under Serialization/Parsers: Dnsmasq, DnsmasqConfig, EffectiveConfig
- EffectiveConfig: Abstractions, Metadata (keys, views, field builder, tooltips,
  placeholders, write semantics), Rendering, Editing, Validation/Handlers, Readonly
- Helpers/Config: only DnsmasqFileEncoding; DnsmasqOptionTooltips moved to Metadata
- Remove empty Infrastructure/Parsers and Helpers/Dnsmasq

Tests:
- Mirror main project: Serialization/Parsers (Dnsmasq, DnsmasqConfig),
  Services/Dnsmasq/Config, Services/EffectiveConfig, Models, Helpers (TestDataHelper)
- Namespaces updated; usings for TestDataHelper and Metadata where needed

Docs: README codebase overview and test layout updated.
2026-03-11 10:50:23 +10:00
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
alex 1bdd176c18 App logs, client settings, NavMenu layout
- Add real-time app logs via SignalR (log level, category filters)
- Add filters modal with restore defaults; sentinel for empty arrays
- Add client settings: max lines and auto-scroll per log panel
- Improve NavMenu: align settings + hamburger, smaller mobile logo
- LogsService, LogsHub, LoggingController, AppLogsLoggerProvider
- Persist log level and filters to appsettings.Overrides.json
2026-02-10 08:44:30 +10:00
alex b70aa02017 Refactor settings modal to use ISettingsModalService and SettingsModalSections
- Replace SettingsModalContext with SettingsModalSections
- Add SettingsModalService for modal state management
- Add StatusSection.razor.css for status-dot styles
- Update layout, NavMenu, and modal components
- Update tests for config/settings changes
2026-02-09 14:59:01 +10:00
alex d06d6ef6c9 install.sh: add jq_release helper, mktemp for zip, trim URL in find_asset_url, fix validation indentation 2026-02-05 23:48:14 +10:00
alex 1dffdcf2ed install.sh: strip control chars from GitHub API JSON so jq parses release body
GitHub can return release body with unescaped control characters (e.g. from
generated release notes); jq rejects them. Sanitize response in fetch_release
and list_releases before piping to jq (keep \n and \r).
2026-02-05 23:28:10 +10:00
alex 3ae556168e Docs: XML comments for interfaces/models and README codebase overview
- Add XML summaries and params to service/client interfaces (IDnsmasqConfigService, IHostsFileService, ILeasesFileService, IReloadService, ILeasesCache, IStatusClient, IConfigSetClient, IReloadClient, IHostsClient, IDhcpHostsClient, ILeasesClient)
- Add <param> to Models/Contracts and Dnsmasq/EffectiveConfig records (ConfigSetSnapshot, HostsSnapshot, ManagedConfigContent, SaveWithReloadResult, ReloadResult, DnsmasqConfigSet, DnsmasqConfigSetEntry, LeasesResult, ReadOnlyHostsFile)
- Document HostEntry, DhcpHostEntry, LeaseEntry with type and property summaries
- Update README codebase overview to reflect refactored layout (Infrastructure/, Models/Config, Models/Contracts, Models/Dnsmasq, etc.)
2026-02-05 23:15:47 +10:00
alex b44dde2c77 Refactor: reorganise namespaces and move service DTOs to Models/Contracts
- Move Client/Http, Services, Parsers under Infrastructure/ (Client, Services, Parsers)
- Move config helpers (DnsmasqConfKeys, DnsmasqOptionTooltips, DnsmasqFileEncoding, EffectiveConfigFieldBuilder) to Infrastructure/Helpers/Config; ApiJsonOptions to Helpers/Http
- Move Configuration types (ApplicationOptions, DnsmasqOptions, DnsmasqOptionsValidator) to Models/Config
- Merge Models/Status and Models/EffectiveConfig into Models/Dnsmasq and Models/Dnsmasq/EffectiveConfig
- Move Client/Models and Client/Services to Models/Client and Infrastructure/Services
- Move ConfigSetSnapshot, HostsSnapshot, ProcessRunResult, ManagedConfigContent from Infrastructure/Services/Abstractions to Models/Contracts
- Update all namespaces and usings; tests pass
2026-02-05 23:08:40 +10:00
alex 23bf6bb9e0 install.sh: fix collect_config under set -e when no config
- Use set +e around env/grep/read so grep (no match), read (EOF), and
  [ -f ]/[ -s ] do not exit the script when no DNSMASQ_WEBUI_* or --set
- Write env vars to temp file .env then read; only process when file
  exists and is non-empty
- Add || true after [ -s CONFIG_SET_FILE ] && cat so empty CONFIG_SET_FILE
  does not trigger exit
Fixes install/update with no config (e.g. plain --version/--update --dir)
failing silently under sh (dash).
2026-02-05 04:04:56 +10:00
alex dfb0b94e4a install.sh: fix no output when run as curl | sh
- Echo 'Installing dnsmasq-webui...' at start of do_install so output is visible
- Use </dev/null on all curl calls so they do not consume stdin; when run
  as 'curl | sh' the script was being eaten and exited before any echo
2026-02-05 03:51:52 +10:00
alex 4317efff68 Use absolute path for conf-file=; list all config files in UI
- EnsureManagedConfigHostedService: write conf-file=/absolute/path so
  dnsmasq finds the managed file regardless of CWD (e.g. systemd with CWD=/)
- Dnsmasq.razor: show Config files (load order) as single list with all
  paths (main, conf-dir, managed); fallback to Main/Managed when set unavailable
2026-02-05 03:40:09 +10:00
805 changed files with 47457 additions and 64181 deletions
+5 -5
View File
@@ -29,22 +29,22 @@ jobs:
run: |
docker run --rm \
-v "${{ github.workspace }}:/src" -w /src \
mcr.microsoft.com/dotnet/sdk:9.0 \
./scripts/publish-self-contained.sh ${{ matrix.rid }}
mcr.microsoft.com/dotnet/sdk:10.0 \
./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
- name: Setup .NET
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
dotnet-version: '10.0.x'
- 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: |
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net9.0/${{ matrix.rid }}/publish"
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net10.0/${{ matrix.rid }}/publish"
(cd "$PUBLISH_DIR" && zip -r "../../../../../../../dnsmasq-webui-${{ matrix.rid }}.zip" .)
- name: Upload artifact
+2 -1
View File
@@ -43,6 +43,7 @@ testdata-mount/
.DS_Store
Thumbs.db
# User-specific
# User-specific / runtime
appsettings.*.local.json
appsettings.Overrides.json
*.local
+40 -10
View File
@@ -2,13 +2,21 @@
# 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.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
ARG DNSMASQ_VERSION=
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"]
RUN dotnet restore "src/DnsmasqWebUI/DnsmasqWebUI.csproj"
@@ -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
+56 -27
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 -->
@@ -39,7 +41,7 @@ The recommended way to get a prebuilt binary is the **install script**. It picks
curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/master/scripts/install.sh | sh
```
**From a git clone** (repo is detected from `git remote origin`):
**From a git clone:**
```bash
./scripts/install.sh
@@ -102,8 +104,6 @@ sudo ./scripts/install.sh --uninstall --purge --system # Also remove /opt/dnsm
./scripts/install.sh --version v1.0.0
```
**Installing from a fork:** Pass the repo explicitly: `./scripts/install.sh --repo owner/dnsmasq-webui` or set `GITHUB_REPO=owner/dnsmasq-webui`. The default repo is `alexhopeoconnor/dnsmasq-webui`.
**If the binary fails to run** (e.g. `TypeLoadException` or glibc errors): use the install script with `--build-from-source` so the app is built for your machine. This requires the .NET SDK and a **git clone** (it does not work when installing via `curl ... | sh`). Clone the repo, then run `./scripts/install.sh --build-from-source`. The script checks that .NET is installed, detects your OS/arch, and builds (on Ubuntu it may use a distro-specific RID for a better match). Other distros use the portable RID and may still hit runtime issues; building from source on the target machine is the most reliable.
**Configuration:** Set at least `Dnsmasq__MainConfigPath` to your main dnsmasq config. See [Configuration](#configuration) for all options.
@@ -165,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:**
@@ -175,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 -->
@@ -207,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):
@@ -238,7 +244,6 @@ All scripts live under `scripts/` and are intended for Linux (WSL may work but i
| Option | Description |
|--------|-------------|
| `--repo OWNER/REPO` | GitHub owner/repo (or set `GITHUB_REPO`). When run from a clone, repo is detected from `git remote origin`. Default repo: `alexhopeoconnor/dnsmasq-webui`. |
| `--list` | List available releases (tag, name, published_at) and exit. |
| `--version TAG` | Install from release TAG (e.g. v1.0.0). Default: latest. |
| `--update` | Reinstall latest into the default user directory (~/.local/share/dnsmasq-webui). |
@@ -291,18 +296,14 @@ Invalid combinations (script errors with a clear message): `--purge` without `--
Script lists release tags and dates; list-only mode cannot be combined with install/uninstall so you do not accidentally change the system.
**Command:** `./scripts/install.sh --list`
10. **Install from a fork or a specific GitHub repo** instead of the default.
You pass the repo; script uses it for the API. Without a clone or `--repo`/GITHUB_REPO it errors so you fix the command instead of getting a confusing API failure.
**Command:** `./scripts/install.sh --repo owner/dnsmasq-webui` or `GITHUB_REPO=owner/dnsmasq-webui ./scripts/install.sh`
11. **Install a specific release tag** (e.g. an older or pinned version) instead of latest.
10. **Install a specific release tag** (e.g. an older or pinned version) instead of latest.
Script fetches that tag's release and the asset for your RID. If no asset matches your OS/arch it errors and prints the asset names so you can pick another tag or report a missing build.
**Command:** `./scripts/install.sh --version v1.0.0`
**Examples:**
```bash
# Install latest (from clone: repo auto-detected; from curl: uses alexhopeoconnor/dnsmasq-webui by default, or use --repo)
# Install latest
./scripts/install.sh
# Update to latest release
@@ -329,8 +330,8 @@ sudo ./scripts/install.sh --uninstall --purge --system
# Switch to another release (overwrites that dir)
./scripts/install.sh --dir ~/.local/share/dnsmasq-webui --version v0.9.0
# List releases (from fork)
./scripts/install.sh --repo alexhopeoconnor/dnsmasq-webui --list
# List releases
./scripts/install.sh --list
```
**How the script works and avoids mistakes**
@@ -351,7 +352,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
`--uninstall` removes only systemd units and symlinks unless you add `--purge`. With `--purge`, you must say *which* install to remove: default user dir (no extra flags), `--dir DIR`, or `--system` for /opt. That avoids accidentally deleting the wrong directory.
6. **Repo and release**
When not in a git clone, the script needs a repo (e.g. `REPO_DEFAULT` in the one-liner or `--repo` / `GITHUB_REPO`). It errors with a clear message instead of hitting the GitHub API with an empty repo. If no asset matches your OS/arch (RID), it prints the available asset names so you can pick a different release or report a missing build.
If no asset matches your OS/arch (RID), the script prints the available asset names so you can pick a different release or report a missing build.
7. **Dependencies**
The script requires `curl`, `jq`, and `unzip`. It checks for `jq` before calling the GitHub API and errors with an install hint. `--service` requires `systemctl` (systemd) and errors on non-systemd systems instead of writing a unit that won’t be used.
@@ -392,13 +393,13 @@ sudo ./scripts/install.sh --uninstall --purge --system
./scripts/publish-self-contained.sh --no-clean ubuntu.24.04-x64
```
**Output:** `src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/`
**Output:** `src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/`
---
### scripts/prepare-test-mount.sh
**Purpose:** Prepare the testdata mount and optionally start or stop the Docker test harness (app + dnsmasq + DHCP clients). Syncs a source dir (default: `testdata`) into a mount dir (default: `testdata-mount`), cleans up previous test data, then runs `docker compose -f docker-compose.test.yml up -d` (or only prepares the mount with `--prepare-only`).
**Purpose:** Prepare the testdata mount and optionally start or stop the Docker test harness (app + dnsmasq + DHCP clients). Syncs a source dir (default: `testdata`) into a mount dir (default: `testdata-mount`), then runs `docker compose -f docker-compose.test.yml up -d` (or only prepares the mount with `--prepare-only`). Fixture sync skips `leases` and app-managed filenames (`zz-dnsmasq-webui.conf`, `zz-dnsmasq-webui.hosts`) so rebuilds keep DHCP state and UI-written files unless you pass `--clear` or `--reset-managed`. By default the script resolves and uses the **latest stable upstream dnsmasq version** when building images; you can pin a specific version (or use the distro package) for compatibility testing.
**Usage:** `./scripts/prepare-test-mount.sh [OPTIONS] [--]`
@@ -408,9 +409,12 @@ 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. |
| `--reset-managed` | After sync, remove `*dnsmasq-webui*.conf` and `*dnsmasq-webui*.hosts` under the mount so the app recreates them; leaves leases and other files. |
| `--prepare-only` | Only prepare the mount; do not run docker compose. |
| `--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). |
@@ -420,18 +424,28 @@ sudo ./scripts/install.sh --uninstall --purge --system
**Examples:**
```bash
# Full run: clear mount, sync testdata, start containers (no rebuild)
# Default: preserve mount (fixtures sync over; managed conf/hosts and leases kept), start (no image rebuild)
./scripts/prepare-test-mount.sh
# Rebuild images then start
# Rebuild images then start (mount state preserved unless you pass --clear or --reset-managed)
./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
# Fresh managed config/hosts after a rebuild (fixtures still synced; leases kept)
./scripts/prepare-test-mount.sh --reset-managed --build
# Stop the harness
./scripts/prepare-test-mount.sh --stop
@@ -456,6 +470,21 @@ sudo ./scripts/install.sh --uninstall --purge --system
**Usage:** Set `DNSMASQ_CONF` to the path of the dnsmasq config file inside the container (e.g. `/data/dnsmasq-test.conf`). No CLI arguments.
---
### 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 -->
---
@@ -477,11 +506,11 @@ sudo ./scripts/install.sh --uninstall --purge --system
**Codebase overview:**
- **Entry and config:** `Program.cs` wires the host; configuration (e.g. `Dnsmasq__*`) is in `Configuration/` (`DnsmasqOptions`, `ApplicationOptions`). Options are validated at startup.
- **Config and hosts:** Services in `Services/` and `Services/Abstractions/` read and write dnsmasq config and hosts. A **managed** config file and optional managed hosts file are written by the app; the main config must include the managed file (e.g. via a final `conf-file=` line). Caches (`ConfigSetCache`, `HostsCache`) keep in-memory snapshots and refresh on file changes or staleness.
- **Parsers and models:** `Parsers/` parses dnsmasq config lines and files (`DnsmasqConfIncludeParser`, `DnsmasqConfDirectiveParser`, etc.). Models in `Models/` represent config sets, config, sources, DHCP entries, hosts, leases.
- **Entry and config:** `Program.cs` wires the host; configuration (e.g. `Dnsmasq__*`) lives in `Models/Config/` (`DnsmasqOptions`, `ApplicationOptions`, `DnsmasqOptionsValidator`). Options are validated at startup.
- **Infrastructure:** Under `Infrastructure/`: **Client/** (HTTP API clients and `Abstractions/`), **Serialization/Parsers/** (Dnsmasq version/compile, DnsmasqConfig conf-line and include parsers, EffectiveConfig syntax helpers), **Services/** (caches, config/hosts/leases services, reload, hosted services; EffectiveConfig has Metadata, Rendering, Editing, Validation, Readonly), **Helpers/Config/** (file encoding for config/hosts), **Helpers/Http/** (HTTP helpers). A **managed** config file and optional managed hosts file are written by the app; the main config must include the managed file (e.g. via a final `conf-file=` line). Caches (`ConfigSetCache`, `HostsCache`) keep in-memory snapshots and refresh on file changes or staleness.
- **Models:** `Models/Config/` (options, validators, `DnsmasqConfLine`); `Models/Contracts/` (snapshots and DTOs such as `ConfigSetSnapshot`, `HostsSnapshot`, `ManagedConfigContent`, `ProcessRunResult`, `ReloadResult`); `Models/Dnsmasq/` (status, `SaveWithReloadResult`, and `EffectiveConfig/` for effective config and sources); `Models/Client/`, `Models/Dhcp/`, `Models/Hosts/` (UI and API DTOs).
- **API and UI:** `Controllers/` expose API endpoints; `Components/` contains Blazor Server components for config editor, hosts, DHCP, Dnsmasq status, and app settings. Static assets in `wwwroot/`.
- **Tests:** `DnsmasqWebUI.Tests` contains unit tests for parsers, config set service, config, and related logic. Run with `dotnet test`.
- **Tests:** `DnsmasqWebUI.Tests` contains unit tests organised to mirror the main project: `Serialization/Parsers/` (Dnsmasq, DnsmasqConfig), `Services/Dnsmasq/Config/`, `Services/EffectiveConfig/`, `Models/`, and `Helpers/` (TestDataHelper). Run with `dotnet test`.
<!-- SUGGESTED SCREENSHOT: SOLUTION EXPLORER OR FOLDER TREE OF SRC/ -->
@@ -506,7 +535,7 @@ Use `appsettings.json` or launch settings to set `Dnsmasq__MainConfigPath` and o
```bash
./scripts/prepare-test-mount.sh
```
This clears the mount dir (by default `testdata-mount`), syncs `testdata/` into it, and starts the stack with `docker-compose.test.yml`. Use `--prepare-only` to only prepare the mount, then start compose manually; use `--build` after changing the app or Dockerfile.
This syncs `testdata/` into the mount (preserving existing managed config, hosts, and `leases` unless you use `--clear` or `--reset-managed`), then starts the stack with `docker-compose.test.yml`. Use `--prepare-only` to only prepare the mount, then start compose manually; use `--build` after changing the app or Dockerfile.
2. Open the UI (e.g. http://localhost:8080). The app and dnsmasq use the mounted config; you can edit config/hosts in the UI and trigger reload.
+22 -8
View File
@@ -17,17 +17,21 @@ 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
4. Run `docker compose -f docker-compose.test.yml up -d --build`
1. Run `docker compose down`, then sync `testdata/` → `testdata-mount/` (preserving mount contents unless you pass `--clear`). Sync skips `leases` and `zz-dnsmasq-webui.conf` / `zz-dnsmasq-webui.hosts` so rebuilds keep DHCP leases and UI-written managed files.
2. Optionally remove managed `*dnsmasq-webui*.conf` / `*dnsmasq-webui*.hosts` in the mount **only** if you pass `--reset-managed`.
3. Resolve the requested dnsmasq version (`latest` by default) to a concrete value when building images.
4. Run `docker compose -f docker-compose.test.yml up -d` (add `--build` / `--no-cache-build` when you need a rebuild).
- **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 and managed config):** default; also use `./scripts/prepare-test-mount.sh --no-build` for a quick up without rebuilding images.
- **Drop managed conf/hosts only (keep leases):** `./scripts/prepare-test-mount.sh --reset-managed` (often combined with `--build`).
App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory.
App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory. The app service has a healthcheck (`GET /healthz/ready`); DHCP client services use `depends_on: app: condition: service_healthy` so they start only after the app (and dnsmasq) is ready.
### Stop the harness
@@ -49,6 +53,9 @@ Stops the harness and deletes the contents of `testdata-mount/` so the next run
- `--source DIR` — Source to sync from (default: `testdata`)
- `--mount DIR` — Mount directory (default: `testdata-mount`). The script exports `TESTDATA_MOUNT=./DIR` when you use `--mount`, so compose uses it.
- `--dnsmasq-version V` — Dnsmasq version for the harness image: `latest` (default), `distro`, or an exact upstream version like `2.91`.
- `--reset-managed` — After sync, delete `*dnsmasq-webui*.conf` and `*dnsmasq-webui*.hosts` under the mount so the app recreates them.
- `--no-cache-build` — Force a fresh image build with `docker compose build --pull --no-cache` before start.
Example: sync from a custom dir and start:
@@ -76,5 +83,12 @@ Build only: `dotnet build`
- **testdata/** — Source for the harness mount. Synced to `testdata-mount/` by `prepare-test-mount.sh`. Unit tests read from the copy in the test output dir. See **testdata/README** for the full layout.
- **testdata/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.
+24 -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 [--clear | --reset-managed | --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,19 +14,31 @@ 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)
Dnsmasq__StatusShowCommand: "/app/dnsmasq-status.sh"
Dnsmasq__LogsCommand: "tail -n 100 /data/dnsmasq.log 2>/dev/null || echo '(no log file yet)'"
# Persist runtime log level across container restarts
RuntimeOverrides__FilePath: "/data/appsettings.Overrides.json"
volumes:
- ${TESTDATA_MOUNT:-./testdata-mount}:/data
networks:
@@ -39,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:
@@ -58,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:
@@ -77,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:
@@ -86,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"
+71 -92
View File
@@ -1,12 +1,10 @@
#!/usr/bin/env sh
# Download and install dnsmasq-webui from a GitHub release. Picks the binary
# matching the current OS/arch (RID). Run from a clone or via:
# curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/main/scripts/install.sh | sh
# When run from a clone, repo is detected from git remote origin.
# curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/master/scripts/install.sh | sh
set -e
GITHUB_REPO="${GITHUB_REPO:-}"
REPO_DEFAULT="${REPO_DEFAULT:-alexhopeoconnor/dnsmasq-webui}"
GITHUB_REPO="alexhopeoconnor/dnsmasq-webui"
RELEASE_TAG=""
LIST=false
@@ -37,10 +35,6 @@ usage() {
echo "Download and install dnsmasq-webui from a GitHub release for this machine's OS/arch."
echo "Supports install, update, and switch-release (re-run with same --dir and different --version)."
echo ""
echo "Repo (when not in a clone):"
echo " --repo OWNER/REPO GitHub owner/repo. Or set GITHUB_REPO. Or set REPO_DEFAULT in the script."
echo " When run inside a git clone with origin, repo is detected automatically."
echo ""
echo "Release:"
echo " --list List available releases (tag, name, published_at) and exit."
echo " --version TAG Install from release TAG (e.g. v1.0.0). Default: latest."
@@ -72,7 +66,7 @@ usage() {
echo " sudo $0 --uninstall --purge --system # also remove /opt/dnsmasq-webui"
echo ""
echo "Examples:"
echo " $0 # Install latest (clone: auto repo; curl: use REPO_DEFAULT or --repo)"
echo " $0 # Install latest"
echo " $0 --update # Reinstall latest to default dir (upgrade)"
echo " $0 --version v1.0.0 # Install v1.0.0 to default dir"
echo " $0 --dir /opt/dnsmasq-webui # Install to custom dir"
@@ -90,25 +84,26 @@ usage() {
exit 0
}
# Trim whitespace and carriage return (e.g. from script downloaded on Windows or with CRLF).
trim_repo() {
echo "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}
# Build CONFIG_VARS_FILE from env (DNSMASQ_WEBUI_*) then --set lines. Call before do_install when installing.
# Result: key=value per line (env first, then --set so --set overrides). Safe for values with = in them.
collect_config() {
CONFIG_VARS_FILE="$(mktemp)"
export CONFIG_VARS_FILE
# Env vars: strip DNSMASQ_WEBUI_ prefix; key=value per line
env | grep '^DNSMASQ_WEBUI_' | while IFS= read -r line; do
key="${line%%=*}"
key="${key#DNSMASQ_WEBUI_}"
value="${line#*=}"
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_VARS_FILE"
done
# Env vars: strip DNSMASQ_WEBUI_ prefix; key=value per line. set +e so grep (no match) / read (EOF) / [ -f ] do not trigger exit.
set +e
env | grep '^DNSMASQ_WEBUI_' > "${CONFIG_VARS_FILE}.env" 2>/dev/null
if [ -f "${CONFIG_VARS_FILE}.env" ] && [ -s "${CONFIG_VARS_FILE}.env" ]; then
while IFS= read -r line; do
key="${line%%=*}"
key="${key#DNSMASQ_WEBUI_}"
value="${line#*=}"
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_VARS_FILE"
done < "${CONFIG_VARS_FILE}.env"
fi
rm -f "${CONFIG_VARS_FILE}.env"
set -e
# --set lines (last occurrence of each key wins when systemd reads the file)
[ -s "$CONFIG_SET_FILE" ] && cat "$CONFIG_SET_FILE" >> "$CONFIG_VARS_FILE"
[ -s "$CONFIG_SET_FILE" ] && cat "$CONFIG_SET_FILE" >> "$CONFIG_VARS_FILE" || true
}
# Write env file for the app/service. $1 = path. Adds ASPNETCORE_URLS=http://0.0.0.0:8080 if not in config (for service).
@@ -139,30 +134,6 @@ write_env_file() {
fi
}
# Detect owner/repo from git remote, REPO_DEFAULT, or require --repo/GITHUB_REPO.
detect_repo() {
if [ -n "$GITHUB_REPO" ]; then
GITHUB_REPO="$(trim_repo "$GITHUB_REPO")"
return
fi
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
origin="$(git remote get-url origin 2>/dev/null)" || true
if [ -n "$origin" ]; then
GITHUB_REPO="$(echo "$origin" | sed -E 's|^https://github\.com/||; s|^git@github\.com:||; s|\.git$||; s|/$||')"
GITHUB_REPO="$(trim_repo "$GITHUB_REPO")"
if [ -n "$GITHUB_REPO" ]; then
return
fi
fi
fi
if [ -n "$REPO_DEFAULT" ]; then
GITHUB_REPO="$(trim_repo "$REPO_DEFAULT")"
return
fi
echo "Error: GitHub repo not set. Use --repo owner/repo, set GITHUB_REPO, or run from a clone. For the one-liner, set REPO_DEFAULT in the script." >&2
exit 1
}
# Require jq for parsing GitHub API JSON.
check_jq() {
if command -v jq >/dev/null 2>&1; then
@@ -172,6 +143,14 @@ check_jq() {
exit 1
}
# Safely run jq on release JSON (avoids echo mangling). $1 = JSON, $2+ = jq args.
jq_release() {
local json
json="$1"
shift
printf '%s' "$json" | jq -r "$@"
}
# Detect RID (same logic as publish-self-contained.sh).
detect_arch() {
case "$(uname -m)" in
@@ -219,6 +198,7 @@ print_env_detection() {
}
# GET release (latest or by tag). Output raw JSON to stdout.
# Use </dev/null so curl does not consume stdin (script when run via curl | sh).
fetch_release() {
local api_url
if [ -z "$RELEASE_TAG" ] || [ "$RELEASE_TAG" = "latest" ]; then
@@ -226,41 +206,46 @@ fetch_release() {
else
api_url="https://api.github.com/repos/$GITHUB_REPO/releases/tags/$RELEASE_TAG"
fi
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url")"
code="$(echo "$resp" | tail -n1)"
body="$(echo "$resp" | sed '$d')"
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url" </dev/null)"
code="$(printf '%s' "$resp" | tail -n1)"
body="$(printf '%s' "$resp" | sed '$d')"
if [ "$code" != "200" ]; then
echo "Error: GitHub API returned $code for $api_url" >&2
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
printf '%s' "$body" | jq -r '.message // .' 2>/dev/null || printf '%s' "$body" >&2
exit 1
fi
echo "$body"
# GitHub API can return release body with unescaped control chars; jq rejects them. Strip control chars (keep \n \r).
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
printf '%s' "$body"
}
# List releases and exit.
list_releases() {
check_jq
detect_repo
api_url="https://api.github.com/repos/$GITHUB_REPO/releases?per_page=20"
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url")"
code="$(echo "$resp" | tail -n1)"
body="$(echo "$resp" | sed '$d')"
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url" </dev/null)"
code="$(printf '%s' "$resp" | tail -n1)"
body="$(printf '%s' "$resp" | sed '$d')"
if [ "$code" != "200" ]; then
echo "Error: GitHub API returned $code" >&2
exit 1
fi
echo "$body" | jq -r '.[] | "\(.tag_name) \(.name) \(.published_at // .created_at)"'
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
printf '%s' "$body" | jq -r '.[] | "\(.tag_name) \(.name) \(.published_at // .created_at)"'
exit 0
}
# Find asset download URL whose name contains the given RID.
# Find asset download URL whose name contains the given RID. Output trimmed single line or empty.
find_asset_url() {
local release_json rid
local release_json rid raw
release_json="$1"
rid="$2"
echo "$release_json" | jq -r --arg rid "$rid" '
raw="$(jq_release "$release_json" --arg rid "$rid" '
.assets[] | select(.name | test($rid)) | .browser_download_url
' | head -n1
' | head -n1)"
# Trim CR and leading/trailing whitespace (defensive for API quirks)
printf '%s' "$raw" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}
# Remove user systemd unit (stop, disable, rm file). No root. Safe if not present.
@@ -361,7 +346,7 @@ do_install_from_source() {
exit 1
fi
if ! command -v dotnet >/dev/null 2>&1; then
echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-9.0)." >&2
echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-10.0)." >&2
exit 1
fi
@@ -386,7 +371,7 @@ do_install_from_source() {
echo "Error: Could not determine RID from build output." >&2
exit 1
fi
publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net9.0/$rid/publish"
publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net10.0/$rid/publish"
if [ ! -d "$publish_dir" ] || [ ! -f "$publish_dir/DnsmasqWebUI" ]; then
echo "Error: Build output not found at $publish_dir" >&2
exit 1
@@ -440,8 +425,8 @@ do_install() {
return
fi
echo "Installing dnsmasq-webui..."
check_jq
detect_repo
if [ "$SYSTEM_INSTALL" = true ]; then
if [ "$(id -u)" -ne 0 ]; then
@@ -468,20 +453,20 @@ do_install() {
print_env_detection "$rid"
echo "Fetching release..."
release_json="$(fetch_release)"
tag="$(echo "$release_json" | jq -r '.tag_name')"
tag="$(jq_release "$release_json" '.tag_name')"
echo "Release: $tag"
url="$(find_asset_url "$release_json" "$rid" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
url="$(find_asset_url "$release_json" "$rid")"
if [ -z "$url" ] || [ "$url" = "null" ]; then
echo "Error: No asset found for RID $rid in release $tag." >&2
echo "Available assets:" >&2
echo "$release_json" | jq -r '.assets[].name' | sed 's/^/ /' >&2
jq_release "$release_json" '.assets[].name' | sed 's/^/ /' >&2
exit 1
fi
mkdir -p "$INSTALL_DIR"
tmpzip="${TMPDIR:-/tmp}/dnsmasq-webui-$rid.zip"
tmpzip="$(mktemp)"
echo "Downloading $url ..."
curl -sSL -A "dnsmasq-webui-install/1.0" -o "$tmpzip" "$url"
curl -sSL -A "dnsmasq-webui-install/1.0" -o "$tmpzip" "$url" </dev/null
echo "Extracting to $INSTALL_DIR ..."
unzip -o -q "$tmpzip" -d "$INSTALL_DIR"
rm -f "$tmpzip"
@@ -619,12 +604,6 @@ while [ $# -gt 0 ]; do
-h|-?|--help)
usage
;;
--repo)
shift
[ $# -gt 0 ] || { echo "Error: --repo requires OWNER/REPO" >&2; exit 1; }
GITHUB_REPO="$1"
shift
;;
--list)
LIST=true
shift
@@ -689,26 +668,26 @@ if [ "$PURGE" = true ] && [ "$UNINSTALL" != true ]; then
echo "Error: --purge must be used with --uninstall. E.g. $0 --uninstall --purge" >&2
exit 1
fi
if [ "$UNINSTALL" = true ]; then
if [ "$UPDATE" = true ] || [ -n "$RELEASE_TAG" ] || [ "$SERVICE" = true ] || [ "$BUILD_FROM_SOURCE" = true ]; then
echo "Error: --uninstall cannot be combined with install/update options (--version, --update, --service, --build-from-source). Run uninstall alone, then install if needed." >&2
exit 1
fi
if [ "$LIST" = true ]; then
echo "Error: --uninstall cannot be combined with --list." >&2
exit 1
fi
if [ "$UNINSTALL" = true ]; then
if [ "$UPDATE" = true ] || [ -n "$RELEASE_TAG" ] || [ "$SERVICE" = true ] || [ "$BUILD_FROM_SOURCE" = true ]; then
echo "Error: --uninstall cannot be combined with install/update options (--version, --update, --service, --build-from-source). Run uninstall alone, then install if needed." >&2
exit 1
fi
if [ "$BUILD_FROM_SOURCE" = true ]; then
if [ "$LIST" = true ]; then
echo "Error: --build-from-source cannot be combined with --list." >&2
exit 1
fi
if [ -n "$RELEASE_TAG" ] || [ "$UPDATE" = true ]; then
echo "Error: --build-from-source builds from current source; do not use --version or --update." >&2
exit 1
fi
if [ "$LIST" = true ]; then
echo "Error: --uninstall cannot be combined with --list." >&2
exit 1
fi
fi
if [ "$BUILD_FROM_SOURCE" = true ]; then
if [ "$LIST" = true ]; then
echo "Error: --build-from-source cannot be combined with --list." >&2
exit 1
fi
if [ -n "$RELEASE_TAG" ] || [ "$UPDATE" = true ]; then
echo "Error: --build-from-source builds from current source; do not use --version or --update." >&2
exit 1
fi
fi
if [ "$LIST" = true ]; then
if [ "$UNINSTALL" = true ] || [ "$UPDATE" = true ] || [ "$SERVICE" = true ] || [ -n "$RELEASE_TAG" ]; then
echo "Error: --list lists releases and exits; do not combine with install/uninstall options." >&2
+147 -25
View File
@@ -3,8 +3,8 @@
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
#
# What this script does:
# Start (default): clear mount (unless --no-clear), sync source -> mount, clean up previous test data,
# then docker compose up -d [--build] [--force-recreate].
# Start (default): sync source -> mount (preserving existing unless --clear); optional --reset-managed
# removes app-written files in the mount. Then docker compose up -d [--build] [--force-recreate].
# --stop: docker compose down (stop and remove containers/networks).
# --tidy: docker compose down, then clear the mount directory for a clean next run.
set -e
@@ -15,40 +15,54 @@ COMPOSE_FILE="docker-compose.test.yml"
SOURCE_DIR=""
MOUNT_DIR=""
DNSMASQ_VERSION=""
PREPARE_ONLY=false
BUILD=false
NO_CACHE_BUILD=false
RECREATE=false
NO_CLEAR=false
CLEAR=false
RESET_MANAGED=false
STOP=false
TIDY=false
MINIMAL_CONF=false
usage() {
echo "Usage: $0 [OPTIONS] [--]"
echo ""
echo "Prepare the testdata mount directory and optionally start or stop the Docker test harness"
echo "(app + dnsmasq + DHCP client). By default: clear mount, sync testdata -> mount, then up -d (no rebuild)."
echo "(app + dnsmasq + DHCP client). By default: sync testdata -> mount (preserving existing), then up -d (no rebuild)."
echo ""
echo "Steps (when starting):"
echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount."
echo " 2. Clean up previous test data (e.g. managed config) so the harness starts clean."
echo " 1. Optionally clear mount dir (only with --clear), then sync source -> mount."
echo " Sync skips app-managed filenames (zz-dnsmasq-webui.conf / .hosts) so rebuilds keep UI state."
echo " 2. Optionally remove managed *dnsmasq-webui*.conf / *dnsmasq-webui*.hosts (only with --reset-managed)."
echo " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
echo ""
echo "Path options:"
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) Clear mount dir completely, then sync. Use for a clean run."
echo " --no-clear Do not clear mount dir; only sync over existing contents."
echo " Use to preserve leases or debug files between runs."
echo " (default) Preserve mount dir; sync source over existing contents."
echo " --clear Clear mount dir completely before sync. Use for a clean run."
echo " --reset-managed After sync, delete *dnsmasq-webui*.conf and *dnsmasq-webui*.hosts under the"
echo " mount so the app recreates managed config/hosts (leases and other files stay)."
echo ""
echo "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 ""
@@ -62,19 +76,25 @@ usage() {
echo ""
echo "Examples:"
echo " $0"
echo " Full run: clear mount, sync testdata, start containers (no rebuild)."
echo " Sync testdata to mount (preserving existing), start containers (no rebuild)."
echo ""
echo " $0 --build"
echo " Clear and sync, then rebuild images and start containers."
echo " Sync, rebuild images, and start containers."
echo ""
echo " $0 --recreate"
echo " Clear and sync, then up -d --force-recreate (fresh containers)."
echo " Sync, then up -d --force-recreate (fresh containers)."
echo ""
echo " $0 --no-clear"
echo " Preserve mount contents, sync over it, start (no rebuild)."
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 ""
echo " $0 --reset-managed --build"
echo " Rebuild images; sync fixtures; drop managed conf/hosts so app writes fresh."
echo ""
echo " $0 --prepare-only"
echo " Only clear and sync testdata -> testdata-mount; no containers."
echo " Only sync testdata -> testdata-mount; no containers."
echo " Then run: docker compose -f $COMPOSE_FILE up -d [--build]"
echo ""
echo " $0 --source myfixtures --mount mymount --prepare-only"
@@ -86,6 +106,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
@@ -106,8 +158,18 @@ while [ $# -gt 0 ]; do
MOUNT_DIR="$1"
shift
;;
--no-clear)
NO_CLEAR=true
--dnsmasq-version)
shift
[ $# -gt 0 ] || { echo "Error: --dnsmasq-version requires VERSION" >&2; exit 1; }
DNSMASQ_VERSION="$1"
shift
;;
--clear)
CLEAR=true
shift
;;
--reset-managed)
RESET_MANAGED=true
shift
;;
--prepare-only)
@@ -118,14 +180,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
@@ -161,6 +233,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
@@ -183,26 +256,44 @@ 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
mkdir -p "$MOUNT_DIR"
if [ "$NO_CLEAR" = false ]; then
if [ "$CLEAR" = true ]; then
echo "Clearing mount directory: $MOUNT_DIR"
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
fi
# Exclude sample leases (harness creates real lease file) and app-managed filenames so rebuilds preserve
# UI-written config/hosts; use --reset-managed or --clear when you want those removed.
if command -v rsync >/dev/null 2>&1; then
rsync -a --exclude='leases' "$SOURCE_DIR/" "$MOUNT_DIR/"
rsync -a \
--exclude='leases' \
--exclude='zz-dnsmasq-webui.conf' \
--exclude='zz-dnsmasq-webui.hosts' \
"$SOURCE_DIR/" "$MOUNT_DIR/"
else
cp -r "$SOURCE_DIR/." "$MOUNT_DIR/"
rm -f "$MOUNT_DIR/leases"
(cd "$SOURCE_DIR" && tar cf - --exclude=leases --exclude=zz-dnsmasq-webui.conf --exclude=zz-dnsmasq-webui.hosts .) | (cd "$MOUNT_DIR" && tar xf -)
fi
# Clean up previous test data (e.g. managed config) so the harness starts clean (app will create zz-dnsmasq-webui.conf on startup).
find "$MOUNT_DIR" -name '*dnsmasq-webui*.conf' -type f -delete 2>/dev/null || true
if [ "$RESET_MANAGED" = true ]; then
echo "Removing app-managed files (*dnsmasq-webui*.conf / *dnsmasq-webui*.hosts) under $MOUNT_DIR"
find "$MOUNT_DIR" \( -name '*dnsmasq-webui*.conf' -o -name '*dnsmasq-webui*.hosts' \) -type f -delete 2>/dev/null || true
fi
echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR)."
@@ -211,7 +302,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
@@ -221,8 +321,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
+23 -6
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)"
@@ -85,7 +88,7 @@ while [ $# -gt 0 ]; do
echo " ubuntu.24.04-x64 ubuntu.24.04-arm64"
echo " ubuntu.22.04-x64 ubuntu.22.04-arm64"
echo ""
echo "Output: src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/"
echo "Output: src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/"
echo ""
echo "Examples:"
echo " $0 # Publish for current machine (auto-detect RID)"
@@ -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,22 +155,32 @@ 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/net9.0/$RID/publish"
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish"
echo ""
echo "Done. Output: $OUT_DIR"
echo "Run on this host: $OUT_DIR/DnsmasqWebUI"
@@ -0,0 +1,71 @@
using DnsmasqWebUI.Components.DnsRecords.Editors;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.DnsRecords;
namespace DnsmasqWebUI.Tests.Components.DnsRecords;
public class DnsRecordEditorSyncGuardTests
{
[Fact]
public void AddMode_SameEmptyKey_ReseedsOnce()
{
string? k = null;
Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null));
Assert.False(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null));
}
[Fact]
public void AddMode_DifferentAddOption_ReseedsAgain()
{
string? k = null;
Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null, "add:naptr-record"));
Assert.False(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null, "add:naptr-record"));
Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null, "add:caa-record"));
}
[Fact]
public void EditMode_UsesRowId()
{
var row = new DnsRecordRow(
"cname:0",
"cname:0",
DnsmasqConfKeys.Cname,
DnsRecordFamily.Cname,
0,
"a,b",
null,
null,
null,
false,
true,
new CnamePayload(["a"], "b", null),
[],
"s");
string? k = null;
Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, row));
Assert.False(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, row));
}
[Fact]
public void TransitionFromAddToEdit_Reseeds()
{
string? k = null;
DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null);
var row = new DnsRecordRow(
"txt:1",
"txt:1",
DnsmasqConfKeys.TxtRecord,
DnsRecordFamily.Txt,
1,
"x,y",
null,
null,
null,
false,
true,
new TxtPayload("x", "y"),
[],
"s");
Assert.True(DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, row));
}
}
@@ -1,54 +0,0 @@
using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for <see cref="ConfigValueSource"/> and <see cref="ConfigValueSource.GetReadOnlyTooltip"/>.
/// </summary>
public class ConfigValueSourceTests
{
[Fact]
public void GetReadOnlyTooltip_WhenManaged_ReturnsNull()
{
var source = new ConfigValueSource("/data/zz-managed.conf", "zz-managed.conf", IsManaged: true, LineNumber: 5);
Assert.Null(source.GetReadOnlyTooltip());
}
[Fact]
public void GetReadOnlyTooltip_WhenReadOnlyWithoutLineNumber_ReturnsFileNameOnly()
{
var source = new ConfigValueSource("/etc/dnsmasq.d/02.conf", "02.conf", IsManaged: false, LineNumber: null);
var tooltip = source.GetReadOnlyTooltip();
Assert.NotNull(tooltip);
Assert.Contains("02.conf", tooltip);
Assert.Contains("readonly", tooltip);
Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip);
Assert.DoesNotContain("line", tooltip);
}
[Fact]
public void GetReadOnlyTooltip_WhenReadOnlyWithLineNumber_IncludesLineNumber()
{
var source = new ConfigValueSource("/etc/dnsmasq.d/02.conf", "02.conf", IsManaged: false, LineNumber: 3);
var tooltip = source.GetReadOnlyTooltip();
Assert.NotNull(tooltip);
Assert.Contains("02.conf", tooltip);
Assert.Contains("line 3", tooltip);
Assert.Contains("readonly", tooltip);
Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip);
}
[Fact]
public void IsReadOnly_WhenManaged_IsFalse()
{
var source = new ConfigValueSource("/data/zz.conf", "zz.conf", IsManaged: true);
Assert.False(source.IsReadOnly);
}
[Fact]
public void IsReadOnly_WhenNotManaged_IsTrue()
{
var source = new ConfigValueSource("/etc/dnsmasq.conf", "dnsmasq.conf", IsManaged: false);
Assert.True(source.IsReadOnly);
}
}
@@ -1,184 +0,0 @@
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for DnsmasqConfDhcpHostLineParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore]
/// Comma-separated; ## = deleted, # = comment; trailing # comment allowed.
/// </summary>
public class DnsmasqConfDhcpHostLineParserTests
{
[Fact]
public void ParseLine_NotDhcpHost_ReturnsNull()
{
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("domain=local", 1));
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-range=192.168.1.1,192.168.1.100", 1));
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("# comment", 1));
}
[Fact]
public void ParseLine_BasicMacIpNameLease_Parses()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1);
Assert.NotNull(e);
Assert.False(e!.IsComment);
Assert.False(e.IsDeleted);
Assert.Single(e.MacAddresses);
Assert.Equal("aa:bb:cc:dd:ee:ff", e.MacAddresses[0]);
Assert.Equal("192.168.1.10", e.Address);
Assert.Equal("testpc", e.Name);
Assert.Equal("infinite", e.Lease);
Assert.Empty(e.Extra);
}
[Fact]
public void ParseLine_CommentedLine_ParsesWithIsComment()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1);
Assert.NotNull(e);
Assert.True(e!.IsComment);
Assert.False(e.IsDeleted);
Assert.Single(e.MacAddresses);
Assert.Equal("192.168.1.10", e.Address);
}
[Fact]
public void ParseLine_DeletedLine_ParsesWithIsDeleted()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc", 1);
Assert.NotNull(e);
Assert.True(e!.IsComment);
Assert.True(e.IsDeleted);
Assert.Single(e.MacAddresses);
Assert.Equal("192.168.1.10", e.Address);
}
[Fact]
public void ParseLine_TrailingComment_ParsesComment()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop", 1);
Assert.NotNull(e);
Assert.Equal("main laptop", e!.Comment);
Assert.Single(e.MacAddresses);
}
[Fact]
public void ParseLine_MultipleMacs_SameIp_Parses()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2", 1);
Assert.NotNull(e);
Assert.Equal(2, e!.MacAddresses.Count);
Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]);
Assert.Equal("12:34:56:78:90:12", e.MacAddresses[1]);
Assert.Equal("192.168.0.2", e.Address);
}
[Fact]
public void ParseLine_WithSetTag_PutsInExtra()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=AA:BB:CC:DD:CC:BB,redhost1,192.168.1.41,infinite,set:red", 1);
Assert.NotNull(e);
Assert.Equal("192.168.1.41", e!.Address);
Assert.Equal("redhost1", e.Name);
Assert.Contains(e.Extra, x => x == "set:red");
}
[Fact]
public void ParseLine_HostnameOnly_Parses()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=lap,192.168.0.199", 1);
Assert.NotNull(e);
Assert.Equal("lap", e!.Name);
Assert.Equal("192.168.0.199", e.Address);
Assert.Empty(e.MacAddresses);
}
[Fact]
public void ParseLine_Ignore_Parses()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=00:20:e0:3b:13:af,ignore", 1);
Assert.NotNull(e);
Assert.True(e!.Ignore);
Assert.Single(e.MacAddresses);
Assert.Equal("00:20:e0:3b:13:af", e.MacAddresses[0]);
}
[Fact]
public void ParseLine_NumericLease_Parses()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,pc,3600", 1);
Assert.NotNull(e);
Assert.Equal("3600", e!.Lease);
}
[Fact]
public void ParseLine_WithIdClient_PutsInExtra()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=id:01:02:03:04,192.168.1.50,myhost", 1);
Assert.NotNull(e);
Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase));
Assert.Equal("192.168.1.50", e.Address);
}
[Fact]
public void ToLine_Roundtrip_Basic()
{
var line = "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("dhcp-host=", back);
Assert.Contains("aa:bb:cc:dd:ee:ff", back);
Assert.Contains("192.168.1.10", back);
Assert.Contains("testpc", back);
Assert.Contains("infinite", back);
}
[Fact]
public void ToLine_Roundtrip_WithComment()
{
var line = "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("dhcp-host=", back);
Assert.Contains("main laptop", back);
}
[Fact]
public void ToLine_CommentedEntry_Prefix()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc", 1);
Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("#dhcp-host=", back);
}
[Fact]
public void ToLine_DeletedEntry_DoubleHash()
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,old", 1);
Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("##dhcp-host=", back);
}
[Fact]
public void Parse_testdata_dhcp_conf_ParsesDhcpHostLines()
{
var lines = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf");
var dhcpEntries = new List<DhcpHostEntry>();
for (var i = 0; i < lines.Length; i++)
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine(lines[i], i + 1);
if (e != null)
dhcpEntries.Add(e);
}
Assert.Single(dhcpEntries);
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpEntries[0].MacAddresses[0]);
Assert.Equal("172.28.0.100", dhcpEntries[0].Address);
Assert.Equal("testpc", dhcpEntries[0].Name);
Assert.Equal("infinite", dhcpEntries[0].Lease);
}
}
@@ -1,65 +0,0 @@
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for DnsmasqConfDirectiveParser. Key/value parsing (TryParseKeyValue) and comment stripping (StripComment)
/// used by DnsmasqConfIncludeParser to build effective config.
/// </summary>
public class DnsmasqConfDirectiveParserTests
{
[Fact]
public void TryParseKeyValue_Empty_ReturnsNull()
{
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue(""));
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue(" "));
}
[Fact]
public void TryParseKeyValue_Comment_ReturnsNull()
{
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("# comment"));
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("# addn-hosts=/etc/hosts"));
}
[Fact]
public void TryParseKeyValue_KeyValue_ReturnsKeyAndValue()
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53");
Assert.NotNull(kv);
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
Assert.Equal("53", kv.Value.value);
}
[Fact]
public void TryParseKeyValue_KeyOnly_ReturnsEmptyValue()
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("domain-needed");
Assert.NotNull(kv);
Assert.Equal(DnsmasqConfKeys.DomainNeeded, kv!.Value.key);
Assert.Equal("", kv.Value.value);
}
[Fact]
public void TryParseKeyValue_CommentedLine_ReturnsNull()
{
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("#addn-hosts=/etc/hosts"));
}
[Fact]
public void StripComment_CommentAfterSpace_StripsToEnd()
{
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # DNS port"));
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # comment"));
}
[Fact]
public void TryParseKeyValue_LineWithComment_ValueExcludesComment()
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53 # DNS port");
Assert.NotNull(kv);
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
Assert.Equal("53", kv.Value.value);
}
}
@@ -1,600 +0,0 @@
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.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 includePath = Path.Combine(dir, "extra.conf");
File.WriteAllText(includePath, "# extra\n");
var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "conf-file=extra.conf\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 subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir);
try
{
File.WriteAllText(Path.Combine(subDir, "zz.conf"), "");
File.WriteAllText(Path.Combine(subDir, "aa.conf"), "");
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d\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, "aa.conf")), paths[1]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "zz.conf")), 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 subDir = Path.Combine(baseDir, "dnsmasq.d");
Directory.CreateDirectory(subDir);
try
{
var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=dnsmasq.d\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_testdata_dnsmasq_conf_ReturnsMainOnlyWhenConfDirMissing()
{
var mainPath = TestDataHelper.GetPath("dnsmasq.conf");
if (!File.Exists(mainPath))
return; // testdata not copied
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
Assert.Single(paths);
Assert.Equal(Path.GetFullPath(mainPath), paths[0]);
}
[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 first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, "dhcp-leasefile=/var/first.leases\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, "dhcp-leasefile=/var/second.leases\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
Assert.Equal(Path.GetFullPath("/var/second.leases"), 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 conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "dhcp-leasefile=subdir/leases\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "subdir", "leases")), 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 first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, "addn-hosts=/etc/hosts.d/first\n");
var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, "addn-hosts=/data/hosts\naddn-hosts=/data/extra.hosts\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath("/etc/hosts.d/first"), result[0]);
Assert.Equal(Path.GetFullPath("/data/hosts"), result[1]);
Assert.Equal(Path.GetFullPath("/data/extra.hosts"), 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 conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "addn-hosts=hosts.d/app.hosts\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
Assert.Single(result);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "hosts.d", "app.hosts")), 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");
try
{
File.WriteAllText(f1, "cache-size=100\n");
File.WriteAllText(f2, "cache-size=200\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
Assert.Equal("200", 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 conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "log-facility=/data/dnsmasq.log\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
Assert.Equal("/data/dnsmasq.log", 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");
try
{
File.WriteAllText(f1, "log-facility=/var/log/dnsmasq.log\n");
File.WriteAllText(f2, "log-facility=/data/dnsmasq.log\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
Assert.Equal("/data/dnsmasq.log", value);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ResolvePath_Relative_ResolvesAgainstDir()
{
var dir = Path.GetTempPath();
var result = DnsmasqConfIncludeParser.ResolvePath("sub/pid.pid", dir);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "sub", "pid.pid")), result);
}
[Fact]
public void ResolvePath_Absolute_ReturnsAsIs()
{
var abs = Path.Combine(Path.GetTempPath(), "absolute.pid");
var result = DnsmasqConfIncludeParser.ResolvePath(abs, "/some/dir");
Assert.Equal(Path.GetFullPath(abs), 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 conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "expand-hosts=1\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 conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "Port=53\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
Assert.Null(value);
}
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");
try
{
File.WriteAllText(main, "port=53\nconf-file=extra.conf\nport=5353\n");
File.WriteAllText(extra, "port=54\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.Equal("54", 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);
try
{
File.WriteAllText(Path.Combine(subDir, "a.conf"), "");
File.WriteAllText(Path.Combine(subDir, "b.txt"), "");
File.WriteAllText(Path.Combine(subDir, "c.conf"), "");
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("a.conf", StringComparison.Ordinal));
Assert.Contains(paths, p => p.EndsWith("c.conf", StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith("b.txt", 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);
try
{
File.WriteAllText(Path.Combine(subDir, "a.conf"), "");
File.WriteAllText(Path.Combine(subDir, "b.conf.bak"), "");
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("a.conf", StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith("b.conf.bak", 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");
try
{
File.WriteAllText(f1, "addn-hosts=/etc/hosts.a\n");
File.WriteAllText(f2, "addn-hosts=/etc/hosts.b\naddn-hosts=/etc/hosts.c\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath("/etc/hosts.a"), 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("/etc/hosts.b"), result[1].Path);
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
Assert.Equal(1, result[1].Source.LineNumber);
Assert.Equal(Path.GetFullPath("/etc/hosts.c"), 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");
try
{
File.WriteAllText(mainPath, "addn-hosts=/other/hosts\n");
File.WriteAllText(managedPath, "addn-hosts=/managed/hosts\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");
try
{
File.WriteAllText(f1, "server=1.1.1.1\nlocal=/local/\n");
File.WriteAllText(f2, "server=/example.com/192.168.1.1\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
Assert.Equal(3, result.Count);
Assert.Equal("1.1.1.1", result[0]);
Assert.Equal("/local/", result[1]);
Assert.Equal("/example.com/192.168.1.1", 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");
try
{
File.WriteAllText(conf, "dhcp-range=172.28.0.10,172.28.0.50,12h\ndhcp-range=192.168.1.10,192.168.1.100,255.255.255.0,24h\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
Assert.Equal(2, result.Count);
Assert.Equal("172.28.0.10,172.28.0.50,12h", result[0]);
Assert.Equal("192.168.1.10,192.168.1.100,255.255.255.0,24h", result[1]);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -1,120 +0,0 @@
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for DnsmasqLeasesFileLineParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated
/// fields — expiry (epoch), MAC, IP, hostname (or *), client-id (or *).
/// </summary>
public class DnsmasqLeasesFileLineParserTests
{
[Fact]
public void ParseLine_Empty_ReturnsNull()
{
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(""));
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(" "));
}
[Fact]
public void ParseLine_ValidFiveFields_Parses()
{
var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05");
Assert.NotNull(e);
Assert.Equal(946689575, e!.Epoch);
Assert.Equal("00:00:00:00:00:05", e.Mac);
Assert.Equal("192.168.1.155", e.Address);
Assert.Equal("wdt", e.Name);
Assert.Equal("01:00:00:00:00:00:05", e.ClientId);
}
[Fact]
public void ParseLine_UnknownHostname_Asterisk()
{
var e = DnsmasqLeasesFileLineParser.ParseLine("946689522 00:00:00:00:00:04 192.168.1.237 * 01:00:00:00:00:00:04");
Assert.NotNull(e);
Assert.Equal("*", e!.Name);
Assert.Equal("192.168.1.237", e.Address);
}
[Fact]
public void ParseLine_UnknownClientId_Asterisk()
{
var e = DnsmasqLeasesFileLineParser.ParseLine("946689351 00:0f:b0:3a:b5:0b 192.168.1.208 colinux *");
Assert.NotNull(e);
Assert.Equal("colinux", e!.Name);
Assert.Equal("*", e.ClientId);
}
[Fact]
public void ParseLine_MultipleSpacesBetweenFields_Parses()
{
var e = DnsmasqLeasesFileLineParser.ParseLine(" 946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 ");
Assert.NotNull(e);
Assert.Equal(946689575, e!.Epoch);
Assert.Equal("00:00:00:00:00:05", e.Mac);
Assert.Equal("192.168.1.155", e.Address);
Assert.Equal("wdt", e.Name);
Assert.Equal("01:00:00:00:00:00:05", e.ClientId);
}
[Fact]
public void ParseLine_TooFewFields_ReturnsNull()
{
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155"));
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05"));
}
[Fact]
public void ParseLine_TrailingJunk_ReturnsNull()
{
// Parser uses .End() so extra text after fifth field fails
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 extra"));
}
[Fact]
public void ParseLine_InvalidEpoch_ReturnsNull()
{
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("notanum 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"));
}
/// <summary>
/// When DHCPv6 is in use, dnsmasq writes a "duid ..." line; we must skip it (first field is not numeric).
/// </summary>
[Fact]
public void ParseLine_DuidLine_ReturnsNull()
{
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("duid 00:11:22:33:44:55"));
}
[Fact]
public void Timestamp_ConvertsEpochToDateTime()
{
var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05");
Assert.NotNull(e);
var dt = e!.Timestamp;
Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12
}
[Fact]
public void Parse_testdata_leases_ParsesAllLines()
{
var lines = TestDataHelper.ReadAllLines("leases");
var entries = new List<LeaseEntry>();
foreach (var line in lines)
{
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
if (e != null)
entries.Add(e);
}
Assert.Equal(2, entries.Count);
Assert.Equal("aa:bb:cc:dd:ee:ff", entries[0].Mac);
Assert.Equal("192.168.1.10", entries[0].Address);
Assert.Equal("testpc", entries[0].Name);
Assert.Equal("01:02:03:04:05:06", entries[0].ClientId);
Assert.Equal("11:22:33:44:55:66", entries[1].Mac);
Assert.Equal("192.168.1.11", entries[1].Address);
Assert.Equal("*", entries[1].Name);
Assert.Equal("*", entries[1].ClientId);
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -1,110 +0,0 @@
using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for EffectiveDnsmasqConfig, including GetLogsPath (log-facility as file path).
/// </summary>
public class EffectiveDnsmasqConfigTests
{
private static EffectiveDnsmasqConfig CreateConfig(string? logFacility = null) =>
new(
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
ServerLocalValues: Array.Empty<string>(),
AddressValues: Array.Empty<string>(),
Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(),
ExceptInterfaces: Array.Empty<string>(),
DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(),
DhcpOptionLines: Array.Empty<string>(),
ResolvFiles: Array.Empty<string>(),
ExpandHosts: false,
BogusPriv: false,
StrictOrder: false,
NoResolv: false,
DomainNeeded: false,
NoPoll: false,
BindInterfaces: false,
NoNegcache: false,
DhcpAuthoritative: false,
LeasefileRo: false,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
LocalTtl: null,
PidFilePath: null,
User: null,
Group: null,
LogFacility: logFacility,
DhcpLeaseMax: null,
NegTtl: null,
MaxTtl: null,
MaxCacheTtl: null,
MinCacheTtl: null,
DhcpTtl: null
);
[Fact]
public void GetLogsPath_NullConfig_ReturnsNull()
{
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(null));
}
[Fact]
public void GetLogsPath_NullLogFacility_ReturnsNull()
{
var config = CreateConfig(logFacility: null);
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_EmptyLogFacility_ReturnsNull()
{
var config = CreateConfig(logFacility: "");
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_WhitespaceLogFacility_ReturnsNull()
{
var config = CreateConfig(logFacility: " ");
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_AbsolutePath_ReturnsPath()
{
var config = CreateConfig(logFacility: "/data/dnsmasq.log");
Assert.Equal("/data/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_AbsolutePathWithTrim_ReturnsPath()
{
var config = CreateConfig(logFacility: " /var/log/dnsmasq.log ");
Assert.Equal("/var/log/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_RelativePathWithSlash_ReturnsPath()
{
var config = CreateConfig(logFacility: "logs/dnsmasq.log");
Assert.Equal("logs/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_SyslogFacilityName_ReturnsNull()
{
var config = CreateConfig(logFacility: "local0");
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_Stderr_ReturnsNull()
{
var config = CreateConfig(logFacility: "-");
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
}
}
@@ -0,0 +1,101 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>
/// Sample config line per option for consistent parser coverage. When adding new options to the app,
/// add an entry here so the option-coverage test continues to exercise every option.
/// </summary>
public static class OptionCoverageData
{
/// <summary>Option key and a minimal valid config line (without newline).</summary>
public static IReadOnlyList<(string OptionKey, string ConfigLine)> GetParserCoverageEntries()
{
return new (string, string)[]
{
// LastWins
(DnsmasqConfKeys.Port, "port=53"),
(DnsmasqConfKeys.CacheSize, "cache-size=500"),
(DnsmasqConfKeys.LocalTtl, "local-ttl=300"),
(DnsmasqConfKeys.MxTarget, "mx-target=mail.example.com"),
(DnsmasqConfKeys.DhcpLeasefile, "dhcp-leasefile=/var/lib/dnsmasq.leases"),
(DnsmasqConfKeys.DhcpLeaseMax, "dhcp-lease-max=1000"),
(DnsmasqConfKeys.Hostsdir, "hostsdir=/etc/dnsmasq.d"),
(DnsmasqConfKeys.PidFile, "pid-file=/run/dnsmasq.pid"),
(DnsmasqConfKeys.User, "user=nobody"),
(DnsmasqConfKeys.TftpRoot, "tftp-root=/var/lib/tftpboot"),
// Flag
(DnsmasqConfKeys.NoHosts, "no-hosts"),
(DnsmasqConfKeys.ExpandHosts, "expand-hosts"),
(DnsmasqConfKeys.BogusPriv, "bogus-priv"),
(DnsmasqConfKeys.NoResolv, "no-resolv"),
(DnsmasqConfKeys.DomainNeeded, "domain-needed"),
(DnsmasqConfKeys.StrictOrder, "strict-order"),
(DnsmasqConfKeys.AllServers, "all-servers"),
(DnsmasqConfKeys.BindInterfaces, "bind-interfaces"),
(DnsmasqConfKeys.DhcpAuthoritative, "dhcp-authoritative"),
(DnsmasqConfKeys.EnableTftp, "enable-tftp"),
(DnsmasqConfKeys.Dnssec, "dnssec"),
(DnsmasqConfKeys.Conntrack, "conntrack"),
(DnsmasqConfKeys.Do0x20Encode, "do-0x20-encode"),
(DnsmasqConfKeys.No0x20Encode, "no-0x20-encode"),
// Multi - resolver / DNS
(DnsmasqConfKeys.Server, "server=1.1.1.1"),
(DnsmasqConfKeys.Local, "local=/lan/"),
(DnsmasqConfKeys.RevServer, "rev-server=192.168.1.0/24,10.0.0.1"),
(DnsmasqConfKeys.Address, "address=/example.com/127.0.0.1"),
(DnsmasqConfKeys.Domain, "domain=home.lan,192.168.1.0/24"),
(DnsmasqConfKeys.Cname, "cname=www.example.com,example.com"),
(DnsmasqConfKeys.MxHost, "mx-host=example.com,mail.example.com,10"),
(DnsmasqConfKeys.Srv, "srv-host=_http._tcp.example.com,host.example.com,80"),
(DnsmasqConfKeys.PtrRecord, "ptr-record=1.168.192.in-addr.arpa,router.lan"),
(DnsmasqConfKeys.TxtRecord, "txt-record=example.com,text"),
(DnsmasqConfKeys.NaptrRecord, "naptr-record=example.com,0,0,a,s,r"),
(DnsmasqConfKeys.DnsRr, "dns-rr=example.com,16,01:02"),
(DnsmasqConfKeys.HostRecord, "host-record=host.example.com,1.2.3.4"),
(DnsmasqConfKeys.DynamicHost, "dynamic-host=example.com"),
(DnsmasqConfKeys.CaaRecord, "caa-record=example.com,0,issue,letsencrypt.org"),
(DnsmasqConfKeys.RebindDomainOk, "rebind-domain-ok=example.com"),
(DnsmasqConfKeys.BogusNxdomain, "bogus-nxdomain=192.168.1.1"),
(DnsmasqConfKeys.IgnoreAddress, "ignore-address=192.168.1.1"),
(DnsmasqConfKeys.Alias, "alias=0.0.0.0,example.com"),
(DnsmasqConfKeys.FilterRr, "filter-rr=0.0.0.0,A"),
(DnsmasqConfKeys.CacheRr, "cache-rr=0.0.0.0,A"),
(DnsmasqConfKeys.Ipset, "ipset=/example.com/set1"),
(DnsmasqConfKeys.Nftset, "nftset=/example.com/table/set"),
(DnsmasqConfKeys.ConnmarkAllowlist, "connmark-allowlist=0xff,example.com"),
// Multi - auth / synth
(DnsmasqConfKeys.AuthServer, "auth-server=zone.example.com,eth0"),
(DnsmasqConfKeys.SynthDomain, "synth-domain=dynamic.example.com,192.168.2.1,192.168.2.100"),
(DnsmasqConfKeys.AuthZone, "auth-zone=example.com"),
(DnsmasqConfKeys.TrustAnchor, "trust-anchor=.,20326,8,2,E4F1769B8A0E3142E125223716F878F0A3B2C3D4"),
// Multi - DHCP
(DnsmasqConfKeys.DhcpRange, "dhcp-range=192.168.1.50,192.168.1.150,12h"),
(DnsmasqConfKeys.DhcpHost, "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10"),
(DnsmasqConfKeys.DhcpOption, "dhcp-option=3,192.168.1.1"),
(DnsmasqConfKeys.DhcpOptionForce, "dhcp-option-force=option:dns-server,192.168.1.1"),
(DnsmasqConfKeys.DhcpMatch, "dhcp-match=set:efi,option:client-arch,6"),
(DnsmasqConfKeys.DhcpMac, "dhcp-mac=set:vendor,aa:bb:cc:dd:ee:ff"),
(DnsmasqConfKeys.DhcpNameMatch, "dhcp-name-match=set:name,hostname*"),
(DnsmasqConfKeys.DhcpIgnoreNames, "dhcp-ignore-names=tag:guest"),
(DnsmasqConfKeys.DhcpBoot, "dhcp-boot=pxelinux.0"),
(DnsmasqConfKeys.Leasequery, "leasequery=10.0.0.0/24"),
(DnsmasqConfKeys.RaParam, "ra-param=eth0,high"),
(DnsmasqConfKeys.Slaac, "slaac=eth0"),
(DnsmasqConfKeys.PxeService, "pxe-service=x86PC,Install Linux"),
(DnsmasqConfKeys.DhcpRelay, "dhcp-relay=192.168.1.1,eth0"),
(DnsmasqConfKeys.DhcpCircuitid, "dhcp-circuitid=eth0,010203"),
(DnsmasqConfKeys.DhcpRemoteid, "dhcp-remoteid=010203"),
(DnsmasqConfKeys.DhcpSubscrid, "dhcp-subscrid=010203"),
(DnsmasqConfKeys.TagIf, "tag-if=set:tag,eth0"),
(DnsmasqConfKeys.SharedNetwork, "shared-network=name,192.168.1.0/24"),
(DnsmasqConfKeys.DhcpOptionPxe, "dhcp-option-pxe=set:known,1"),
(DnsmasqConfKeys.DhcpVendorclass, "dhcp-vendorclass=set:pxe,PXEClient"),
(DnsmasqConfKeys.DhcpUserclass, "dhcp-userclass=set:efi,EFI"),
(DnsmasqConfKeys.AddnHosts, "addn-hosts=/etc/hosts.extra"),
(DnsmasqConfKeys.Interface, "interface=eth0"),
(DnsmasqConfKeys.ListenAddress, "listen-address=127.0.0.1"),
(DnsmasqConfKeys.ResolvFile, "resolv-file=/etc/resolv.dnsmasq"),
};
}
}
@@ -0,0 +1,51 @@
using System.Text.Json;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>Expected parser/effective state for a real-world corpus case. Counts and booleans only.</summary>
public sealed class CorpusExpected
{
public int? ServerCountMin { get; set; }
public int? LocalCountMin { get; set; }
public int? AddressCountMin { get; set; }
public int? CacheSize { get; set; }
public int? AddnHostsCountMin { get; set; }
public int? DomainCountMin { get; set; }
public bool? NoHosts { get; set; }
public bool? NoResolv { get; set; }
public bool? BogusPriv { get; set; }
public string? Do0x20State { get; set; }
public int? Port { get; set; }
public bool? ExpandHosts { get; set; }
public int? CnameCountMin { get; set; }
public int? TxtRecordCountMin { get; set; }
public int? SrvCountMin { get; set; }
public int? PtrRecordCountMin { get; set; }
public int? MxHostCountMin { get; set; }
public int? AuthZoneCountMin { get; set; }
public int? SynthDomainCountMin { get; set; }
public int? AuthServerCountMin { get; set; }
}
/// <summary>Single real-world corpus case from dnsmasq-real-cases.json.</summary>
public sealed class CorpusCase
{
public string File { get; set; } = "";
public CorpusExpected Expected { get; set; } = new();
public List<string> SemanticInvalidOptions { get; set; } = [];
}
/// <summary>Loads real-world corpus index and resolves paths via TestDataHelper.</summary>
public static class RealWorldCasesHelper
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
public static IReadOnlyList<CorpusCase> LoadCases()
{
var path = TestDataHelper.GetPath("real-world/dnsmasq-real-cases.json");
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<List<CorpusCase>>(json, JsonOptions) ?? [];
}
public static string Resolve(CorpusCase c) => TestDataHelper.GetPath(c.File);
}
@@ -0,0 +1,50 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>Ensures corpus index and files stay in sync; fails fast when testdata drifts.</summary>
public class RealWorldCorpusIntegrityTests
{
[Fact]
public void EveryCorpusCase_PointsToExistingFile()
{
var cases = RealWorldCasesHelper.LoadCases();
Assert.NotEmpty(cases);
foreach (var c in cases)
{
var path = RealWorldCasesHelper.Resolve(c);
Assert.True(File.Exists(path), $"Corpus file missing: {c.File}");
}
}
[Fact]
public void EveryBadCase_DeclaresSemanticInvalidOptions()
{
var cases = RealWorldCasesHelper.LoadCases();
var badFiles = new[] { "bad/", "real-world/bad/" };
foreach (var c in cases)
{
var isBad = badFiles.Any(p => c.File.Contains(p, StringComparison.Ordinal));
if (isBad)
Assert.NotEmpty(c.SemanticInvalidOptions);
}
}
[Fact]
public void SemanticInvalidOptions_ArePresentInParsedValues()
{
foreach (var c in RealWorldCasesHelper.LoadCases())
{
if (c.SemanticInvalidOptions.Count == 0)
continue;
var mainPath = RealWorldCasesHelper.Resolve(c);
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
foreach (var option in c.SemanticInvalidOptions)
{
var values = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, option);
Assert.NotEmpty(values);
}
}
}
}
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>
/// Resolves paths to testdata files. Files are copied to output (testdata/) when the project builds.
@@ -0,0 +1,43 @@
using DnsmasqWebUI.Models.Config;
namespace DnsmasqWebUI.Tests.Models.Config;
public sealed class DnsmasqManagedPathSetTests
{
[Fact]
public void FromOptions_WithoutManagedFilesDirectory_UsesMainConfigDirectory()
{
var options = new DnsmasqOptions
{
MainConfigPath = "/etc/dnsmasq.conf",
ManagedFileName = "zz-webui.conf",
ManagedHostsFileName = "zz-webui.hosts"
};
var paths = DnsmasqManagedPathSet.FromOptions(options);
Assert.Equal("/etc/dnsmasq.conf", paths.MainConfigPath);
Assert.Equal("/etc", paths.ManagedFilesDirectory);
Assert.Equal("/etc/zz-webui.conf", paths.ManagedFilePath);
Assert.Equal("/etc/zz-webui.hosts", paths.ManagedHostsFilePath);
}
[Fact]
public void FromOptions_WithManagedFilesDirectory_UsesOverrideDirectory()
{
var options = new DnsmasqOptions
{
MainConfigPath = "/etc/dnsmasq.conf",
ManagedFilesDirectory = "/srv/repo/dnsmasq",
ManagedFileName = "zz-webui.conf",
ManagedHostsFileName = "zz-webui.hosts"
};
var paths = DnsmasqManagedPathSet.FromOptions(options);
Assert.Equal("/etc/dnsmasq.conf", paths.MainConfigPath);
Assert.Equal("/srv/repo/dnsmasq", paths.ManagedFilesDirectory);
Assert.Equal("/srv/repo/dnsmasq/zz-webui.conf", paths.ManagedFilePath);
Assert.Equal("/srv/repo/dnsmasq/zz-webui.hosts", paths.ManagedHostsFilePath);
}
}
@@ -0,0 +1,64 @@
using DnsmasqWebUI.Models.Config;
namespace DnsmasqWebUI.Tests.Models.Config;
public sealed class DnsmasqOptionsValidatorTests
{
[Fact]
public void Validate_WithRelativeManagedFilesDirectory_Fails()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-validator-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(mainPath, "port=53\n");
try
{
var validator = new DnsmasqOptionsValidator();
var options = new DnsmasqOptions
{
MainConfigPath = mainPath,
ManagedFilesDirectory = "repo/dnsmasq"
};
var result = validator.Validate(null, options);
Assert.False(result.Succeeded);
Assert.NotNull(result.Failures);
Assert.Contains(result.Failures!, f => f.Contains("ManagedFilesDirectory", StringComparison.Ordinal));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void Validate_WithManagedFilesDirectoryAndFileNames_Succeeds()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-validator-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(mainPath, "port=53\n");
try
{
var validator = new DnsmasqOptionsValidator();
var options = new DnsmasqOptions
{
MainConfigPath = mainPath,
ManagedFilesDirectory = Path.Combine(dir, "repo"),
ManagedFileName = "zz-webui.conf",
ManagedHostsFileName = "zz-webui.hosts"
};
var result = validator.Validate(null, options);
Assert.True(result.Succeeded);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,38 @@
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Tests.Models.Dhcp;
public sealed class DhcpManagedHostSelectionTests
{
[Fact]
public void Single_removal_shifts_higher_indices()
{
var s = new HashSet<int> { 0, 2, 4 };
DhcpManagedHostSelection.RemapAfterRemovals(s, new[] { 1 });
Assert.Equal(new[] { 0, 1, 3 }, s.Order().ToArray());
}
[Fact]
public void Bulk_removal_removes_and_shifts()
{
var s = new HashSet<int> { 0, 2, 4 };
DhcpManagedHostSelection.RemapAfterRemovals(s, new[] { 1, 3, 5 });
Assert.Equal(new[] { 0, 1, 2 }, s.Order().ToArray());
}
[Fact]
public void Removing_edited_index_drops_it_from_selection()
{
var s = new HashSet<int> { 1, 3 };
DhcpManagedHostSelection.RemapAfterRemovals(s, new[] { 1 });
Assert.Equal(new[] { 2 }, s.Order().ToArray());
}
[Fact]
public void Empty_removed_is_noop()
{
var s = new HashSet<int> { 1, 2 };
DhcpManagedHostSelection.RemapAfterRemovals(s, Array.Empty<int>());
Assert.Equal(new[] { 1, 2 }, s.Order().ToArray());
}
}
@@ -0,0 +1,40 @@
using System.Linq;
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Tests.Models.Dhcp;
public class DhcpOptionDraftTests
{
[Fact]
public void FromRaw_WithPrefixes_RoundTrips()
{
const string raw = "tag:guest,option:router,192.168.1.1";
var d = DhcpOptionDraft.FromRaw(raw);
Assert.True(d.IsStructured);
Assert.Equal("option:router", d.Selector);
Assert.Equal(raw, d.ToRaw());
}
[Fact]
public void Preset_Router_HasExpectedSelector()
{
var p = DhcpOptionPresets.All.First(x => x.Key == "router");
Assert.Equal("option:router", p.Selector);
}
[Fact]
public void ToRaw_WithTagPrefix_PreservesOrder()
{
var d = new DhcpOptionDraft
{
PrefixTokens = new[] { "tag:guest" },
Selector = "option:dns-server",
ValueTokens = new List<string> { "8.8.8.8", "8.8.4.4" }
};
const string expected = "tag:guest,option:dns-server,8.8.8.8,8.8.4.4";
Assert.Equal(expected, d.ToRaw());
var roundTrip = DhcpOptionDraft.FromRaw(expected);
Assert.True(roundTrip.IsStructured);
Assert.Equal(expected, roundTrip.ToRaw());
}
}
@@ -0,0 +1,33 @@
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Tests.Models.Dhcp;
public class DhcpRangeDraftTests
{
[Fact]
public void FromRaw_Structured_RoundTrips()
{
const string raw = "tag:guest,192.168.1.50,192.168.1.150,12h";
var d = DhcpRangeDraft.FromRaw(raw);
Assert.True(d.IsStructured);
Assert.Equal(raw, d.ToRaw());
}
[Fact]
public void FromRaw_Unparseable_UsesFallback()
{
const string raw = "not-a-valid-range";
var d = DhcpRangeDraft.FromRaw(raw);
Assert.False(d.IsStructured);
Assert.Equal(raw, d.ToRaw());
}
[Fact]
public void FromRaw_ConstructorIpv4Range_IsStructured()
{
const string raw = "192.168.1.50,192.168.1.150,255.255.255.0,12h";
var d = DhcpRangeDraft.FromRaw(raw);
Assert.True(d.IsStructured);
Assert.Equal(raw, d.ToRaw());
}
}
@@ -0,0 +1,47 @@
using DnsmasqWebUI.Models.DnsRecords;
namespace DnsmasqWebUI.Tests.Models.DnsRecords;
public sealed class DnsRecordsFileFilterBuilderTests
{
private static DnsRecordRow Row(
string id,
string sourcePath,
bool isEditable,
bool isDraftOnly = false) =>
new(
Id: id,
OccurrenceId: id,
OptionName: "cname",
Family: DnsRecordFamily.Cname,
IndexInOption: 0,
RawValue: "alias,target",
Source: null,
SourcePath: sourcePath,
SourceLabel: Path.GetFileName(sourcePath),
IsDraftOnly: isDraftOnly,
IsEditable: isEditable,
Payload: new CnamePayload(["alias"], "target", null),
Issues: [],
Summary: "alias -> target");
[Fact]
public void Build_ClassifiesOnlyManagedConfigPathAsManaged()
{
var managed = "/etc/dnsmasq-webui/managed.conf";
var rows = new[]
{
Row("managed", managed, isEditable: true),
Row("draft-other", "/tmp/custom-writable.conf", isEditable: true, isDraftOnly: true)
};
var builder = new DnsRecordsFileFilterBuilder(rows, managed);
var model = builder.Build();
Assert.Equal(2, model.Sections.Count);
Assert.Equal("managed", model.Sections[0].Kind);
Assert.Equal(managed, model.Sections[0].Options[0].Value);
Assert.Equal("other", model.Sections[1].Kind);
Assert.Equal("/tmp/custom-writable.conf", model.Sections[1].Options[0].Value);
}
}
@@ -0,0 +1,149 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Editing;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
public class EffectiveConfigMultiDisplayContextTests
{
private static EffectiveMultiValueConfigFieldDescriptor DomainDescriptor(
IReadOnlyList<ValueWithSource> baseline) =>
new(
EffectiveConfigSections.SectionHosts,
DnsmasqConfKeys.Domain,
Status: null,
ResolveItems: _ => baseline,
Behavior: new DefaultMultiValueEditBehavior(),
Validator: null);
private static ConfigValueSource ReadonlySource() =>
new("/etc/dnsmasq.conf", "dnsmasq.conf", IsManaged: false, LineNumber: 1);
[Fact]
public void From_BaselineOnly_UsesBaselineItems()
{
var baseline = new List<ValueWithSource>
{
new("a.example", null),
new("b.example", null)
};
var d = DomainDescriptor(baseline);
var ctx = EffectiveConfigMultiDisplayContext.From(
d, baseline, pendingList: null, hasPendingChange: false, draftList: null,
isEditMode: false, isActive: false, fieldKey: "k");
Assert.Same(baseline, ctx.BaselineItems);
Assert.Equal(["a.example", "b.example"], ctx.EffectiveValues);
Assert.False(ctx.HasPendingChange);
Assert.False(ctx.HasDraftChange);
Assert.False(ctx.HasPendingOrDraft);
}
[Fact]
public void From_PendingOverridesBaseline_PreservesSourcesWhereValuesMatch()
{
var ro = ReadonlySource();
var baseline = new List<ValueWithSource>
{
new("a.example", ro),
new("b.example", null)
};
var d = DomainDescriptor(baseline);
var pending = new List<string> { "a.example", "c.example" };
var ctx = EffectiveConfigMultiDisplayContext.From(
d, baseline, pending, hasPendingChange: true, draftList: null,
isEditMode: false, isActive: false, fieldKey: "k");
Assert.Equal(["a.example", "c.example"], ctx.EffectiveValues);
Assert.True(ctx.HasPendingChange);
Assert.Equal(ro, ctx.EffectiveItems[0].Source);
Assert.Null(ctx.EffectiveItems[1].Source);
}
[Fact]
public void From_DraftOverridesPending()
{
var baseline = new List<ValueWithSource> { new("a.example", null) };
var d = DomainDescriptor(baseline);
var pending = new List<string> { "b.example" };
var draft = new List<string> { "c.example" };
var ctx = EffectiveContext(d, baseline, pending, draft);
Assert.Equal(["c.example"], ctx.EffectiveValues);
Assert.True(ctx.HasDraftChange);
Assert.True(ctx.HasPendingOrDraft);
}
[Fact]
public void From_AllReadonlyNotEditMode_SetsRowSourceFromFirstItem()
{
var ro = ReadonlySource();
var baseline = new List<ValueWithSource>
{
new("a.example", ro)
};
var d = DomainDescriptor(baseline);
var ctx = EffectiveConfigMultiDisplayContext.From(
d, baseline, null, false, null,
isEditMode: false, isActive: false, fieldKey: "k");
Assert.Equal(ro, ctx.RowSource);
}
[Fact]
public void From_EditModeOrMixedWritable_ClearsRowSource()
{
var ro = ReadonlySource();
var baseline = new List<ValueWithSource>
{
new("a.example", ro)
};
var d = DomainDescriptor(baseline);
var ctxEdit = EffectiveConfigMultiDisplayContext.From(
d, baseline, null, false, null,
isEditMode: true, isActive: false, fieldKey: "k");
Assert.Null(ctxEdit.RowSource);
var mixed = new List<ValueWithSource>
{
new("a.example", ro),
new("b.example", null)
};
var d2 = DomainDescriptor(mixed);
var ctxMixed = EffectiveConfigMultiDisplayContext.From(
d2, mixed, null, false, null,
isEditMode: false, isActive: false, fieldKey: "k");
Assert.Null(ctxMixed.RowSource);
}
[Fact]
public void From_IsActiveEditor_RespectsCapabilityDisabled()
{
var baseline = new List<ValueWithSource> { new("a.example", null) };
var d = DomainDescriptor(baseline) with { IsCapabilityDisabled = true };
var ctx = EffectiveConfigMultiDisplayContext.From(
d, baseline, null, false, null,
isEditMode: true, isActive: true, fieldKey: "k");
Assert.False(ctx.IsActiveEditor);
Assert.False(ctx.ShowEditableBadge);
}
private static EffectiveConfigMultiDisplayContext EffectiveContext(
EffectiveMultiValueConfigFieldDescriptor d,
IReadOnlyList<ValueWithSource> baseline,
IReadOnlyList<string>? pending,
IReadOnlyList<string>? draft) =>
EffectiveConfigMultiDisplayContext.From(
d, baseline, pending, pending != null, draft,
isEditMode: false, isActive: false, fieldKey: "k");
}
@@ -0,0 +1,90 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
public class ManagedHostsPendingChangeDiffTests
{
[Fact]
public void Build_same_entries_returns_empty()
{
var e = new HostEntry { Id = "a", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "h" } };
var oldL = new List<HostEntry> { e };
var newL = new List<HostEntry>
{
new()
{
Id = e.Id,
LineNumber = e.LineNumber,
Address = e.Address,
Names = new List<string>(e.Names)
}
};
Assert.Empty(ManagedHostsPendingChangeDiff.Build(oldL, newL));
}
[Fact]
public void Build_added_entry()
{
var oldL = new List<HostEntry>();
var newL = new List<HostEntry>
{
new() { Id = "new:x", LineNumber = 1, Address = "192.168.0.1", Names = new List<string> { "router" } }
};
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var a = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.Added, a.Kind);
Assert.Contains("192.168.0.1", a.AfterLine, StringComparison.Ordinal);
Assert.Contains("router", a.AfterLine, StringComparison.Ordinal);
}
[Fact]
public void Build_removed_entry()
{
var oldL = new List<HostEntry>
{
new() { Id = "1", LineNumber = 1, Address = "10.0.0.2", Names = new List<string> { "x" } }
};
var newL = new List<HostEntry>();
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var r = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.Removed, r.Kind);
Assert.Contains("10.0.0.2", r.BeforeLine, StringComparison.Ordinal);
}
[Fact]
public void Build_modified_address()
{
var oldL = new List<HostEntry>
{
new() { Id = "k", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "h" } }
};
var newL = new List<HostEntry>
{
new() { Id = "k", LineNumber = 1, Address = "10.0.0.9", Names = new List<string> { "h" } }
};
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var m = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.Modified, m.Kind);
Assert.Contains("10.0.0.1", m.BeforeLine, StringComparison.Ordinal);
Assert.Contains("10.0.0.9", m.AfterLine, StringComparison.Ordinal);
}
[Fact]
public void Build_order_only()
{
var oldL = new List<HostEntry>
{
new() { Id = "a", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "a" } },
new() { Id = "b", LineNumber = 2, Address = "10.0.0.2", Names = new List<string> { "b" } }
};
var newL = new List<HostEntry>
{
new() { Id = "b", LineNumber = 1, Address = "10.0.0.2", Names = new List<string> { "b" } },
new() { Id = "a", LineNumber = 2, Address = "10.0.0.1", Names = new List<string> { "a" } }
};
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var o = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.OrderOnly, o.Kind);
}
}
@@ -0,0 +1,53 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
public class PendingManagedHostsChangeTests
{
[Fact]
public void HasChanges_WhenEntriesDiffer_ReturnsTrue()
{
var oldEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string> { "host1" } } };
var newEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.2", Names = new List<string> { "host1" } } };
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
Assert.True(pending.HasChanges);
}
[Fact]
public void HasChanges_WhenEntriesSame_ReturnsFalse()
{
var entries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string> { "host1" } } };
var clone = entries.Select(e => new HostEntry { Id = e.Id, Address = e.Address, Names = new List<string>(e.Names) }).ToList();
var pending = new PendingManagedHostsChange(entries, clone, "/etc/hosts");
Assert.False(pending.HasChanges);
}
[Fact]
public void HasChanges_WhenCountDiffers_ReturnsTrue()
{
var oldEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string>() } };
var newEntries = new List<HostEntry>
{
new() { Id = "1", Address = "192.168.1.1", Names = new List<string>() },
new() { Id = "2", Address = "192.168.1.2", Names = new List<string>() }
};
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
Assert.True(pending.HasChanges);
}
[Fact]
public void HasChanges_WhenInlineCommentDiffers_ReturnsTrue()
{
var oldEntries = new List<HostEntry>
{
new() { Id = "1", Address = "10.0.0.1", Names = new List<string> { "a" }, InlineComment = "old" }
};
var newEntries = new List<HostEntry>
{
new() { Id = "1", Address = "10.0.0.1", Names = new List<string> { "a" }, InlineComment = "new" }
};
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
Assert.True(pending.HasChanges);
}
}
@@ -0,0 +1,93 @@
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public class HostsEffectiveNamesDomainRulesTests
{
[Fact]
public void Expand_UsesMatchingScopedDomainForIpv4()
{
var names = new[] { "router" };
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.20.1.9");
Assert.Equal(new[] { "router", "router.corp.lan" }, result);
}
[Fact]
public void Expand_FallsBackToDefaultDomainWhenNoScopedMatch()
{
var names = new[] { "cache" };
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.99.1.9");
Assert.Equal(new[] { "cache", "cache.home.lan" }, result);
}
[Fact]
public void Expand_UsesLastParsedScopedRuleWhenRangesOverlap()
{
var names = new[] { "app" };
var domains = new[]
{
"home.lan",
"corp-wide.lan,10.0.0.0/8",
"corp-subnet.lan,10.20.0.0/16"
};
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.20.1.5");
Assert.Equal(new[] { "app", "app.corp-subnet.lan" }, result);
}
[Fact]
public void Expand_UsesDefaultDomainForIpv6Address()
{
var names = new[] { "node" };
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "2001:db8::1");
Assert.Equal(new[] { "node", "node.home.lan" }, result);
}
[Fact]
public void ExplainSuffixSource_ScopedRuleMentionsRawLine()
{
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var msg = HostsEffectiveNames.ExplainSuffixSource(
expandHosts: true,
domains,
"10.20.1.9",
new[] { "router" });
Assert.Equal("Suffix from scoped rule: corp.lan,10.20.0.0/16", msg);
}
[Fact]
public void ExplainSuffixSource_DefaultDomainWhenNoScopedMatch()
{
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var msg = HostsEffectiveNames.ExplainSuffixSource(
expandHosts: true,
domains,
"10.99.1.9",
new[] { "cache" });
Assert.Equal("Suffix from default rule: home.lan", msg);
}
[Fact]
public void ExplainSuffixSource_ExpandHostsOff()
{
var msg = HostsEffectiveNames.ExplainSuffixSource(
expandHosts: false,
new[] { "home.lan" },
"10.0.0.1",
new[] { "a" });
Assert.Contains("expand-hosts is off", msg);
}
}
@@ -0,0 +1,34 @@
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public class HostsEntryEditValidatorTests
{
[Fact]
public void Empty_is_invalid()
{
var e = HostsEntryEditValidator.GetErrors("", Array.Empty<string>());
Assert.NotEmpty(e);
}
[Fact]
public void Valid_ipv4_and_name_ok()
{
Assert.True(HostsEntryEditValidator.IsValid("192.168.1.1", new[] { "router" }));
}
[Fact]
public void Missing_names_invalid()
{
var e = HostsEntryEditValidator.GetErrors("10.0.0.1", Array.Empty<string>());
Assert.Contains(e, x => x.Contains("hostname", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Field_errors_split_address_and_names()
{
Assert.NotEmpty(HostsEntryEditValidator.GetAddressFieldErrors(""));
Assert.Empty(HostsEntryEditValidator.GetHostnameFieldErrors(new[] { "h" }));
Assert.NotEmpty(HostsEntryEditValidator.GetHostnameFieldErrors(Array.Empty<string>()));
}
}
@@ -0,0 +1,111 @@
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Models.Ui;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public sealed class HostsFileFilterBuilderTests
{
private static HostsPageRow Row(HostsRowSourceKind kind, string path, bool isComment = false) =>
new(
Id: "id",
SourceKind: kind,
SourcePath: path,
IsEditable: false,
IsActive: true,
InactiveReason: null,
Address: "127.0.0.1",
Names: [],
EffectiveNames: [],
IsComment: isComment,
LineNumber: 1);
[Fact]
public void Build_InjectsManagedPath_WhenNotInRows()
{
var managed = "/data/managed.hosts";
var builder = new HostsFileFilterBuilder([], managed);
var model = builder.Build();
Assert.NotNull(model.AllOption);
var managedSection = model.Sections.Single();
Assert.Equal("Managed hosts", managedSection.Label);
Assert.Equal("managed", managedSection.Kind);
Assert.Single(managedSection.Options);
Assert.Equal(managed, managedSection.Options[0].Value);
Assert.Equal(0, managedSection.Options[0].Count);
}
[Fact]
public void Build_OrdersGroupsManagedSystemAddn_AndCountsNonComments()
{
var rows = new[]
{
Row(HostsRowSourceKind.SystemHosts, "/etc/hosts"),
Row(HostsRowSourceKind.Managed, "/m/a.hosts"),
Row(HostsRowSourceKind.Managed, "/m/a.hosts", isComment: true),
Row(HostsRowSourceKind.AddnHosts, "/extra/b.hosts"),
};
var builder = new HostsFileFilterBuilder(rows, managedHostsPath: null);
var model = builder.Build();
Assert.Equal(3, model.Sections.Count);
Assert.Equal("managed", model.Sections[0].Kind);
Assert.Equal("system", model.Sections[1].Kind);
Assert.Equal("addn", model.Sections[2].Kind);
Assert.Single(model.Sections[0].Options);
Assert.Equal(1, model.Sections[0].Options[0].Count);
}
[Fact]
public void GetSummaryText_AllFiles_ShowsRecordCount()
{
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/x.hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
Assert.Equal("All files · 1 records", builder.GetSummaryText(""));
Assert.Equal("All files · 1 records", builder.GetSummaryText(" "));
}
[Fact]
public void GetSummaryText_SelectedFile_UsesShortLabelAndPrefix()
{
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/my.hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
Assert.Equal("Managed · my.hosts (1)", builder.GetSummaryText("/m/my.hosts"));
}
[Fact]
public void GetSummaryText_UnknownPath_FallsBackToShortLabel()
{
var builder = new HostsFileFilterBuilder([], null);
Assert.Equal("orphan.txt", builder.GetSummaryText("/tmp/orphan.txt"));
}
[Fact]
public void GetTriggerSummary_SingleSource_SplitsPrefixPrimaryAndCount()
{
var rows = new[] { Row(HostsRowSourceKind.AddnHosts, "/etc/extra.d/hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
var s = builder.GetTriggerSummary("/etc/extra.d/hosts");
Assert.Equal(GroupedSelectTriggerSummaryKind.SingleSource, s.Kind);
Assert.Equal("Additional", s.CategoryPrefix);
Assert.Equal("hosts", s.PrimaryLabel);
Assert.Equal("1", s.SecondaryMeta);
Assert.Equal("Additional · hosts (1)", s.AccessibleFullText);
}
[Fact]
public void GetTriggerSummary_AllSources_WithRecords_HasSecondaryMeta()
{
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/x.hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
var s = builder.GetTriggerSummary("");
Assert.Equal(GroupedSelectTriggerSummaryKind.AllSources, s.Kind);
Assert.Null(s.CategoryPrefix);
Assert.Equal("All files", s.PrimaryLabel);
Assert.Equal("1 records", s.SecondaryMeta);
}
}
@@ -0,0 +1,58 @@
using DnsmasqWebUI.Infrastructure.Serialization;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
namespace DnsmasqWebUI.Tests.Serialization;
public class DnsmasqConfTextTests
{
[Fact]
public void DirectivePrefix_ReturnsOptionEquals()
{
Assert.Equal("dhcp-host=", DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.DhcpHost));
Assert.Equal("server=", DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.Server));
}
[Fact]
public void DirectiveLine_WhenValueNullOrEmpty_ReturnsOptionOnly()
{
Assert.Equal("dhcp-host", DnsmasqConfText.DirectiveLine("dhcp-host", null));
Assert.Equal("dhcp-host", DnsmasqConfText.DirectiveLine("dhcp-host", ""));
}
[Fact]
public void DirectiveLine_WhenValueProvided_ReturnsOptionEqualsValue()
{
Assert.Equal("dhcp-host=11:22:33:44:55:66,192.168.1.1", DnsmasqConfText.DirectiveLine("dhcp-host", "11:22:33:44:55:66,192.168.1.1"));
}
[Fact]
public void StripDirectivePrefix_WhenLineStartsWithPrefix_ReturnsRest()
{
var line = "dhcp-host=mac,ip";
Assert.Equal("mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, line));
}
[Fact]
public void StripDirectivePrefix_WhenLineDoesNotStartWithPrefix_ReturnsFullLine()
{
var line = "other=value";
Assert.Equal("other=value", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, line));
}
[Fact]
public void StripDirectivePrefix_WhenLineIsCommented_PreservesLeadingHashInResult()
{
Assert.Equal("#mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, "#dhcp-host=mac,ip"));
Assert.Equal("##mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, "##dhcp-host=mac,ip"));
}
[Fact]
public void HasDirectivePrefix_MatchesOptionAndCommentedForms()
{
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "dhcp-host=value"));
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "#dhcp-host=value"));
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "##dhcp-host=value"));
Assert.False(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "server=8.8.8.8"));
Assert.False(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "other"));
}
}
@@ -0,0 +1,66 @@
using DnsmasqWebUI.Infrastructure.Serialization;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Serialization;
public class EffectiveConfigDirectiveSerializerTests
{
private readonly EffectiveConfigDirectiveSerializer _sut = new();
[Fact]
public void SerializeSingle_Flag_True_WritesBareKey()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.Conntrack, true);
Assert.Equal(DnsmasqConfKeys.Conntrack, result);
}
[Fact]
public void SerializeSingle_Flag_False_WritesEmpty()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.Conntrack, false);
Assert.Equal("", result);
}
[Fact]
public void SerializeSingle_SingleValue_WritesKeyEqualsValue()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.Port, 5353);
Assert.Equal("port=5353", result);
}
[Fact]
public void SerializeSingle_KeyOnlyOrValue_Empty_WritesBareKey()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.UseStaleCache, "");
Assert.Equal(DnsmasqConfKeys.UseStaleCache, result);
}
[Fact]
public void SerializeMulti_MultiValue_WritesEachDirective()
{
var result = _sut.SerializeMulti(DnsmasqConfKeys.Server, ["1.1.1.1", "8.8.8.8"]);
Assert.Equal(["server=1.1.1.1", "server=8.8.8.8"], result);
}
[Fact]
public void SerializeMulti_MultiKeyOnlyOrValue_WritesMixedForms()
{
var result = _sut.SerializeMulti(DnsmasqConfKeys.Leasequery, ["", "net:tag"]);
Assert.Equal(["leasequery", "leasequery=net:tag"], result);
}
[Fact]
public void SerializeSingle_InversePair_Enabled_WritesPositiveKey()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Enabled);
Assert.Equal(DnsmasqConfKeys.Do0x20Encode, result);
}
[Fact]
public void SerializeSingle_InversePair_Disabled_WritesNegativeKey()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Disabled);
Assert.Equal(DnsmasqConfKeys.No0x20Encode, result);
}
}
@@ -0,0 +1,106 @@
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Tests.Serialization.OptionHandlers;
public class DhcpHostOptionValueHandlerTests
{
private readonly DhcpHostOptionValueHandler _sut = new();
[Fact]
public void OptionName_EqualsDnsmasqConfKeysDhcpHost()
{
Assert.Equal(DnsmasqConfKeys.DhcpHost, _sut.OptionName);
}
[Fact]
public void SerializeValue_EmitsValueOnlyForm()
{
var entry = new DhcpHostEntry
{
MacAddresses = new List<string> { "11:22:33:44:55:66" },
Address = "192.168.1.10"
};
var value = _sut.SerializeValue(entry);
Assert.Equal("11:22:33:44:55:66, 192.168.1.10", value);
}
[Fact]
public void SerializeValue_IgnoreEntry_EmitsIgnoreToken()
{
var entry = new DhcpHostEntry
{
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
Ignore = true
};
var value = _sut.SerializeValue(entry);
Assert.Contains("ignore", value);
Assert.Contains("aa:bb:cc:dd:ee:ff", value);
}
[Fact]
public void SerializeValue_EmptyEntry_ReturnsEmptyString()
{
var entry = new DhcpHostEntry();
var value = _sut.SerializeValue(entry);
Assert.Equal("", value);
}
[Fact]
public void TryParseValue_RoundTripsStructuredFields()
{
var input = new DhcpHostEntry
{
MacAddresses = new List<string> { "11:22:33:44:55:66" },
Address = "192.168.1.10",
Extra = new List<string> { "id:01:02:03", "set:test" },
Comment = "note",
IsComment = true
};
var value = _sut.SerializeValue(input);
var ok = _sut.TryParseValue(value, 1, out var output);
Assert.True(ok);
Assert.NotNull(output);
Assert.True(output!.IsComment);
Assert.Contains("id:01:02:03", output.Extra!);
Assert.Equal("note", output.Comment);
}
[Fact]
public void TryParseValue_IsComment_RoundTrips()
{
var entry = new DhcpHostEntry
{
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
Address = "192.168.1.1",
IsComment = true
};
var value = _sut.SerializeValue(entry);
Assert.StartsWith("#", value);
var ok = _sut.TryParseValue(value, 1, out var roundTripped);
Assert.True(ok);
Assert.NotNull(roundTripped);
Assert.True(roundTripped!.IsComment);
Assert.Single(roundTripped.MacAddresses!);
Assert.Equal("aa:bb:cc:dd:ee:ff", roundTripped.MacAddresses[0]);
Assert.Equal("192.168.1.1", roundTripped.Address);
}
[Fact]
public void TryParseValue_Comment_EmitsTrailingComment_AndRoundTrips()
{
var entry = new DhcpHostEntry
{
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
Address = "192.168.1.1",
Comment = " my note "
};
var value = _sut.SerializeValue(entry);
Assert.Contains(" # ", value);
var ok = _sut.TryParseValue(value, 1, out var roundTripped);
Assert.True(ok);
Assert.NotNull(roundTripped);
Assert.Equal("my note", roundTripped!.Comment?.Trim());
}
}
@@ -0,0 +1,126 @@
using System.Reflection;
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Tests.Serialization.OptionHandlers;
/// <summary>Ensures structured handlers use DnsmasqConfKeys and registry has no duplicate option names.</summary>
public class StructuredOptionValueHandlerRegistryTests
{
[Fact]
public void DhcpHostOptionValueHandler_OptionName_EqualsDnsmasqConfKeysDhcpHost()
{
var handler = new DhcpHostOptionValueHandler();
Assert.Equal(DnsmasqConfKeys.DhcpHost, handler.OptionName);
}
[Fact]
public void DhcpHostOptionValueHandler_OptionName_IsFromDnsmasqConfKeys()
{
var handler = new DhcpHostOptionValueHandler();
var validKeys = GetDnsmasqConfKeysValues();
Assert.Contains(handler.OptionName, validKeys);
}
[Fact]
public void Registry_WithDhcpHostHandler_ReturnsHandlerForDhcpHost()
{
var handler = new DhcpHostOptionValueHandler();
var registry = new StructuredOptionValueHandlerRegistry(new[] { handler });
var resolved = registry.Get<DhcpHostEntry>(DnsmasqConfKeys.DhcpHost);
Assert.NotNull(resolved);
Assert.Equal(DnsmasqConfKeys.DhcpHost, resolved.OptionName);
}
[Fact]
public void Registry_IsStructured_WhenHandlerExists_ReturnsTrue()
{
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
Assert.True(registry.IsStructured(DnsmasqConfKeys.DhcpHost));
Assert.False(registry.IsStructured(DnsmasqConfKeys.Server));
}
[Fact]
public void Registry_GetRequired_WhenHandlerExists_ReturnsTypedHandler()
{
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
var handler = registry.GetRequired<DhcpHostEntry>(DnsmasqConfKeys.DhcpHost);
Assert.Equal(DnsmasqConfKeys.DhcpHost, handler.OptionName);
}
[Fact]
public void Registry_GetRequired_WhenOptionIsNotStructured_Throws()
{
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
var ex = Assert.Throws<InvalidOperationException>(() => registry.GetRequired<DhcpHostEntry>(DnsmasqConfKeys.Server));
Assert.Contains("not declared as a structured option", ex.Message);
}
[Fact]
public void Registry_DuplicateOptionNames_Throws()
{
var ex = Assert.Throws<InvalidOperationException>(() =>
new StructuredOptionValueHandlerRegistry([
new DhcpHostOptionValueHandler(),
new DuplicateDhcpHostHandler()
]));
Assert.Contains("Duplicate structured option handlers", ex.Message);
}
[Fact]
public void Semantics_DhcpHost_HasStructuredValueTypeMatchingHandler()
{
var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(DnsmasqConfKeys.DhcpHost);
Assert.NotNull(structuredType);
Assert.Equal(typeof(DhcpHostEntry), structuredType);
var handler = new DhcpHostOptionValueHandler();
Assert.Equal(structuredType, handler.ValueType);
}
[Fact]
public void Registry_HandlerWithoutStructuredMetadata_Throws()
{
var ex = Assert.Throws<InvalidOperationException>(() =>
new StructuredOptionValueHandlerRegistry([new InvalidServerHandler()]));
Assert.Contains("no StructuredValueType", ex.Message);
}
private static HashSet<string> GetDnsmasqConfKeysValues()
{
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var field in typeof(DnsmasqConfKeys).GetFields(BindingFlags.Public | BindingFlags.Static))
{
if (field.FieldType == typeof(string) && field.GetValue(null) is string value)
set.Add(value);
}
return set;
}
private sealed class DuplicateDhcpHostHandler : IStructuredOptionValueHandler<DhcpHostEntry>
{
public string OptionName => DnsmasqConfKeys.DhcpHost;
public Type ValueType => typeof(DhcpHostEntry);
public string SerializeLine(DhcpHostEntry value) => "";
public string SerializeValue(DhcpHostEntry value) => "";
public bool TryParseValue(string text, int lineNumber, out DhcpHostEntry? value)
{
value = null;
return false;
}
}
private sealed class InvalidServerHandler : IStructuredOptionValueHandler<string>
{
public string OptionName => DnsmasqConfKeys.Server;
public Type ValueType => typeof(string);
public string SerializeLine(string value) => "";
public string SerializeValue(string value) => value;
public bool TryParseValue(string text, int lineNumber, out string? value)
{
value = text;
return true;
}
}
}
@@ -0,0 +1,72 @@
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.Dnsmasq;
/// <summary>
/// Tests for parsing "Compile time options: ..." from dnsmasq --version output.
/// </summary>
public class DnsmasqCompileOptionsParserTests
{
[Fact]
public void Parse_WhenNoCompileLine_ReturnsAllCapabilitiesFalse()
{
var result = DnsmasqCompileOptionsParser.Parse("Dnsmasq version 2.89\nCopyright ...", null);
Assert.False(result.Dhcp);
Assert.False(result.Tftp);
Assert.False(result.Dnssec);
Assert.False(result.Dbus);
Assert.NotNull(result.RawTokens);
Assert.Empty(result.RawTokens);
}
[Fact]
public void Parse_WhenCompileLineHasDnssec_SetsDnssecTrue()
{
var stdout = "Dnsmasq version 2.89\nCompile time options: IPv6 GNU-getopt no-DBus no-Idn DHCP DHCPv6 TFTP DNSSEC auth";
var result = DnsmasqCompileOptionsParser.Parse(stdout, null);
Assert.True(result.Dnssec);
Assert.True(result.Dhcp);
Assert.True(result.Tftp);
Assert.False(result.Dbus);
Assert.Contains("DNSSEC", result.RawTokens, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void Parse_WhenCompileLineHasDhcpOnly_SetsDhcpTrueOthersFalse()
{
var result = DnsmasqCompileOptionsParser.Parse(null, "Compile time options: DHCP no-DBus no-DNSSEC");
Assert.True(result.Dhcp);
Assert.False(result.Tftp);
Assert.False(result.Dnssec);
Assert.False(result.Dbus);
}
[Fact]
public void Parse_WhenCompileLineHasDhcpv6_SetsDhcpTrue()
{
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: DHCPv6", null);
Assert.True(result.Dhcp);
}
[Fact]
public void Parse_WhenCompileLineHasTftp_SetsTftpTrue()
{
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: TFTP", null);
Assert.True(result.Tftp);
}
[Fact]
public void Parse_WhenCompileLineHasDbus_SetsDbusTrue()
{
var result = DnsmasqCompileOptionsParser.Parse("Compile time options: DBus", null);
Assert.True(result.Dbus);
}
[Fact]
public void Parse_CombinesStdoutAndStderr()
{
var result = DnsmasqCompileOptionsParser.Parse("Version line\n", "Compile time options: DNSSEC");
Assert.True(result.Dnssec);
}
}
@@ -0,0 +1,275 @@
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests for DnsmasqConfDhcpHostLineParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore]
/// Comma-separated; ## = deleted, # = comment; trailing # comment allowed.
/// </summary>
public class DnsmasqConfDhcpHostLineParserTests
{
[Fact]
public void ParseLine_NotDhcpHost_ReturnsNull()
{
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("domain=local", 1));
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-range=192.168.1.1,192.168.1.100", 1));
Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("# comment", 1));
}
[Fact]
public void ParseLine_BasicMacIpNameLease_Parses()
{
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
string lease = "infinite";
var line = $"dhcp-host={mac},{address},{name},{lease}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.False(e!.IsComment);
Assert.False(e.IsDeleted);
Assert.Single(e.MacAddresses);
Assert.Equal(mac, e.MacAddresses[0]);
Assert.Equal(address, e.Address);
Assert.Equal(name, e.Name);
Assert.Equal(lease, e.Lease);
Assert.Empty(e.Extra);
}
[Fact]
public void ParseLine_CommentedLine_ParsesWithIsComment()
{
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
string lease = "infinite";
var line = $"#dhcp-host={mac},{address},{name},{lease}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.True(e!.IsComment);
Assert.False(e.IsDeleted);
Assert.Single(e.MacAddresses);
Assert.Equal(address, e.Address);
}
[Fact]
public void ParseLine_DeletedLine_ParsesWithIsDeleted()
{
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "oldpc";
var line = $"##dhcp-host={mac},{address},{name}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.True(e!.IsComment);
Assert.True(e.IsDeleted);
Assert.Single(e.MacAddresses);
Assert.Equal(address, e.Address);
}
[Fact]
public void ParseLine_TrailingComment_ParsesComment()
{
string mac = "11:22:33:44:55:66";
string address = "192.168.1.11";
string name = "laptop";
string lease = "infinite";
string comment = "main laptop";
var line = $"dhcp-host={mac},{address},{name},{lease} # {comment}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal(comment, e!.Comment);
Assert.Single(e.MacAddresses);
}
[Fact]
public void ParseLine_MultipleMacs_SameIp_Parses()
{
string mac1 = "11:22:33:44:55:66";
string mac2 = "12:34:56:78:90:12";
string address = "192.168.0.2";
var line = $"dhcp-host={mac1},{mac2},{address}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal(2, e!.MacAddresses.Count);
Assert.Equal(mac1, e.MacAddresses[0]);
Assert.Equal(mac2, e.MacAddresses[1]);
Assert.Equal(address, e.Address);
}
[Fact]
public void ParseLine_WithSetTag_PutsInExtra()
{
string mac = "AA:BB:CC:DD:CC:BB";
string name = "redhost1";
string address = "192.168.1.41";
string lease = "infinite";
string extraTag = "set:red";
var line = $"dhcp-host={mac},{name},{address},{lease},{extraTag}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal(address, e!.Address);
Assert.Equal(name, e.Name);
Assert.Contains(e.Extra, x => x == extraTag);
}
[Fact]
public void ParseLine_HostnameOnly_Parses()
{
string name = "lap";
string address = "192.168.0.199";
var line = $"dhcp-host={name},{address}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal(name, e!.Name);
Assert.Equal(address, e.Address);
Assert.Empty(e.MacAddresses);
}
[Fact]
public void ParseLine_Ignore_Parses()
{
string mac = "00:20:e0:3b:13:af";
var line = $"dhcp-host={mac},ignore";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.True(e!.Ignore);
Assert.Single(e.MacAddresses);
Assert.Equal(mac, e.MacAddresses[0]);
}
[Fact]
public void ParseLine_NumericLease_Parses()
{
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "pc";
string lease = "3600";
var line = $"dhcp-host={mac},{address},{name},{lease}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal(lease, e!.Lease);
}
[Fact]
public void ParseLine_WithIdClient_PutsInExtra()
{
string idPart = "id:01:02:03:04";
string address = "192.168.1.50";
string name = "myhost";
var line = $"dhcp-host={idPart},{address},{name}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase));
Assert.Equal(address, e.Address);
}
[Fact]
public void ToLine_Roundtrip_Basic()
{
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
string lease = "infinite";
var line = $"dhcp-host={mac},{address},{name},{lease}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("dhcp-host=", back);
Assert.Contains(mac, back);
Assert.Contains(address, back);
Assert.Contains(name, back);
Assert.Contains(lease, back);
}
[Fact]
public void ToLine_Roundtrip_WithComment()
{
string mac = "11:22:33:44:55:66";
string address = "192.168.1.11";
string name = "laptop";
string lease = "infinite";
string comment = "main laptop";
var line = $"dhcp-host={mac},{address},{name},{lease} # {comment}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("dhcp-host=", back);
Assert.Contains(comment, back);
}
[Fact]
public void ToLine_CommentedEntry_Prefix()
{
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
var line = $"#dhcp-host={mac},{address},{name}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("#dhcp-host=", back);
}
[Fact]
public void ToLine_DeletedEntry_DoubleHash()
{
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "old";
var line = $"##dhcp-host={mac},{address},{name}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("##dhcp-host=", back);
}
[Fact]
public void Parse_testdata_dhcp_conf_ParsesDhcpHostLines()
{
var lines = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf");
var dhcpEntries = new List<DhcpHostEntry>();
for (var i = 0; i < lines.Length; i++)
{
var e = DnsmasqConfDhcpHostLineParser.ParseLine(lines[i], i + 1);
if (e != null)
dhcpEntries.Add(e);
}
Assert.Single(dhcpEntries);
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpEntries[0].MacAddresses[0]);
Assert.Equal("172.28.0.100", dhcpEntries[0].Address);
Assert.Equal("testpc", dhcpEntries[0].Name);
Assert.Equal("infinite", dhcpEntries[0].Lease);
}
}
@@ -0,0 +1,93 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests for DnsmasqConfDirectiveParser. Key/value parsing (TryParseKeyValue) and comment stripping (StripComment)
/// used by DnsmasqConfIncludeParser to build effective config.
/// </summary>
public class DnsmasqConfDirectiveParserTests
{
[Fact]
public void TryParseKeyValue_Empty_ReturnsNull()
{
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue(""));
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue(" "));
}
[Fact]
public void TryParseKeyValue_Comment_ReturnsNull()
{
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("# comment"));
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("# addn-hosts=/etc/hosts"));
}
[Fact]
public void TryParseKeyValue_KeyValue_ReturnsKeyAndValue()
{
var value = "53";
var line = $"port={value}";
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
Assert.NotNull(kv);
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
Assert.Equal(value, kv.Value.value);
}
[Fact]
public void TryParseKeyValue_KeyOnly_ReturnsEmptyValue()
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("domain-needed");
Assert.NotNull(kv);
Assert.Equal(DnsmasqConfKeys.DomainNeeded, kv!.Value.key);
Assert.Equal("", kv.Value.value);
}
[Fact]
public void TryParseKeyValue_CommentedLine_ReturnsNull()
{
Assert.Null(DnsmasqConfDirectiveParser.TryParseKeyValue("#addn-hosts=/etc/hosts"));
}
[Fact]
public void StripComment_CommentAfterSpace_StripsToEnd()
{
var before = "port=53";
var comment1 = " DNS port";
var comment2 = " comment";
Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment1));
Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment2));
}
[Fact]
public void TryParseKeyValue_LineWithComment_ValueExcludesComment()
{
var value = "53";
var comment = " DNS port";
var line = $"port={value} #{comment}";
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
Assert.NotNull(kv);
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);
}
}
@@ -1,8 +1,10 @@
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests for DnsmasqConfFileLineParser. Parses dnsmasq config file lines into Blank, Comment, AddnHosts, DhcpHost, Other.
@@ -19,73 +21,89 @@ public class DnsmasqConfFileLineParserTests
[Fact]
public void ParseFile_BlankLine_BlankKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile([""]);
var raw = "";
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
Assert.Single(lines);
var blank = Assert.IsType<BlankLine>(lines[0]);
Assert.Equal(1, blank.LineNumber);
Assert.Equal("", blank.RawLine);
Assert.Equal(raw, blank.RawLine);
}
[Fact]
public void ParseFile_BlankLineWithSpaces_BlankKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile([" \t "]);
var raw = " \t ";
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
Assert.Single(lines);
var blank = Assert.IsType<BlankLine>(lines[0]);
Assert.Equal(" \t ", blank.RawLine);
Assert.Equal(raw, blank.RawLine);
}
[Fact]
public void ParseFile_CommentLine_CommentKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile(["# Not managed by this app."]);
var commentLine = "# Not managed by this app.";
var lines = DnsmasqConfFileLineParser.ParseFile([commentLine]);
Assert.Single(lines);
var comment = Assert.IsType<CommentLine>(lines[0]);
Assert.Equal(1, comment.LineNumber);
Assert.Equal("# Not managed by this app.", comment.RawLine);
Assert.Equal(commentLine, comment.RawLine);
}
[Fact]
public void ParseFile_OtherDirective_OtherKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile(["domain=local"]);
var raw = "domain=local";
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
Assert.Single(lines);
var other = Assert.IsType<OtherLine>(lines[0]);
Assert.Equal("domain=local", other.RawLine);
Assert.Equal(raw, other.RawLine);
}
[Fact]
public void ParseFile_AddnHostsLine_AddnHostsKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile(["addn-hosts=/var/lib/dnsmasq/hosts"]);
var path = "/var/lib/dnsmasq/hosts";
var line = $"addn-hosts={path}";
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
Assert.Single(lines);
var addn = Assert.IsType<AddnHostsLine>(lines[0]);
Assert.Equal("/var/lib/dnsmasq/hosts", addn.AddnHostsPath);
Assert.Equal(path, addn.AddnHostsPath);
}
[Fact]
public void ToLine_AddnHosts_EmitsAddnHostsLine()
{
var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = "/etc/hosts" };
Assert.Equal("addn-hosts=/etc/hosts", DnsmasqConfFileLineParser.ToLine(line));
var path = "/etc/hosts";
var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = path };
Assert.Equal($"addn-hosts={path}", DnsmasqConfFileLineParser.ToLine(line));
}
[Fact]
public void ParseFile_DhcpHostLine_DhcpHostKind()
{
var lines = DnsmasqConfFileLineParser.ParseFile(["dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"]);
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
string lease = "infinite";
var line = $"dhcp-host={mac},{address},{name},{lease}";
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
Assert.Single(lines);
var dhcpLine = Assert.IsType<DhcpHostLine>(lines[0]);
Assert.Single(dhcpLine.DhcpHost.MacAddresses);
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]);
Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address);
Assert.Equal(mac, dhcpLine.DhcpHost.MacAddresses[0]);
Assert.Equal(address, dhcpLine.DhcpHost.Address);
}
[Fact]
public void ParseFile_CommentedDhcpHost_TreatedAsComment()
{
// Parser treats any line starting with # as Comment (does not parse dhcp-host content)
var lines = DnsmasqConfFileLineParser.ParseFile(["#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc"]);
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "oldpc";
var line = $"#dhcp-host={mac},{address},{name}";
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
Assert.Single(lines);
Assert.Equal(DnsmasqConfLineKind.Comment, lines[0].Kind);
}
@@ -93,13 +111,21 @@ public class DnsmasqConfFileLineParserTests
[Fact]
public void ParseFile_MixedFile_ParsesAll()
{
var domainLine = "domain=local";
string dhcpMac = "11:22:33:44:55:66";
string dhcpAddress = "192.168.1.11";
string dhcpName = "laptop";
string dhcpLease = "infinite";
var rangeLine = "dhcp-range=192.168.1.100,192.168.1.200";
var dhcpLine = $"dhcp-host={dhcpMac},{dhcpAddress},{dhcpName},{dhcpLease}";
var input = new[]
{
"# Sample config",
"",
"domain=local",
"dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite",
"dhcp-range=192.168.1.100,192.168.1.200"
domainLine,
dhcpLine,
rangeLine
};
var lines = DnsmasqConfFileLineParser.ParseFile(input);
Assert.Equal(5, lines.Count);
@@ -107,48 +133,55 @@ public class DnsmasqConfFileLineParserTests
Assert.IsType<CommentLine>(lines[0]);
Assert.IsType<BlankLine>(lines[1]);
var other2 = Assert.IsType<OtherLine>(lines[2]);
Assert.Equal("domain=local", other2.RawLine);
Assert.Equal(domainLine, other2.RawLine);
var dhcp3 = Assert.IsType<DhcpHostLine>(lines[3]);
Assert.Equal("192.168.1.11", dhcp3.DhcpHost.Address);
Assert.Equal(dhcpAddress, dhcp3.DhcpHost.Address);
var other4 = Assert.IsType<OtherLine>(lines[4]);
Assert.Equal("dhcp-range=192.168.1.100,192.168.1.200", other4.RawLine);
Assert.Equal(rangeLine, other4.RawLine);
}
[Fact]
public void ToLine_Blank_PreservesRawLine()
{
var line = new BlankLine { LineNumber = 1, RawLine = " " };
Assert.Equal(" ", DnsmasqConfFileLineParser.ToLine(line));
var raw = " ";
var line = new BlankLine { LineNumber = 1, RawLine = raw };
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
}
[Fact]
public void ToLine_Comment_PreservesRawLine()
{
var line = new CommentLine { LineNumber = 1, RawLine = "# comment" };
Assert.Equal("# comment", DnsmasqConfFileLineParser.ToLine(line));
var raw = "# comment";
var line = new CommentLine { LineNumber = 1, RawLine = raw };
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
}
[Fact]
public void ToLine_Other_PreservesRawLine()
{
var line = new OtherLine { LineNumber = 1, RawLine = "domain=local" };
Assert.Equal("domain=local", DnsmasqConfFileLineParser.ToLine(line));
var raw = "domain=local";
var line = new OtherLine { LineNumber = 1, RawLine = raw };
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
}
[Fact]
public void ToLine_DhcpHost_SerializesViaDhcpHostLineParser()
{
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
string lease = "infinite";
var dhcp = new DhcpHostEntry
{
LineNumber = 1,
MacAddresses = ["aa:bb:cc:dd:ee:ff"],
Address = "192.168.1.10",
Name = "testpc",
Lease = "infinite"
MacAddresses = [mac],
Address = address,
Name = name,
Lease = lease
};
var line = new DhcpHostLine { LineNumber = 1, DhcpHost = dhcp };
var back = DnsmasqConfFileLineParser.ToLine(line);
Assert.Equal("dhcp-host=aa:bb:cc:dd:ee:ff, testpc, 192.168.1.10, infinite", back);
Assert.Equal($"dhcp-host={mac}, {name}, {address}, {lease}", back);
}
[Fact]
@@ -0,0 +1,329 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <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.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <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.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>Boolean flags: no-hosts, bogus-priv, strict-order, domain-needed, no-poll, bind-*, rebind-*, clear-on-reload, filter*, localise-queries, log-debug, dhcp-authoritative, leasefile-ro.</summary>
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,201 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <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.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <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.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <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.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <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,120 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <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 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);
}
/// <summary>Official-like sample with commented and active lines: commented ignored, active parsed, state reflects active only.</summary>
[Fact]
public void OfficialLikeActive_CommentedLinesIgnored_ActiveLinesParsed()
{
var mainPath = TestDataHelper.GetPath("real-world/edge/dnsmasq-real-official-like-active.conf");
Assert.True(File.Exists(mainPath));
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.NotNull(portVal);
Assert.True(int.TryParse(portVal, out var port) && port == 5353);
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
Assert.Single(servers);
Assert.Equal("1.1.1.1", servers[0]);
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
}
}
@@ -0,0 +1,73 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Ensures every option in the coverage list is parsed consistently: one config line per option,
/// dispatch by parser behavior (Flag / LastWins / Multi), assert we get a value back.
/// Add new options to <see cref="OptionCoverageData.GetParserCoverageEntries"/> when adding to the app.
/// </summary>
public class DnsmasqConfIncludeParserOptionCoverageTests
{
public static IEnumerable<object[]> GetCoverageEntries()
{
foreach (var (optionKey, configLine) in OptionCoverageData.GetParserCoverageEntries())
yield return new object[] { optionKey, configLine };
}
[Theory]
[MemberData(nameof(GetCoverageEntries))]
public void EveryOption_ParseSingleLine_ReturnsExpectedResult(string optionKey, string configLine)
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-opt-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, configLine + "\n");
var paths = new[] { conf };
if (optionKey == DnsmasqConfKeys.AddnHosts)
{
var addn = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
Assert.True(addn.Count >= 1, $"addn-hosts should parse from: {configLine}");
return;
}
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionKey);
switch (behavior)
{
case EffectiveConfigParserBehavior.Flag:
{
var flag = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, optionKey);
Assert.True(flag, $"Flag option {optionKey} should be true when set.");
break;
}
case EffectiveConfigParserBehavior.LastWins:
{
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, optionKey);
Assert.NotNull(value);
Assert.True(value!.Length > 0, $"LastWins option {optionKey} should return non-empty value.");
break;
}
case EffectiveConfigParserBehavior.Multi:
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, optionKey);
Assert.True(list.Count >= 1, $"Multi option {optionKey} should return at least one value.");
break;
}
default:
Assert.Fail($"Unknown behavior for {optionKey}");
break;
}
}
finally
{
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,148 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Corpus-driven parser tests using testdata/real-world. Asserts counts, booleans, and effective state only.
/// </summary>
public class DnsmasqConfIncludeParserRealWorldCorpusTests
{
public static IEnumerable<object[]> GetCases()
{
foreach (var c in RealWorldCasesHelper.LoadCases())
yield return new object[] { c };
}
[Theory]
[MemberData(nameof(GetCases))]
public void GetIncludedPaths_RealWorldCorpus_ReturnsAtLeastMain(CorpusCase c)
{
var mainPath = RealWorldCasesHelper.Resolve(c);
Assert.True(File.Exists(mainPath), $"Corpus file missing: {c.File}");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
Assert.NotNull(paths);
Assert.NotEmpty(paths);
Assert.Contains(Path.GetFullPath(mainPath), paths);
}
[Theory]
[MemberData(nameof(GetCases))]
public void ParseEffectiveSignals_FromRealWorldCorpus(CorpusCase c)
{
var e = c.Expected;
var hasAny = e.ServerCountMin is not null || e.NoHosts is not null || e.Do0x20State is not null ||
e.Port is not null || e.ExpandHosts is not null || e.LocalCountMin is not null ||
e.AddressCountMin is not null || e.CacheSize is not null || e.AddnHostsCountMin is not null ||
e.DomainCountMin is not null || e.NoResolv is not null || e.BogusPriv is not null ||
e.CnameCountMin is not null || e.TxtRecordCountMin is not null || e.SrvCountMin is not null ||
e.PtrRecordCountMin is not null || e.MxHostCountMin is not null || e.AuthZoneCountMin is not null ||
e.SynthDomainCountMin is not null || e.AuthServerCountMin is not null;
if (!hasAny)
return;
var mainPath = RealWorldCasesHelper.Resolve(c);
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
if (e.ServerCountMin is int minServers)
{
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
Assert.True(servers.Count >= minServers, $"Expected at least {minServers} server(s) for {c.File}");
}
if (e.LocalCountMin is int minLocal)
{
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
Assert.True(locals.Count >= minLocal, $"Expected at least {minLocal} local(s) for {c.File}");
}
if (e.AddressCountMin is int minAddr)
{
var addrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
Assert.True(addrs.Count >= minAddr, $"Expected at least {minAddr} address(es) for {c.File}");
}
if (e.CacheSize is int cacheSize)
{
var (val, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
Assert.NotNull(val);
Assert.True(int.TryParse(val, out var n) && n == cacheSize);
}
if (e.AddnHostsCountMin is int minAddn)
{
var addn = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
Assert.True(addn.Count >= minAddn);
}
if (e.DomainCountMin is int minDomain)
{
var domains = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Domain);
Assert.True(domains.Count >= minDomain);
}
if (e.NoHosts is bool noHostsExpected)
{
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
Assert.Equal(noHostsExpected, noHosts);
}
if (e.NoResolv is true)
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.NoResolv));
}
if (e.BogusPriv is true)
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.BogusPriv));
}
if (e.Do0x20State == "Disabled")
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.No0x20Encode));
}
if (e.Port is int portExpected)
{
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.NotNull(portVal);
Assert.True(int.TryParse(portVal, out var port) && port == portExpected);
}
if (e.ExpandHosts is true)
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
}
if (e.CnameCountMin is int minCname)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Cname);
Assert.True(list.Count >= minCname);
}
if (e.TxtRecordCountMin is int minTxt)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.TxtRecord);
Assert.True(list.Count >= minTxt);
}
if (e.SrvCountMin is int minSrv)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Srv);
Assert.True(list.Count >= minSrv);
}
if (e.PtrRecordCountMin is int minPtr)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.PtrRecord);
Assert.True(list.Count >= minPtr);
}
if (e.MxHostCountMin is int minMx)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.MxHost);
Assert.True(list.Count >= minMx);
}
if (e.AuthZoneCountMin is int minAz)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.AuthZone);
Assert.True(list.Count >= minAz);
}
if (e.SynthDomainCountMin is int minSd)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.SynthDomain);
Assert.True(list.Count >= minSd);
}
if (e.AuthServerCountMin is int minAs)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.AuthServer);
Assert.True(list.Count >= minAs);
}
}
}
@@ -0,0 +1,232 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>DNS records and local names: hostsdir, domain, cname, mx-host, srv, ptr/txt/naptr/host-record, dynamic-host, interface-name.</summary>
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.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <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.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>Single-value options (last wins): local-ttl, pid-file, user, group, auth-ttl, edns-packet-max, query-port, port-limit, min/max-port, log-async, local-service, dhcp-lease-max, neg/max/min-cache-ttl, dhcp-ttl.</summary>
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);
}
}
}
@@ -0,0 +1,42 @@
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
public class DnsmasqDhcpRangeValueParserTests
{
[Fact]
public void TryParse_ValidRange_ReturnsStructuredTokens()
{
var ok = DnsmasqDhcpRangeValueParser.TryParse(
"tag:guest,set:known,192.168.1.50,192.168.1.150,12h",
out var parsed,
out var error);
Assert.True(ok);
Assert.Null(error);
Assert.NotNull(parsed);
Assert.Equal(["tag:guest", "set:known"], parsed!.Tags);
Assert.Equal("192.168.1.50", parsed.StartToken);
Assert.Equal("192.168.1.150", parsed.SecondToken);
Assert.Equal(["12h"], parsed.RemainingTokens);
}
[Fact]
public void TryParse_MalformedTrailingComma_ReturnsError()
{
var ok = DnsmasqDhcpRangeValueParser.TryParse("172.28.0.10,", out var parsed, out var error);
Assert.False(ok);
Assert.Null(parsed);
Assert.Equal("dhcp-range contains an empty comma-separated segment.", error);
}
[Fact]
public void GetIPv4StartEnd_ReturnsParsedIpv4Range()
{
var result = DnsmasqDhcpRangeValueParser.GetIPv4StartEnd("tag:guest,192.168.1.50,192.168.1.150,12h");
Assert.Equal(("192.168.1.50", "192.168.1.150"), result);
}
}
@@ -0,0 +1,181 @@
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests for DnsmasqLeasesFileLineParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated
/// fields — expiry (epoch), MAC, IP, hostname (or *), client-id (or *).
/// </summary>
public class DnsmasqLeasesFileLineParserTests
{
[Fact]
public void ParseLine_Empty_ReturnsNull()
{
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(""));
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(" "));
}
[Fact]
public void ParseLine_ValidFiveFields_Parses()
{
long epoch = 946689575;
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
string name = "wdt";
string clientId = "01:00:00:00:00:00:05";
var line = $"{epoch} {mac} {address} {name} {clientId}";
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
Assert.NotNull(e);
Assert.Equal(epoch, e!.Epoch);
Assert.Equal(mac, e.Mac);
Assert.Equal(address, e.Address);
Assert.Equal(name, e.Name);
Assert.Equal(clientId, e.ClientId);
}
[Fact]
public void ParseLine_UnknownHostname_Asterisk()
{
long epoch = 946689522;
string mac = "00:00:00:00:00:04";
string address = "192.168.1.237";
string name = "*";
string clientId = "01:00:00:00:00:00:04";
var line = $"{epoch} {mac} {address} {name} {clientId}";
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
Assert.NotNull(e);
Assert.Equal(name, e!.Name);
Assert.Equal(address, e.Address);
}
[Fact]
public void ParseLine_UnknownClientId_Asterisk()
{
long epoch = 946689351;
string mac = "00:0f:b0:3a:b5:0b";
string address = "192.168.1.208";
string name = "colinux";
string clientId = "*";
var line = $"{epoch} {mac} {address} {name} {clientId}";
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
Assert.NotNull(e);
Assert.Equal(name, e!.Name);
Assert.Equal(clientId, e.ClientId);
}
[Fact]
public void ParseLine_MultipleSpacesBetweenFields_Parses()
{
long epoch = 946689575;
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
string name = "wdt";
string clientId = "01:00:00:00:00:00:05";
var line = $" {epoch} {mac} {address} {name} {clientId} ";
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
Assert.NotNull(e);
Assert.Equal(epoch, e!.Epoch);
Assert.Equal(mac, e.Mac);
Assert.Equal(address, e.Address);
Assert.Equal(name, e.Name);
Assert.Equal(clientId, e.ClientId);
}
[Fact]
public void ParseLine_TooFewFields_ReturnsNull()
{
long epoch = 946689575;
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine($"{epoch} {mac} {address}"));
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine($"{epoch} {mac}"));
}
[Fact]
public void ParseLine_TrailingJunk_ReturnsNull()
{
// Parser uses .End() so extra text after fifth field fails
long epoch = 946689575;
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
string name = "wdt";
string clientId = "01:00:00:00:00:00:05";
var line = $"{epoch} {mac} {address} {name} {clientId} extra";
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(line));
}
[Fact]
public void ParseLine_InvalidEpoch_ReturnsNull()
{
string invalidEpoch = "notanum";
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
string name = "wdt";
string clientId = "01:00:00:00:00:00:05";
var line = $"{invalidEpoch} {mac} {address} {name} {clientId}";
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(line));
}
/// <summary>
/// When DHCPv6 is in use, dnsmasq writes a "duid ..." line; we must skip it (first field is not numeric).
/// </summary>
[Fact]
public void ParseLine_DuidLine_ReturnsNull()
{
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("duid 00:11:22:33:44:55"));
}
[Fact]
public void Timestamp_ConvertsEpochToDateTime()
{
long epoch = 946689575;
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
string name = "wdt";
string clientId = "01:00:00:00:00:00:05";
var line = $"{epoch} {mac} {address} {name} {clientId}";
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
Assert.NotNull(e);
Assert.Equal(epoch, e!.Epoch);
var dt = e.Timestamp;
Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12
}
[Fact]
public void Parse_testdata_leases_ParsesAllLines()
{
var lines = TestDataHelper.ReadAllLines("leases");
var entries = new List<LeaseEntry>();
foreach (var line in lines)
{
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
if (e != null)
entries.Add(e);
}
Assert.Equal(2, entries.Count);
Assert.Equal("aa:bb:cc:dd:ee:ff", entries[0].Mac);
Assert.Equal("192.168.1.10", entries[0].Address);
Assert.Equal("testpc", entries[0].Name);
Assert.Equal("01:02:03:04:05:06", entries[0].ClientId);
Assert.Equal("11:22:33:44:55:66", entries[1].Mac);
Assert.Equal("192.168.1.11", entries[1].Address);
Assert.Equal("*", entries[1].Name);
Assert.Equal("*", entries[1].ClientId);
}
}
@@ -1,7 +1,9 @@
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests for HostsFileLineParser. Format: IP_address canonical_hostname [aliases...] (hosts(5), RFC 952).
@@ -22,7 +24,8 @@ public class HostsFileLineParserTests
[Fact]
public void ParseLine_WhitespaceOnly_ReturnsPassthrough()
{
var e = HostsFileLineParser.ParseLine(" \t ", 2);
var raw = " \t ";
var e = HostsFileLineParser.ParseLine(raw, 2);
Assert.NotNull(e);
Assert.True(e!.IsPassthrough);
}
@@ -30,7 +33,8 @@ public class HostsFileLineParserTests
[Fact]
public void ParseLine_CommentOnly_TreatsWholeLineAsComment()
{
var e = HostsFileLineParser.ParseLine("# This is a comment", 1);
var commentLine = "# This is a comment";
var e = HostsFileLineParser.ParseLine(commentLine, 1);
Assert.NotNull(e);
Assert.True(e!.IsComment);
Assert.True(e.IsPassthrough);
@@ -42,7 +46,10 @@ public class HostsFileLineParserTests
public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress()
{
// Per hosts(5): text from # to EOL is comment - so "# 127.0.0.1 localhost" is entirely comment
var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1);
string address = "127.0.0.1";
string name = "localhost";
var line = $"# {address} {name}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.True(e!.IsComment);
Assert.Equal("", e.Address);
@@ -52,75 +59,110 @@ public class HostsFileLineParserTests
[Fact]
public void ParseLine_IPv4Localhost_ParsesAddressAndName()
{
var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost", 1);
string address = "127.0.0.1";
string name = "localhost";
var line = $"{address} {name}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.False(e!.IsPassthrough);
Assert.Equal("127.0.0.1", e.Address);
Assert.Equal(address, e.Address);
Assert.Single(e.Names);
Assert.Equal("localhost", e.Names[0]);
Assert.Equal(name, e.Names[0]);
}
[Fact]
public void ParseLine_IPv4WithCanonicalAndAlias_ParsesAll()
{
var e = HostsFileLineParser.ParseLine("127.0.1.1 thishost.example.org thishost", 1);
string address = "127.0.1.1";
string canonical = "thishost.example.org";
string alias = "thishost";
var line = $"{address} {canonical} {alias}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal("127.0.1.1", e.Address);
Assert.Equal(address, e.Address);
Assert.True(e.Names.Count >= 1);
Assert.Equal("thishost.example.org", e.Names[0]);
Assert.Equal(canonical, e.Names[0]);
if (e.Names.Count >= 2)
Assert.Equal("thishost", e.Names[1]);
Assert.Equal(alias, e.Names[1]);
}
[Fact]
public void ParseLine_IPv4WithMultipleSpaces_ParsesCorrectly()
{
var e = HostsFileLineParser.ParseLine("192.168.1.10 foo.example.org foo", 1);
string address = "192.168.1.10";
string name1 = "foo.example.org";
string name2 = "foo";
var line = $"{address} {name1} {name2}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal("192.168.1.10", e.Address);
Assert.Equal(address, e.Address);
Assert.True(e.Names.Count >= 1);
Assert.Equal("foo.example.org", e.Names[0]);
Assert.Equal(name1, e.Names[0]);
}
[Fact]
public void ParseLine_IPv6_ParsesAddressAndNames()
{
var e = HostsFileLineParser.ParseLine("::1 localhost ip6-localhost ip6-loopback", 1);
string address = "::1";
string name1 = "localhost";
var line = $"{address} {name1} ip6-localhost ip6-loopback";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal("::1", e.Address);
Assert.Equal(address, e.Address);
Assert.True(e.Names.Count >= 1);
Assert.Equal("localhost", e.Names[0]);
Assert.Equal(name1, e.Names[0]);
}
[Fact]
public void ParseLine_IPv6Multicast_Parses()
{
var e = HostsFileLineParser.ParseLine("ff02::1 ip6-allnodes", 1);
string address = "ff02::1";
string name = "ip6-allnodes";
var line = $"{address} {name}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal("ff02::1", e.Address);
Assert.Equal(address, e.Address);
Assert.Single(e.Names);
Assert.Equal("ip6-allnodes", e.Names[0]);
Assert.Equal(name, e.Names[0]);
}
[Fact]
public void ParseLine_InlineComment_StopsAtHash()
{
// Token stops at #; only address and names before # are parsed
var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost # loopback", 1);
string address = "127.0.0.1";
string name = "localhost";
string comment = "loopback";
var line = $"{address} {name} # {comment}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal("127.0.0.1", e.Address);
Assert.Equal(address, e.Address);
Assert.Single(e.Names);
Assert.Equal("localhost", e.Names[0]);
Assert.Equal(name, e.Names[0]);
Assert.Equal(comment, e.InlineComment);
}
[Fact]
public void ToLine_WithInlineComment_AppendsHashAndRoundTrips()
{
var line = "192.168.0.5 router # edge gateway";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
var back = HostsFileLineParser.ToLine(e!);
Assert.Equal(line, back);
}
[Fact]
public void ParseLine_LeadingWhitespace_TrimmedAndParsed()
{
var e = HostsFileLineParser.ParseLine(" 192.168.1.13 bar", 1);
string address = "192.168.1.13";
string name = "bar";
var line = $" {address} {name}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
Assert.Equal("192.168.1.13", e.Address);
Assert.Equal(address, e.Address);
Assert.Single(e.Names);
Assert.Equal("bar", e.Names[0]);
Assert.Equal(name, e.Names[0]);
}
[Fact]
@@ -136,7 +178,8 @@ public class HostsFileLineParserTests
public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough()
{
// Content requires address + whitespace + at least one name
var e = HostsFileLineParser.ParseLine("192.168.1.1", 1);
string address = "192.168.1.1";
var e = HostsFileLineParser.ParseLine(address, 1);
Assert.NotNull(e);
Assert.True(e!.IsPassthrough);
}
@@ -144,7 +187,9 @@ public class HostsFileLineParserTests
[Fact]
public void ToLine_Entry_Roundtrips()
{
var line = "127.0.0.1 localhost";
string address = "127.0.0.1";
string name = "localhost";
var line = $"{address} {name}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
var back = HostsFileLineParser.ToLine(e!);
@@ -154,19 +199,23 @@ public class HostsFileLineParserTests
[Fact]
public void ToLine_CommentedEntry_PrefixPreserved()
{
var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1);
string address = "127.0.0.1";
string name = "localhost";
var commentLine = $"# {address} {name}";
var e = HostsFileLineParser.ParseLine(commentLine, 1);
Assert.NotNull(e);
// When we parse "# ..." we get isComment=true, address="", names=[]. ToLine for passthrough returns RawLine.
e = new HostEntry { LineNumber = 1, Address = "127.0.0.1", Names = ["localhost"], IsComment = true };
e = new HostEntry { LineNumber = 1, Address = address, Names = [name], IsComment = true };
var back = HostsFileLineParser.ToLine(e);
Assert.Equal("# 127.0.0.1 localhost", back);
Assert.Equal(commentLine, back);
}
[Fact]
public void ToLine_Passthrough_ReturnsRawLine()
{
var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true };
Assert.Equal(" \t ", HostsFileLineParser.ToLine(e));
var raw = " \t ";
var e = new HostEntry { LineNumber = 1, RawLine = raw, IsPassthrough = true };
Assert.Equal(raw, HostsFileLineParser.ToLine(e));
}
[Fact]
@@ -0,0 +1,63 @@
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.EffectiveConfig;
public class DomainRuleFieldsTests
{
[Theory]
[InlineData("home.arpa", "home.arpa")]
[InlineData("#", "# (resolv.conf search)")]
[InlineData("example.com,192.168.0.0/24", "example.com · 192.168.0.0/24")]
[InlineData("example.com,192.168.0.0/24,local", "example.com · 192.168.0.0/24 · local DNS")]
[InlineData("example.com,eth0", "example.com · interface eth0")]
[InlineData("example.com,local", "example.com · local DNS")]
public void FormatSummary_FormatsExpected(string line, string expectedContains)
{
var s = DomainRuleFields.FormatSummary(line);
Assert.Equal(expectedContains, s);
}
[Fact]
public void Parse_RoundTrip_Unconditional()
{
var f = DomainRuleFields.Parse("my.domain");
Assert.False(f.IsRaw);
Assert.Equal("my.domain", f.ToConfigLine());
}
[Fact]
public void Parse_RoundTrip_RangeAndLocal()
{
var line = "my.domain,10.0.0.0/24,local";
var f = DomainRuleFields.Parse(line);
Assert.False(f.IsRaw);
Assert.Equal(line, f.ToConfigLine());
}
[Fact]
public void Parse_RoundTrip_Interface()
{
var line = "my.domain,br0";
var f = DomainRuleFields.Parse(line);
Assert.False(f.IsRaw);
Assert.Equal(DomainRuleScope.InterfaceName, f.Scope);
Assert.Equal(line, f.ToConfigLine());
}
[Fact]
public void Parse_TooManyTokens_UsesRaw()
{
var line = "a,b,c,d";
var f = DomainRuleFields.Parse(line);
Assert.True(f.IsRaw);
Assert.Equal(line, f.ToConfigLine());
}
[Fact]
public void FromRawConfigLine_PreservesLine()
{
var f = DomainRuleFields.FromRawConfigLine(" weird,line,here ");
Assert.True(f.IsRaw);
Assert.Equal("weird,line,here", f.ToConfigLine());
}
}
@@ -0,0 +1,89 @@
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters;
using Xunit;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.Filters;
public class FilterRuleFieldsTests
{
[Fact]
public void Address_AllDomains_NullSinkhole_SummaryAndRoundTrip()
{
var raw = "/#/#";
var p = AddressRuleFields.Parse(raw);
Assert.False(p.IsRaw);
Assert.Equal(AddressMatchMode.AllDomains, p.MatchMode);
Assert.Equal(AddressResponseMode.NullSinkhole, p.ResponseMode);
Assert.Equal("all domains return null/sinkhole", p.ToSummary());
Assert.Equal(raw, p.ToConfigLine());
}
[Fact]
public void Address_DomainPattern_Ip_Summary()
{
var raw = "/corp.local/10.0.0.2";
var p = AddressRuleFields.Parse(raw);
Assert.False(p.IsRaw);
Assert.Equal(AddressMatchMode.DomainPattern, p.MatchMode);
Assert.Equal("corp.local", p.DomainPath);
Assert.Equal("queries for /corp.local/ return 10.0.0.2", p.ToSummary());
}
[Fact]
public void Server_Scoped_Summary()
{
var raw = "/corp.local/10.0.0.2";
var p = ServerRuleFields.Parse(raw);
Assert.False(p.IsRaw);
Assert.True(p.IsScoped);
Assert.Equal("queries for /corp.local/ go to 10.0.0.2", p.ToSummary());
}
[Fact]
public void RevServer_Summary_MatchesExample()
{
var raw = "192.168.1.0/24,10.0.0.1";
var p = RevServerRuleFields.Parse(raw);
Assert.False(p.IsRaw);
Assert.Equal("reverse lookups for 192.168.1.0/24 go to 10.0.0.1", p.ToSummary());
}
[Fact]
public void BogusNxdomain_Summary()
{
var p = BogusNxdomainRuleFields.Parse("64.94.110.11/24");
Assert.False(p.IsRaw);
Assert.Contains("64.94.110.11/24", p.ToSummary());
Assert.Contains("bogus NXDOMAIN", p.ToSummary());
}
[Fact]
public void FilterRr_Summary()
{
var p = FilterRrRuleFields.Parse("0.0.0.0,A");
Assert.False(p.IsRaw);
Assert.Contains("A", p.ToSummary());
Assert.Contains("0.0.0.0", p.ToSummary());
}
[Fact]
public void FilterRr_MatchMayContainCommas_SplitsOnLastComma_RoundTrips()
{
const string raw = "10.0.0.0,192.168.1.0,A";
var p = FilterRrRuleFields.Parse(raw);
Assert.False(p.IsRaw);
Assert.Equal("10.0.0.0,192.168.1.0", p.MatchValue);
Assert.Equal("A", p.RecordType);
Assert.Equal(raw, p.ToConfigLine());
}
[Fact]
public void Local_UnqualifiedNames_Parse_ToConfigLine_RoundTrips()
{
const string raw = "//";
var p = LocalRuleFields.Parse(raw);
Assert.False(p.IsRaw);
Assert.Equal("", p.DomainPath);
Assert.Equal("unqualified names are answered locally only", p.ToSummary());
Assert.Equal(raw, p.ToConfigLine());
}
}
@@ -0,0 +1,217 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
/// <summary>
/// Tests effective-config resolution in ConfigSetCache (BuildEffectiveConfig / BuildEffectiveConfigSources).
/// Verifies no-0x20-encode precedence and source attribution per dnsmasq docs.
/// </summary>
public class ConfigSetCacheEffectiveConfigTests
{
private static (string Dir, string MainPath, string ManagedPath, ConfigSetCache Cache) CreateCacheWithManaged(string managedContent)
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cache-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, managedContent);
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
return (dir, mainPath, managedPath, cache);
}
[Fact]
public async Task Do0x20_BothPresent_No0x20Wins()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("do-0x20-encode\nno-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_OnlyDo_Enabled()
{
var (dir, _, _, cache) = CreateCacheWithManaged("do-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Enabled, snapshot.Config.Do0x20EncodeState);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_OnlyNo_Disabled()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("no-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_Neither_Default()
{
var (dir, _, _, cache) = CreateCacheWithManaged("port=53\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Default, snapshot.Config.Do0x20EncodeState);
Assert.Null(snapshot.Sources.Do0x20Encode);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task Do0x20_SourceAttribution_WhenBothPresent_IsNo0x20Line()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("no-0x20-encode\ndo-0x20-encode\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.Do0x20Encode.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task StripFlags_ParseState_And_Source()
{
var (dir, _, managedPath, cache) = CreateCacheWithManaged("strip-mac\nstrip-subnet\n");
try
{
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.StripMac);
Assert.True(snapshot.Config.StripSubnet);
Assert.NotNull(snapshot.Sources.StripMac);
Assert.NotNull(snapshot.Sources.StripSubnet);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.StripMac!.FilePath, StringComparer.OrdinalIgnoreCase);
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Sources.StripSubnet!.FilePath, StringComparer.OrdinalIgnoreCase);
}
finally
{
cache.Dispose();
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task RealWorldCorpus_EdgeCommentedActiveMix_Do0x20StateAndSource()
{
var mainPath = TestDataHelper.GetPath("real-world/edge/dnsmasq-real-edge-commented-active-mix.conf");
Assert.True(File.Exists(mainPath));
var dir = Path.GetDirectoryName(mainPath)!;
var managedName = "zz-managed.conf";
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Single(snapshot.Config.ServerValues);
Assert.Equal("1.1.1.1", snapshot.Config.ServerValues[0]);
}
[Fact]
public async Task RealWorldCorpus_GoodHomeBasic_ServerCountAndSource()
{
var mainPath = TestDataHelper.GetPath("real-world/good/dnsmasq-real-home-basic.conf");
Assert.True(File.Exists(mainPath));
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "zz-managed.conf" });
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.ServerValues.Count >= 2);
Assert.Equal(2, snapshot.Sources.ServerValues.Count);
}
[Fact]
public async Task RealWorldCorpus_GoodResolverRich_LocalAddressCacheSizeFlags()
{
var mainPath = TestDataHelper.GetPath("real-world/good/dnsmasq-real-resolver-rich.conf");
Assert.True(File.Exists(mainPath));
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "zz-managed.conf" });
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.ServerValues.Count >= 2);
Assert.True(snapshot.Config.LocalValues.Count >= 2);
Assert.True(snapshot.Config.AddressValues.Count >= 2);
Assert.Equal(1000, snapshot.Config.CacheSize);
Assert.True(snapshot.Config.NoResolv);
Assert.True(snapshot.Config.BogusPriv);
Assert.True(snapshot.Config.ExpandHosts);
}
[Fact]
public async Task GetSnapshot_WithManagedFilesDirectory_UsesOverridePaths()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cache-" + Guid.NewGuid().ToString("N"));
var managedDir = Path.Combine(dir, "repo");
Directory.CreateDirectory(dir);
Directory.CreateDirectory(managedDir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedFileName = "zz-managed.conf";
var managedHostsFileName = "zz-managed.hosts";
var managedPath = Path.Combine(managedDir, managedFileName);
File.WriteAllText(mainPath, $"conf-file={managedPath}\n");
File.WriteAllText(managedPath, $"addn-hosts={Path.Combine(managedDir, managedHostsFileName)}\n");
using var cache = new ConfigSetCache(
Options.Create(new DnsmasqOptions
{
MainConfigPath = mainPath,
ManagedFilesDirectory = managedDir,
ManagedFileName = managedFileName,
ManagedHostsFileName = managedHostsFileName
}),
NullLogger<ConfigSetCache>.Instance);
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(Path.GetFullPath(managedPath), snapshot.Set.ManagedFilePath);
Assert.Equal(Path.GetFullPath(Path.Combine(managedDir, managedHostsFileName)), snapshot.Set.ManagedHostsFilePath);
Assert.Contains(snapshot.Set.Files, f => f.IsManaged && string.Equals(f.Path, Path.GetFullPath(managedPath), StringComparison.Ordinal));
}
}
@@ -0,0 +1,735 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
/// <summary>
/// Save-path tests for DnsmasqConfigService.ApplyEffectiveConfigChangesAsync.
/// Verifies that pending changes produce the correct managed config lines.
/// </summary>
public class DnsmasqConfigServiceApplyChangesTests
{
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_ConntrackTrue_WritesBareFlagLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Conntrack, false, true, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.Contains(lines, l => l == "conntrack");
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_ConntrackFalse_RemovesFlagLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, "conntrack\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Conntrack, true, false, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.DoesNotContain(lines, l => l == "conntrack" || l.StartsWith("conntrack=", StringComparison.Ordinal));
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_StripMacTrue_WritesBareFlagLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.StripMac, false, true, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.Contains(lines, l => l == DnsmasqConfKeys.StripMac);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_StripSubnetFalse_RemovesFlagLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, "strip-subnet\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.StripSubnet, true, false, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.DoesNotContain(lines, l => l == DnsmasqConfKeys.StripSubnet || l.StartsWith($"{DnsmasqConfKeys.StripSubnet}=", StringComparison.Ordinal));
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_DnssecCheckUnsignedNo_WritesEqualsNo()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var text = await File.ReadAllTextAsync(managedPath);
Assert.Contains("dnssec-check-unsigned=no", text);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_SingleValueNull_RemovesExistingLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, "port=54\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Port, "54", null, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.DoesNotContain(lines, l => l == DnsmasqConfKeys.Port || l.StartsWith($"{DnsmasqConfKeys.Port}=", StringComparison.Ordinal));
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_KeyOnlyOrValue_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("use-stale-cache", content);
Assert.DoesNotContain("use-stale-cache=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_InversePair_Enabled_WritesDo0x20Encode()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Enabled, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
Assert.True(File.Exists(managedPath));
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("do-0x20-encode", content);
Assert.DoesNotContain("no-0x20-encode", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Do0x20_Default_RemovesBothLines()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, "do-0x20-encode\nno-0x20-encode\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Disabled, ExplicitToggleState.Default, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.DoesNotContain(lines, l => l == "do-0x20-encode" || l == "no-0x20-encode");
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Do0x20Enabled_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Enabled, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Enabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_UseStaleCache_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "60", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal("60", snapshot.Config.UseStaleCache);
Assert.NotNull(snapshot.Sources.UseStaleCache);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Server_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server, null, new List<string> { "1.1.1.1", "8.8.8.8" }, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(2, snapshot.Config.ServerValues.Count);
Assert.Contains("1.1.1.1", snapshot.Config.ServerValues);
Assert.Contains("8.8.8.8", snapshot.Config.ServerValues);
Assert.Equal(2, snapshot.Sources.ServerValues.Count);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Conntrack_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Conntrack, false, true, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.Conntrack);
Assert.NotNull(snapshot.Sources.Conntrack);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Do0x20_Disabled_WritesNo0x20Encode()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Disabled, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("no-0x20-encode", content);
Assert.DoesNotContain("do-0x20-encode", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_UseStaleCache_Value_WritesKeyEqualsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "60", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("use-stale-cache=60", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_AddMac_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddMac, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("add-mac", content);
Assert.DoesNotContain("add-mac=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_AddMac_ValueBase64_WritesKeyEqualsValue()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddMac, null, "base64", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("add-mac=base64", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_AddSubnet_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.AddSubnet, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("add-subnet", content);
Assert.DoesNotContain("add-subnet=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Umbrella_KeyOnly_WritesBareKey()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.Umbrella, null, "", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
Assert.Contains("umbrella", content);
Assert.DoesNotContain("umbrella=", content);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_Leasequery_MultiKeyOnlyOrValue_DoesNotDuplicateReadonlyValues()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var readonlyPath = Path.Combine(dir, "readonly.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, $"port=53\nconf-file={readonlyPath}\n");
File.WriteAllText(readonlyPath, "leasequery\nleasequery=10.0.0.0/24\n");
File.WriteAllText(managedPath, "leasequery=172.16.0.0/16\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
// Simulate UI effective values (readonly + managed). Save should write only managed-only values.
var changes = new List<PendingEffectiveConfigChange>
{
new(
EffectiveConfigSections.SectionDhcp,
DnsmasqConfKeys.Leasequery,
null,
new List<string> { "", "10.0.0.0/24", "192.168.50.0/24" },
null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var lines = (await File.ReadAllLinesAsync(managedPath))
.Select(l => l.Trim())
.Where(l => l.StartsWith("leasequery", StringComparison.Ordinal))
.ToList();
Assert.Single(lines);
Assert.Equal("leasequery=192.168.50.0/24", lines[0]);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
}
@@ -1,10 +1,11 @@
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Services;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
/// <summary>
/// Tests for DnsmasqConfigSetService, including GetEffectiveConfigWithSources.
@@ -24,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);
@@ -40,7 +42,10 @@ public class DnsmasqConfigSetServiceTests
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\ncache-size=500\naddn-hosts=/etc/hosts.extra\n");
int port = 53;
int cacheSize = 500;
var addnHostsPath = "/etc/hosts.extra";
File.WriteAllText(mainPath, $"port={port}\ncache-size={cacheSize}\naddn-hosts={addnHostsPath}\n");
var options = Options.Create(new DnsmasqOptions
{
MainConfigPath = mainPath,
@@ -51,11 +56,12 @@ public class DnsmasqConfigSetServiceTests
var (config, sources) = service.GetEffectiveConfigWithSources();
Assert.NotNull(config);
Assert.Equal(53, config.Port);
Assert.Equal(500, config.CacheSize);
Assert.Equal(port, config.Port);
Assert.Equal(cacheSize, config.CacheSize);
Assert.Single(config.AddnHostsPaths);
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), config.AddnHostsPaths[0]);
Assert.Empty(config.ServerLocalValues);
Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
Assert.Empty(config.ServerValues);
Assert.Empty(config.LocalValues);
Assert.Empty(config.DhcpRanges);
Assert.NotNull(sources);
@@ -63,7 +69,7 @@ public class DnsmasqConfigSetServiceTests
Assert.Equal(Path.GetFileName(mainPath), sources.Port!.FileName);
Assert.NotNull(sources.CacheSize);
Assert.Single(sources.AddnHostsPaths);
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), sources.AddnHostsPaths[0].Path);
Assert.Equal(Path.GetFullPath(addnHostsPath), sources.AddnHostsPaths[0].Path);
Assert.NotNull(sources.AddnHostsPaths[0].Source);
Assert.Equal(Path.GetFileName(mainPath), sources.AddnHostsPaths[0].Source!.FileName);
}
@@ -0,0 +1,209 @@
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Dhcp.Ui;
using DnsmasqWebUI.Models.Dnsmasq;
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Dhcp;
public class DhcpPageProjectionServiceTests
{
private static readonly DhcpPageProjectionService Sut = new(new EffectiveMultiValueProjectionService());
private static readonly DhcpHostOptionValueHandler Handler = new();
private static DhcpHostPageRow SyntheticRow(
int effectiveIndex,
DhcpSourceKind kind,
string sourcePath,
bool editable,
string mac,
string? name = null,
string? address = "192.168.1.10") =>
new(
EffectiveIndex: effectiveIndex,
ValueString: mac,
RowKey: $"t:{effectiveIndex}",
OccurrenceId: $"t:{effectiveIndex}",
SourceKind: kind,
SourcePath: sourcePath,
IsDraftOnly: false,
IsEditable: editable,
IsActive: true,
Entry: new DhcpHostEntry
{
LineNumber = effectiveIndex + 1,
MacAddresses = new List<string> { mac },
Name = name,
Address = address,
Lease = "infinite",
Extra = new List<string>()
},
Conflict: null,
LinkedLease: null,
MatchesManagedStatic: kind == DhcpSourceKind.Managed && editable);
[Fact]
public void BuildHostRows_NullSources_TreatsLinesAsManagedEditable()
{
var status = MinimalStatus(null, "/managed.conf", "/etc/dnsmasq.conf");
var values = new[] { "aa:bb:cc:dd:ee:ff,192.168.1.2" };
var rows = Sut.BuildHostRows(status, values, Handler);
Assert.Single(rows);
Assert.True(rows[0].IsEditable);
Assert.Equal(DhcpSourceKind.Managed, rows[0].SourceKind);
}
[Fact]
public void ApplyHostConflictsAndLeases_DetectsDuplicateMac()
{
var entry1 = new DhcpHostEntry
{
LineNumber = 1,
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
Address = "192.168.1.2"
};
var entry2 = new DhcpHostEntry
{
LineNumber = 2,
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
Address = "192.168.1.3"
};
var rows = new List<DhcpHostPageRow>
{
new(0, "a", "k0", "k0", DhcpSourceKind.Managed, "/m", false, true, true, entry1, null, null, true),
new(1, "b", "k1", "k1", DhcpSourceKind.Managed, "/m", false, true, true, entry2, null, null, true)
};
DhcpPageProjectionService.ApplyHostConflictsAndLeases(rows, null, null);
Assert.NotNull(rows[0].Conflict);
Assert.True(rows[0].Conflict!.DuplicateMac);
Assert.NotNull(rows[1].Conflict);
Assert.True(rows[1].Conflict!.DuplicateMac);
}
[Fact]
public void BuildHostGroups_PrependsEmptyManagedGroupWhenOnlyExternalRows()
{
var rows = new[]
{
SyntheticRow(0, DhcpSourceKind.MainConfig, "/etc/dnsmasq.conf", false, "aa:bb:cc:dd:ee:01")
};
var query = new DhcpPageQueryState("", null, DhcpHostSortMode.LineNumber, false);
var groups = Sut.BuildHostGroups(rows, query, "/var/lib/dnsmasq/managed.conf");
Assert.True(groups.Count >= 2);
Assert.Equal(DhcpSourceKind.Managed, groups[0].SourceKind);
Assert.Empty(groups[0].Rows);
Assert.True(groups[0].IsSourceEditable);
Assert.Single(groups[1].Rows);
}
[Fact]
public void BuildHostGroups_OrdersManagedGroupBeforeMainConfig()
{
var rows = new[]
{
SyntheticRow(1, DhcpSourceKind.MainConfig, "/etc/dnsmasq.conf", false, "aa:bb:cc:dd:ee:02"),
SyntheticRow(0, DhcpSourceKind.Managed, "/m.conf", true, "aa:bb:cc:dd:ee:01")
};
var query = new DhcpPageQueryState("", null, DhcpHostSortMode.LineNumber, false);
var groups = Sut.BuildHostGroups(rows, query, "/m.conf");
Assert.Equal(2, groups.Count);
Assert.Equal(DhcpSourceKind.Managed, groups[0].SourceKind);
Assert.Equal(DhcpSourceKind.MainConfig, groups[1].SourceKind);
}
[Fact]
public void BuildHostGroups_SearchKeepsOnlyMatchingRows()
{
var rows = new[]
{
SyntheticRow(0, DhcpSourceKind.Managed, "/m.conf", true, "aa:bb:cc:dd:ee:01", name: "printer-one"),
SyntheticRow(1, DhcpSourceKind.Managed, "/m.conf", true, "aa:bb:cc:dd:ee:02", name: "router-fw")
};
var query = new DhcpPageQueryState("printer", null, DhcpHostSortMode.LineNumber, false);
var groups = Sut.BuildHostGroups(rows, query, "/m.conf");
Assert.Single(groups);
Assert.Single(groups[0].Rows);
Assert.Contains("printer", groups[0].Rows[0].Entry.Name, StringComparison.Ordinal);
}
[Fact]
public void BuildLeaseRows_FlagsManagedStaticWhenMacMatchesEditableRow()
{
var status = MinimalStatus(null, "/m.conf", "/etc/dnsmasq.conf");
var hostRows = new[]
{
SyntheticRow(0, DhcpSourceKind.Managed, "/m.conf", true, "11:22:33:44:55:66")
};
var leases = new List<LeaseEntry>
{
new()
{
Epoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
Mac = "11:22:33:44:55:66",
Address = "192.168.1.50",
Name = "x",
ClientId = ""
}
};
var vms = Sut.BuildLeaseRows(status, leases, hostRows);
Assert.Single(vms);
Assert.True(vms[0].HasManagedStaticByMac);
Assert.True(vms[0].HasAnyStaticByMac);
}
[Fact]
public void ApplyHostConflictsAndLeases_DetectsDuplicateAddress()
{
var entry1 = new DhcpHostEntry
{
LineNumber = 1,
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:01" },
Address = "192.168.1.5"
};
var entry2 = new DhcpHostEntry
{
LineNumber = 2,
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:02" },
Address = "192.168.1.5"
};
var rows = new List<DhcpHostPageRow>
{
new(0, "a", "k0", "k0", DhcpSourceKind.Managed, "/m", false, true, true, entry1, null, null, true),
new(1, "b", "k1", "k1", DhcpSourceKind.Managed, "/m", false, true, true, entry2, null, null, true)
};
DhcpPageProjectionService.ApplyHostConflictsAndLeases(rows, null, null);
Assert.NotNull(rows[0].Conflict);
Assert.True(rows[0].Conflict!.DuplicateAddress);
Assert.True(rows[1].Conflict!.DuplicateAddress);
}
private static DnsmasqServiceStatus MinimalStatus(
DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.EffectiveConfigSources? sources,
string managedPath,
string mainPath) =>
new(
SystemHostsPath: null, SystemHostsPathExists: false, ManagedHostsFilePath: null, ManagedHostsPathExists: false,
NoHosts: false, AddnHostsPaths: [],
EffectiveConfig: null,
EffectiveConfigSources: sources,
MainConfigPath: mainPath, ManagedFilePath: managedPath, LeasesPath: null,
MainConfigPathExists: true, ManagedFilePathExists: true, 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: null, MinimumSupportedDnsmasqVersion: "2.90", DnsmasqVersionSupported: true, DnsmasqVersionError: null,
DnsmasqSupportsDhcp: true, DnsmasqSupportsTftp: true, DnsmasqSupportsDnssec: false, DnsmasqSupportsDbus: false);
}
@@ -0,0 +1,131 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using static DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata.EffectiveConfigSections;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.DnsRecords;
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.DnsRecords;
public class DnsRecordDirectiveCodecProviderTests
{
private readonly IDnsRecordDirectiveCodecProvider _provider = new DnsRecordDirectiveCodecProvider();
[Fact]
public void CnameCodec_RoundTrip_PreservesValue()
{
var codec = _provider.Get(DnsmasqConfKeys.Cname);
const string raw = "alias1,alias2,target.example.com,3600";
var row = codec.Parse(new ValueWithSource(raw, null), 0);
Assert.Equal(raw, codec.Serialize(row));
var p = Assert.IsType<CnamePayload>(row.Payload);
Assert.Equal(new[] { "alias1", "alias2" }, p.Aliases);
Assert.Equal("target.example.com", p.Target);
Assert.Equal(3600, p.Ttl);
}
[Fact]
public void AuthSoaCodec_RoundTrip_KeepsRefreshInCorrectSlotWhenHostmasterBlank()
{
var codec = _provider.Get(DnsmasqConfKeys.AuthSoa);
const string raw = "42,,3600";
var row = codec.Parse(new ValueWithSource(raw, null), 0);
Assert.Equal(raw, codec.Serialize(row));
var p = Assert.IsType<AuthSoaPayload>(row.Payload);
Assert.Equal("42", p.Serial);
Assert.Equal("3600", p.Refresh);
}
[Fact]
public void AuthSoaCodec_RoundTrip_FullLineWithHostmaster()
{
var codec = _provider.Get(DnsmasqConfKeys.AuthSoa);
const string raw = "1,hostmaster.example.com,3600,600,86400";
var row = codec.Parse(new ValueWithSource(raw, null), 0);
Assert.Equal(raw, codec.Serialize(row));
var p = Assert.IsType<AuthSoaPayload>(row.Payload);
Assert.Equal("1", p.Serial);
Assert.Equal("hostmaster.example.com", p.Hostmaster);
Assert.Equal("3600", p.Refresh);
Assert.Equal("600", p.Retry);
Assert.Equal("86400", p.Expiry);
}
[Fact]
public void AuthSoaCodec_RoundTrip_SerialOnly()
{
var codec = _provider.Get(DnsmasqConfKeys.AuthSoa);
const string raw = "99";
var row = codec.Parse(new ValueWithSource(raw, null), 0);
Assert.Equal(raw, codec.Serialize(row));
}
[Fact]
public void HostRecordCodec_RoundTrip_OwnersIpv4AndTtl()
{
var codec = _provider.Get(DnsmasqConfKeys.HostRecord);
const string raw = "host.example.com,www.host.example.com,192.168.1.10,120";
var row = codec.Parse(new ValueWithSource(raw, null), 0);
Assert.Equal(raw, codec.Serialize(row));
var p = Assert.IsType<HostRecordPayload>(row.Payload);
Assert.Equal(new[] { "host.example.com", "www.host.example.com" }, p.Owners);
Assert.Equal("192.168.1.10", p.IPv4);
Assert.Equal(120, p.Ttl);
}
[Fact]
public void SrvCodec_RoundTrip_PreservesValue()
{
var codec = _provider.Get(DnsmasqConfKeys.Srv);
const string raw = "_sip._tcp.example.com,host.example.com,5060,10,5";
var row = codec.Parse(new ValueWithSource(raw, null), 0);
Assert.Equal(raw, codec.Serialize(row));
var p = Assert.IsType<SrvPayload>(row.Payload);
Assert.Equal("_sip._tcp.example.com", p.ServiceName);
Assert.Equal("host.example.com", p.Target);
Assert.Equal(5060, p.Port);
Assert.Equal(10, p.Priority);
Assert.Equal(5, p.Weight);
}
[Fact]
public void SrvCodec_RoundTrip_ServiceAndTargetOnly_NoTrailingZeros()
{
var codec = _provider.Get(DnsmasqConfKeys.Srv);
const string raw = "_sip._tcp.example.com,host.example.com";
var row = codec.Parse(new ValueWithSource(raw, null), 0);
Assert.Equal(raw, codec.Serialize(row));
var p = Assert.IsType<SrvPayload>(row.Payload);
Assert.Null(p.Port);
Assert.Null(p.Priority);
Assert.Null(p.Weight);
}
[Fact]
public void SrvCodec_RoundTrip_ServiceTargetPort_OmitsPriorityAndWeight()
{
var codec = _provider.Get(DnsmasqConfKeys.Srv);
const string raw = "_http._tcp.example.com,host.example.com,80";
var row = codec.Parse(new ValueWithSource(raw, null), 0);
Assert.Equal(raw, codec.Serialize(row));
var p = Assert.IsType<SrvPayload>(row.Payload);
Assert.Equal(80, p.Port);
Assert.Null(p.Priority);
Assert.Null(p.Weight);
}
[Fact]
public void Provider_ListsEveryDnsRecordsSectionOptionExceptAdjunctFlags()
{
var sectionOptions = Sections
.First(s => s.SectionId == SectionDnsRecords)
.OptionNames;
foreach (var name in sectionOptions)
{
if (name is DnsmasqConfKeys.MxTarget or DnsmasqConfKeys.Localmx or DnsmasqConfKeys.Selfmx)
continue;
Assert.True(_provider.TryGet(name, out var c) && c != null, $"Missing codec for {name}");
}
}
}
@@ -0,0 +1,534 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.DnsRecords;
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.DnsRecords;
public class DnsRecordsPageProjectionServiceTests
{
private readonly DnsRecordsPageProjectionService _sut = new(
new DnsRecordDirectiveCodecProvider(),
new NoopOptionSemanticValidator(),
new EffectiveMultiValueProjectionService());
[Fact]
public void BuildRows_ReturnsEmpty_WhenEffectiveConfigNull()
{
var status = MinimalStatus() with { EffectiveConfig = null };
Assert.Empty(_sut.BuildRows(status));
}
[Fact]
public void FilterRows_HidesReadOnly_WhenShowReadOnlyFalse()
{
var rows = new[]
{
Row("a", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", editable: true),
Row("b", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "p,q", editable: false),
};
var q = new DnsRecordsQueryState("", DnsRecordsUiFamily.All, null, ShowReadOnly: false, OnlyWithIssues: false);
var filtered = _sut.FilterRows(rows, q);
Assert.Single(filtered);
Assert.Equal("a", filtered[0].Id);
}
[Fact]
public void FilterRows_SourcePathFilter_MatchesFilePath()
{
var src = new ConfigValueSource("/etc/extra.conf", "extra.conf", IsManaged: false);
var rows = new[]
{
Row("a", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", editable: true, source: src),
Row("b", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "p,q", editable: true, source: null),
};
var q = new DnsRecordsQueryState("", DnsRecordsUiFamily.All, "/etc/extra.conf", true, false);
var filtered = _sut.FilterRows(rows, q);
Assert.Single(filtered);
Assert.Equal("a", filtered[0].Id);
}
[Fact]
public void FilterRows_Search_MatchesSummaryCaseInsensitive()
{
var rows = new[]
{
Row("a", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", editable: true, summary: "Alpha summary"),
Row("b", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "p,q", editable: true, summary: "Beta"),
};
var q = new DnsRecordsQueryState("alpha", DnsRecordsUiFamily.All, null, true, false);
var filtered = _sut.FilterRows(rows, q);
Assert.Single(filtered);
Assert.Equal("a", filtered[0].Id);
}
[Fact]
public void FilterRows_UiFamily_Advanced_KeepsOnlyAdvancedFamilies()
{
var rows = new[]
{
Row("c", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, "x,y", true),
Row("n", DnsmasqConfKeys.NaptrRecord, DnsRecordFamily.Naptr, "1", true, payload: new NaptrPayload("e.com", "1", "2", "s", "SIP", "!^.*$!", "_sip._udp.example.com")),
};
var q = new DnsRecordsQueryState("", DnsRecordsUiFamily.Advanced, null, true, false);
var filtered = _sut.FilterRows(rows, q);
Assert.Single(filtered);
Assert.Equal("n", filtered[0].Id);
}
[Fact]
public void BuildRows_CurrentValuesAccessor_UsesDraftValuesAndManagedSourcePathForUnmatchedRows()
{
var status = MinimalStatus() with
{
ManagedFilePath = "/managed.conf",
EffectiveConfig = MinimalConfig() with { CnameValues = ["disk.example,target.example"] },
EffectiveConfigSources = MinimalSources() with
{
CnameValues = [new ValueWithSource("disk.example,target.example", new ConfigValueSource("/etc/dnsmasq.d/base.conf", "base.conf", false, 12))]
}
};
var rows = _sut.BuildRows(status, optionName => optionName == DnsmasqConfKeys.Cname
? ["draft.example,target.example"]
: []);
var row = Assert.Single(rows);
Assert.Equal("draft.example,target.example", row.RawValue);
Assert.True(row.IsDraftOnly);
Assert.True(row.IsEditable);
Assert.Equal("/managed.conf", row.SourcePath);
Assert.Equal("managed.conf", row.SourceLabel);
}
private static DnsRecordRow Row(
string id,
string optionName,
DnsRecordFamily family,
string rawValue,
bool editable,
ConfigValueSource? source = null,
DnsRecordPayload? payload = null,
string? summary = null,
IReadOnlyList<DnsRecordIssue>? issues = null) =>
new(
id,
id,
optionName,
family,
IndexInOption: 0,
rawValue,
source,
source?.FilePath,
source?.FileName,
false,
editable,
payload ?? new CnamePayload(["x"], "y", null),
issues ?? [],
summary ?? rawValue);
private static EffectiveDnsmasqConfig MinimalConfig() =>
new(
NoHosts: false,
AddnHostsPaths: [],
HostsdirPath: null,
ServerValues: [],
LocalValues: [],
RevServerValues: [],
AddressValues: [],
Interfaces: [],
ListenAddresses: [],
ExceptInterfaces: [],
DhcpRanges: [],
DhcpHostLines: [],
DhcpOptionLines: [],
DhcpMatchValues: [],
DhcpBootValues: [],
DhcpIgnoreValues: [],
DhcpVendorclassValues: [],
DhcpUserclassValues: [],
RaParamValues: [],
SlaacValues: [],
PxeServiceValues: [],
TrustAnchorValues: [],
ResolvFiles: [],
RebindDomainOkValues: [],
BogusNxdomainValues: [],
IgnoreAddressValues: [],
AliasValues: [],
FilterRrValues: [],
CacheRrValues: [],
AuthServerValues: [],
NoDhcpInterfaceValues: [],
NoDhcpv4InterfaceValues: [],
NoDhcpv6InterfaceValues: [],
DomainValues: [],
CnameValues: [],
MxHostValues: [],
SrvValues: [],
PtrRecordValues: [],
TxtRecordValues: [],
NaptrRecordValues: [],
HostRecordValues: [],
DynamicHostValues: [],
InterfaceNameValues: [],
DhcpOptionForceLines: [],
IpsetValues: [],
NftsetValues: [],
DhcpMacValues: [],
DhcpNameMatchValues: [],
DhcpIgnoreNamesValues: [],
DhcpHostsfilePaths: [],
DhcpOptsfilePaths: [],
DhcpHostsdirPaths: [],
DhcpOptsdirPaths: [],
ConnmarkAllowlistValues: [],
CaaRecordValues: [],
DnsRrValues: [],
SynthDomainValues: [],
AuthZoneValues: [],
AuthSoaValues: [],
AuthSecServersValues: [],
AuthPeerValues: [],
DhcpRelayValues: [],
DhcpCircuitidValues: [],
DhcpRemoteidValues: [],
DhcpSubscridValues: [],
DhcpProxyValues: [],
TagIfValues: [],
BridgeInterfaceValues: [],
SharedNetworkValues: [],
DhcpOptionPxeValues: [],
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: [],
DhcpGenerateNames: null,
DhcpBroadcast: null,
DhcpSequentialIp: false,
DhcpIgnoreClid: false,
BootpDynamic: null,
NoPing: false,
ScriptArp: false,
ScriptOnRenewal: false,
DhcpNoOverride: false,
QuietDhcp: false,
QuietDhcp6: false,
QuietRa: false,
QuietTftp: false,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
LocalTtl: null,
PidFilePath: null,
User: null,
Group: null,
LogFacility: null,
LogQueries: null,
AuthTtl: null,
EdnsPacketMax: null,
QueryPort: null,
PortLimit: null,
MinPort: null,
MaxPort: null,
LogAsync: null,
LocalService: null,
DhcpLeaseMax: null,
NegTtl: null,
MaxTtl: null,
MaxCacheTtl: null,
MinCacheTtl: null,
DhcpTtl: null,
TftpRootPath: null,
PxePrompt: null,
EnableDbus: null,
EnableUbus: null,
FastDnsRetry: null,
DhcpScriptPath: null,
MxTarget: null,
DnsForwardMax: null,
DumpfilePath: null,
Dumpmask: null,
AddCpeId: null,
DnssecTimestamp: null,
DnssecLimits: null,
DhcpAlternatePort: null,
DhcpDuid: null,
DhcpLuascriptPath: null,
DhcpScriptuser: null,
DhcpPxeVendor: null,
UseStaleCache: null,
AddMac: null,
StripMac: false,
AddSubnet: null,
StripSubnet: false,
Umbrella: null,
Do0x20EncodeState: ExplicitToggleState.Default,
Conntrack: false);
private static EffectiveConfigSources MinimalSources() =>
new(
NoHosts: null,
AddnHostsPaths: [],
HostsdirPath: null,
ServerValues: [],
LocalValues: [],
RevServerValues: [],
AddressValues: [],
Interfaces: [],
ListenAddresses: [],
ExceptInterfaces: [],
DhcpRanges: [],
DhcpHostLines: [],
DhcpOptionLines: [],
DhcpMatchValues: [],
DhcpBootValues: [],
DhcpIgnoreValues: [],
DhcpVendorclassValues: [],
DhcpUserclassValues: [],
RaParamValues: [],
SlaacValues: [],
PxeServiceValues: [],
TrustAnchorValues: [],
ResolvFiles: [],
RebindDomainOkValues: [],
BogusNxdomainValues: [],
IgnoreAddressValues: [],
AliasValues: [],
FilterRrValues: [],
CacheRrValues: [],
AuthServerValues: [],
NoDhcpInterfaceValues: [],
NoDhcpv4InterfaceValues: [],
NoDhcpv6InterfaceValues: [],
DomainValues: [],
CnameValues: [],
MxHostValues: [],
SrvValues: [],
PtrRecordValues: [],
TxtRecordValues: [],
NaptrRecordValues: [],
HostRecordValues: [],
DynamicHostValues: [],
InterfaceNameValues: [],
DhcpOptionForceLines: [],
IpsetValues: [],
NftsetValues: [],
DhcpMacValues: [],
DhcpNameMatchValues: [],
DhcpIgnoreNamesValues: [],
DhcpHostsfilePaths: [],
DhcpOptsfilePaths: [],
DhcpHostsdirPaths: [],
DhcpOptsdirPaths: [],
ConnmarkAllowlistValues: [],
CaaRecordValues: [],
DnsRrValues: [],
SynthDomainValues: [],
AuthZoneValues: [],
AuthSoaValues: [],
AuthSecServersValues: [],
AuthPeerValues: [],
DhcpRelayValues: [],
DhcpCircuitidValues: [],
DhcpRemoteidValues: [],
DhcpSubscridValues: [],
DhcpProxyValues: [],
TagIfValues: [],
BridgeInterfaceValues: [],
SharedNetworkValues: [],
DhcpOptionPxeValues: [],
ExpandHosts: null,
BogusPriv: null,
StrictOrder: null,
AllServers: null,
NoResolv: null,
DomainNeeded: null,
NoPoll: null,
BindInterfaces: null,
BindDynamic: null,
NoNegcache: null,
DnsLoopDetect: null,
StopDnsRebind: null,
RebindLocalhostOk: null,
ClearOnReload: null,
Filterwin2k: null,
FilterA: null,
FilterAaaa: null,
LocaliseQueries: null,
LogDebug: null,
DhcpAuthoritative: null,
LeasefileRo: null,
EnableTftp: null,
TftpSecure: null,
TftpNoFail: null,
TftpNoBlocksize: null,
Dnssec: null,
DnssecCheckUnsigned: null,
ReadEthers: null,
DhcpRapidCommit: null,
Localmx: null,
Selfmx: null,
EnableRa: null,
LogDhcp: null,
KeepInForeground: null,
NoDaemon: null,
ProxyDnssec: null,
ConnmarkAllowlistEnable: null,
NoRoundRobin: null,
DnssecNoTimecheck: null,
DnssecDebug: null,
LeasequeryValues: [],
DhcpGenerateNames: null,
DhcpBroadcast: null,
DhcpSequentialIp: null,
DhcpIgnoreClid: null,
BootpDynamic: null,
NoPing: null,
ScriptArp: null,
ScriptOnRenewal: null,
DhcpNoOverride: null,
QuietDhcp: null,
QuietDhcp6: null,
QuietRa: null,
QuietTftp: null,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
LocalTtl: null,
PidFilePath: null,
User: null,
Group: null,
LogFacility: null,
LogQueries: null,
AuthTtl: null,
EdnsPacketMax: null,
QueryPort: null,
PortLimit: null,
MinPort: null,
MaxPort: null,
LogAsync: null,
LocalService: null,
DhcpLeaseMax: null,
NegTtl: null,
MaxTtl: null,
MaxCacheTtl: null,
MinCacheTtl: null,
DhcpTtl: null,
TftpRootPath: null,
PxePrompt: null,
EnableDbus: null,
EnableUbus: null,
FastDnsRetry: null,
DhcpScriptPath: null,
MxTarget: null,
DnsForwardMax: null,
DumpfilePath: null,
Dumpmask: null,
AddCpeId: null,
DnssecTimestamp: null,
DnssecLimits: null,
DhcpAlternatePort: null,
DhcpDuid: null,
DhcpLuascriptPath: null,
DhcpScriptuser: null,
DhcpPxeVendor: null,
UseStaleCache: null,
AddMac: null,
StripMac: null,
AddSubnet: null,
StripSubnet: null,
Umbrella: null,
Do0x20Encode: null,
Conntrack: null);
private static DnsmasqServiceStatus MinimalStatus() =>
new(
SystemHostsPath: null,
SystemHostsPathExists: false,
ManagedHostsFilePath: null,
ManagedHostsPathExists: false,
NoHosts: false,
AddnHostsPaths: [],
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: null,
MinimumSupportedDnsmasqVersion: "2.91",
DnsmasqVersionSupported: true,
DnsmasqVersionError: null,
DnsmasqSupportsDhcp: true,
DnsmasqSupportsTftp: true,
DnsmasqSupportsDnssec: true,
DnsmasqSupportsDbus: false);
private sealed class NoopOptionSemanticValidator : IOptionSemanticValidator
{
public string? ValidateSingle(string optionName, object? value, OptionValidationSemantics semantics) => null;
public string? ValidateMultiItem(string optionName, string? value, OptionValidationSemantics semantics) => null;
}
}
@@ -0,0 +1,462 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Rules;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
public class CrossOptionRulesTests
{
private static EffectiveConfigCrossOptionContext Ctx(EffectiveDnsmasqConfig cfg) =>
new(CrossOptionTestHelpers.Status(cfg), []);
[Fact]
public void NoResolvWithoutUpstreamsRule_Warns_when_no_server_and_no_resolv_file()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { NoResolv = true };
var rule = new NoResolvWithoutUpstreamsRule();
var issues = rule.Evaluate(Ctx(cfg));
var key = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server);
Assert.Single(issues);
Assert.Equal(key, issues[0].FieldKey);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
}
[Fact]
public void NoResolvWithoutUpstreamsRule_No_issue_when_resolv_file_configured()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
NoResolv = true,
ResolvFiles = ["/run/resolv.conf"]
};
var rule = new NoResolvWithoutUpstreamsRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void ConntrackWithQueryPortRule_Error_when_both_set()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Conntrack = true, QueryPort = 5353 };
var rule = new ConntrackWithQueryPortRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Error, issues[0].Severity);
Assert.Contains("query-port", issues[0].Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void RebindExceptionsRequireStopDnsRebindRule_Warns_on_localhost_ok_without_stop()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { RebindLocalhostOk = true };
var rule = new RebindExceptionsRequireStopDnsRebindRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
}
[Fact]
public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_private_in_addr_server()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
ServerValues = ["/0.168.192.in-addr.arpa/10.0.0.1"]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server),
issues[0].FieldKey);
}
[Theory]
[InlineData("/10.in-addr.arpa/8.8.8.8")]
[InlineData("/31.172.in-addr.arpa/8.8.8.8")]
public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_rfc1918_zone_roots(string serverValue)
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
ServerValues = [serverValue]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
Assert.Single(rule.Evaluate(Ctx(cfg)));
}
[Theory]
[InlineData("/210.in-addr.arpa/8.8.8.8")]
[InlineData("/110.in-addr.arpa/8.8.8.8")]
[InlineData("/1.0.0.192.in-addr.arpa/8.8.8.8")]
[InlineData("/15.172.in-addr.arpa/8.8.8.8")]
[InlineData("/32.172.in-addr.arpa/8.8.8.8")]
[InlineData("/10.0.0.192.in-addr.arpa/8.8.8.8")]
public void BogusPrivBlocksPrivateReverseServerRule_No_issue_for_non_private_in_addr_substrings(string serverValue)
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
ServerValues = [serverValue]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Theory]
[InlineData("192.168.1.0/24,8.8.8.8")]
[InlineData("10.0.0.0/8,8.8.8.8")]
[InlineData("172.16.0.0/12,8.8.8.8")]
public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_rfc1918_rev_server(string revServerValue)
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
RevServerValues = [revServerValue]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RevServer),
issues[0].FieldKey);
}
[Theory]
[InlineData("8.8.8.0/24,1.1.1.1")]
[InlineData("1.0.0.0/8,1.1.1.1")]
public void BogusPrivBlocksPrivateReverseServerRule_No_issue_for_public_rev_server(string revServerValue)
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
RevServerValues = [revServerValue]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void BogusPrivBlocksPrivateReverseServerRule_Emits_separate_issues_for_server_and_rev_server()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
ServerValues = ["/0.168.192.in-addr.arpa/10.0.0.1"],
RevServerValues = ["192.168.2.0/24,8.8.8.8"]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Equal(2, issues.Count);
Assert.Contains(
issues,
i => i.FieldKey == EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server));
Assert.Contains(
issues,
i => i.FieldKey == EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RevServer));
}
[Fact]
public void BogusPrivBlocksPrivateReverseServerRule_Pending_rev_server_overrides_disk()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
RevServerValues = ["8.8.8.0/24,1.1.1.1"]
};
var pending = new[]
{
new PendingOptionChange(
"resolver",
DnsmasqConfKeys.RevServer,
Array.Empty<string>(),
new[] { "192.168.1.0/24,8.8.8.8" },
null)
};
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
var rule = new BogusPrivBlocksPrivateReverseServerRule();
var issues = rule.Evaluate(ctx);
Assert.Single(issues);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RevServer),
issues[0].FieldKey);
}
[Fact]
public void NoPingWithDhcpSequentialIpRule_Warns_when_both_set()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
NoPing = true,
DhcpSequentialIp = true
};
var rule = new NoPingWithDhcpSequentialIpRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.DhcpSequentialIp),
issues[0].FieldKey);
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(false, false)]
public void NoPingWithDhcpSequentialIpRule_No_issue_unless_both(bool noPing, bool sequential)
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
NoPing = noPing,
DhcpSequentialIp = sequential
};
var rule = new NoPingWithDhcpSequentialIpRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void DnsPortZeroWithTftpOrPxeRule_Warns_when_port_zero_and_enable_tftp()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
Port = 0,
EnableTftp = true
};
var rule = new DnsPortZeroWithTftpOrPxeRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Port),
issues[0].FieldKey);
}
[Fact]
public void DnsPortZeroWithTftpOrPxeRule_Warns_when_port_zero_and_pxe_service()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
Port = 0,
PxeServiceValues = ["x86PC,Boot from network,pxelinux"]
};
var rule = new DnsPortZeroWithTftpOrPxeRule();
Assert.Single(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void DnsPortZeroWithTftpOrPxeRule_No_issue_when_port_nonzero_even_with_netboot()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
Port = 53,
EnableTftp = true,
PxeServiceValues = ["x86PC,Boot from network,pxelinux"]
};
var rule = new DnsPortZeroWithTftpOrPxeRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void DnsPortZeroWithTftpOrPxeRule_No_issue_when_port_zero_without_tftp_or_pxe()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Port = 0 };
var rule = new DnsPortZeroWithTftpOrPxeRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void DnssecPrerequisitesRule_Error_when_build_lacks_dnssec()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true };
var status = CrossOptionTestHelpers.Status(cfg, dnsmasqSupportsDnssec: false);
var ctx = new EffectiveConfigCrossOptionContext(status, []);
var rule = new DnssecPrerequisitesRule();
var issues = rule.Evaluate(ctx);
var dnssecKey = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Dnssec);
Assert.Contains(issues, i =>
i.Severity == FieldIssueSeverity.Error && i.FieldKey == dnssecKey);
}
[Fact]
public void DnssecPrerequisitesRule_Warns_when_no_trust_anchors()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true };
var rule = new DnssecPrerequisitesRule();
var issues = rule.Evaluate(Ctx(cfg));
var trustAnchorKey = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.TrustAnchor);
Assert.Contains(issues, i =>
i.Severity == FieldIssueSeverity.Warning &&
i.FieldKey == trustAnchorKey &&
i.Message.Contains("trust-anchor", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ProxyDnssecCacheWarningRule_Warns_when_cache_not_zero()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
ProxyDnssec = true,
CacheSize = 150
};
var rule = new ProxyDnssecCacheWarningRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.CacheSize),
issues[0].FieldKey);
}
[Fact]
public void ConnmarkAllowlistEnableRequiresAllowlistRule_Error_when_enable_key_only()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ConnmarkAllowlistEnable = "" };
var rule = new ConnmarkAllowlistEnableRequiresAllowlistRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Error, issues[0].Severity);
}
[Fact]
public void ConnmarkAllowlistEnableRequiresAllowlistRule_No_issue_when_allowlists_present()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
ConnmarkAllowlistEnable = "0xff",
ConnmarkAllowlistValues = ["0x1,*.example.com"]
};
var rule = new ConnmarkAllowlistEnableRequiresAllowlistRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void QueryPortIgnoredForSourceBoundServerRule_Warns_when_server_has_at()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
QueryPort = 12345,
ServerValues = ["10.0.0.1@192.168.1.1"]
};
var rule = new QueryPortIgnoredForSourceBoundServerRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
}
[Fact]
public void AddSubnetCacheBehaviorRule_Warns_when_add_subnet_set()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { AddSubnet = "24,96" };
var rule = new AddSubnetCacheBehaviorRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.AddSubnet),
issues[0].FieldKey);
}
[Fact]
public void Filterwin2kSrvWarningRule_Warns_when_enabled()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Filterwin2k = true };
var rule = new Filterwin2kSrvWarningRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Contains("SRV", issues[0].Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AddressLocalDnsmasq286CompatibilityRule_Warns_for_domain_address()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
AddressValues = ["/example.com/192.0.2.1"]
};
var rule = new AddressLocalDnsmasq286CompatibilityRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
}
[Fact]
public void AddressLocalDnsmasq286CompatibilityRule_No_issue_when_matching_local_exists()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
AddressValues = ["/example.com/192.0.2.1"],
LocalValues = ["/example.com/"]
};
var rule = new AddressLocalDnsmasq286CompatibilityRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void AddressLocalDnsmasq286CompatibilityRule_No_issue_for_nxdomain_form_without_ip()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
AddressValues = ["/ads.example/"]
};
var rule = new AddressLocalDnsmasq286CompatibilityRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void AddressLocalDnsmasq286CompatibilityRule_Warns_for_hash_target_without_local()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
AddressValues = ["/blocked.example/#"]
};
var rule = new AddressLocalDnsmasq286CompatibilityRule();
Assert.Single(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void DnssecCheckUnsignedRequiresDnssecRule_Warns_when_dnssec_check_unsigned_set_without_dnssec()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
Dnssec = false,
DnssecCheckUnsigned = ""
};
var rule = new DnssecCheckUnsignedRequiresDnssecRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.DnssecCheckUnsigned),
issues[0].FieldKey);
}
[Fact]
public void LocalServiceIgnoredByBindSettingsRule_Warns_when_local_service_with_interface_filters()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
LocalService = "net",
Interfaces = ["eth0"]
};
var rule = new LocalServiceIgnoredByBindSettingsRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.LocalService),
issues[0].FieldKey);
}
[Fact]
public void Pending_overlay_overrides_config_for_cross_option_rules()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { NoResolv = false };
var pending = new[]
{
new PendingOptionChange("resolver", DnsmasqConfKeys.NoResolv, false, true, null)
};
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
var rule = new NoResolvWithoutUpstreamsRule();
var issues = rule.Evaluate(ctx);
Assert.Single(issues);
}
}
@@ -0,0 +1,226 @@
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
internal static class CrossOptionTestHelpers
{
/// <summary>Minimal baseline effective config; override with record <c>with</c> in tests.</summary>
public static EffectiveDnsmasqConfig BaselineConfig() =>
new(
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
HostsdirPath: null,
ServerValues: Array.Empty<string>(),
LocalValues: Array.Empty<string>(),
RevServerValues: Array.Empty<string>(),
AddressValues: Array.Empty<string>(),
Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(),
ExceptInterfaces: Array.Empty<string>(),
DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(),
DhcpOptionLines: Array.Empty<string>(),
DhcpMatchValues: Array.Empty<string>(),
DhcpBootValues: Array.Empty<string>(),
DhcpIgnoreValues: Array.Empty<string>(),
DhcpVendorclassValues: Array.Empty<string>(),
DhcpUserclassValues: Array.Empty<string>(),
RaParamValues: Array.Empty<string>(),
SlaacValues: Array.Empty<string>(),
PxeServiceValues: Array.Empty<string>(),
TrustAnchorValues: Array.Empty<string>(),
ResolvFiles: Array.Empty<string>(),
RebindDomainOkValues: Array.Empty<string>(),
BogusNxdomainValues: Array.Empty<string>(),
IgnoreAddressValues: Array.Empty<string>(),
AliasValues: Array.Empty<string>(),
FilterRrValues: Array.Empty<string>(),
CacheRrValues: Array.Empty<string>(),
AuthServerValues: Array.Empty<string>(),
NoDhcpInterfaceValues: Array.Empty<string>(),
NoDhcpv4InterfaceValues: Array.Empty<string>(),
NoDhcpv6InterfaceValues: Array.Empty<string>(),
DomainValues: Array.Empty<string>(),
CnameValues: Array.Empty<string>(),
MxHostValues: Array.Empty<string>(),
SrvValues: Array.Empty<string>(),
PtrRecordValues: Array.Empty<string>(),
TxtRecordValues: Array.Empty<string>(),
NaptrRecordValues: Array.Empty<string>(),
HostRecordValues: Array.Empty<string>(),
DynamicHostValues: Array.Empty<string>(),
InterfaceNameValues: Array.Empty<string>(),
DhcpOptionForceLines: Array.Empty<string>(),
IpsetValues: Array.Empty<string>(),
NftsetValues: Array.Empty<string>(),
DhcpMacValues: Array.Empty<string>(),
DhcpNameMatchValues: Array.Empty<string>(),
DhcpIgnoreNamesValues: Array.Empty<string>(),
DhcpHostsfilePaths: Array.Empty<string>(),
DhcpOptsfilePaths: Array.Empty<string>(),
DhcpHostsdirPaths: Array.Empty<string>(),
DhcpOptsdirPaths: Array.Empty<string>(),
ConnmarkAllowlistValues: Array.Empty<string>(),
CaaRecordValues: Array.Empty<string>(),
DnsRrValues: Array.Empty<string>(),
SynthDomainValues: Array.Empty<string>(),
AuthZoneValues: Array.Empty<string>(),
AuthSoaValues: Array.Empty<string>(),
AuthSecServersValues: Array.Empty<string>(),
AuthPeerValues: Array.Empty<string>(),
DhcpRelayValues: Array.Empty<string>(),
DhcpCircuitidValues: Array.Empty<string>(),
DhcpRemoteidValues: Array.Empty<string>(),
DhcpSubscridValues: Array.Empty<string>(),
DhcpProxyValues: Array.Empty<string>(),
TagIfValues: Array.Empty<string>(),
BridgeInterfaceValues: Array.Empty<string>(),
SharedNetworkValues: Array.Empty<string>(),
DhcpOptionPxeValues: Array.Empty<string>(),
ExpandHosts: false,
BogusPriv: false,
StrictOrder: false,
AllServers: false,
NoResolv: false,
DomainNeeded: false,
NoPoll: false,
BindInterfaces: false,
BindDynamic: false,
NoNegcache: false,
DnsLoopDetect: false,
StopDnsRebind: false,
RebindLocalhostOk: false,
ClearOnReload: false,
Filterwin2k: false,
FilterA: false,
FilterAaaa: false,
LocaliseQueries: false,
LogDebug: false,
DhcpAuthoritative: false,
LeasefileRo: false,
EnableTftp: false,
TftpSecure: false,
TftpNoFail: false,
TftpNoBlocksize: false,
Dnssec: false,
DnssecCheckUnsigned: null,
ReadEthers: false,
DhcpRapidCommit: false,
Localmx: false,
Selfmx: false,
EnableRa: false,
LogDhcp: false,
KeepInForeground: false,
NoDaemon: false,
ProxyDnssec: false,
ConnmarkAllowlistEnable: null,
NoRoundRobin: false,
DnssecNoTimecheck: false,
DnssecDebug: false,
LeasequeryValues: Array.Empty<string>(),
DhcpGenerateNames: null,
DhcpBroadcast: null,
DhcpSequentialIp: false,
DhcpIgnoreClid: false,
BootpDynamic: null,
NoPing: false,
ScriptArp: false,
ScriptOnRenewal: false,
DhcpNoOverride: false,
QuietDhcp: false,
QuietDhcp6: false,
QuietRa: false,
QuietTftp: false,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
LocalTtl: null,
PidFilePath: null,
User: null,
Group: null,
LogFacility: null,
LogQueries: null,
AuthTtl: null,
EdnsPacketMax: null,
QueryPort: null,
PortLimit: null,
MinPort: null,
MaxPort: null,
LogAsync: null,
LocalService: null,
DhcpLeaseMax: null,
NegTtl: null,
MaxTtl: null,
MaxCacheTtl: null,
MinCacheTtl: null,
DhcpTtl: null,
TftpRootPath: null,
PxePrompt: null,
EnableDbus: null,
EnableUbus: null,
FastDnsRetry: null,
DhcpScriptPath: null,
MxTarget: null,
DnsForwardMax: null,
DumpfilePath: null,
Dumpmask: null,
AddCpeId: null,
DnssecTimestamp: null,
DnssecLimits: null,
DhcpAlternatePort: null,
DhcpDuid: null,
DhcpLuascriptPath: null,
DhcpScriptuser: null,
DhcpPxeVendor: null,
UseStaleCache: null,
AddMac: null,
StripMac: false,
AddSubnet: null,
StripSubnet: false,
Umbrella: null,
Do0x20EncodeState: ExplicitToggleState.Default,
Conntrack: false);
public static DnsmasqServiceStatus Status(EffectiveDnsmasqConfig config, bool dnsmasqSupportsDnssec = true) =>
new(
SystemHostsPath: null,
SystemHostsPathExists: false,
ManagedHostsFilePath: null,
ManagedHostsPathExists: false,
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
EffectiveConfig: config,
EffectiveConfigSources: null,
MainConfigPath: null,
ManagedFilePath: null,
LeasesPath: null,
MainConfigPathExists: false,
ManagedFilePathExists: false,
LeasesPathConfigured: false,
LeasesPathExists: false,
ConfigFiles: null,
ReloadCommandConfigured: false,
StatusCommandConfigured: false,
StatusShowConfigured: false,
LogsConfigured: false,
LogsPath: null,
StatusShowCommand: null,
LogsCommand: null,
DnsmasqStatus: "active",
StatusCommandExitCode: null,
StatusCommandStdout: null,
StatusCommandStderr: null,
StatusShowOutput: null,
LogsOutput: null,
DhcpRangeStart: null,
DhcpRangeEnd: null,
DnsmasqVersion: "2.91",
MinimumSupportedDnsmasqVersion: "2.91",
DnsmasqVersionSupported: true,
DnsmasqVersionError: null,
DnsmasqSupportsDhcp: true,
DnsmasqSupportsTftp: true,
DnsmasqSupportsDnssec: dnsmasqSupportsDnssec,
DnsmasqSupportsDbus: false);
}
@@ -0,0 +1,46 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
public class EffectiveConfigCrossOptionContextTests
{
[Fact]
public void GetMulti_WhenPendingNewValueIsNull_ReturnsEmpty_NotDiskValues()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["1.1.1.1", "8.8.8.8"] };
var pending = new[]
{
new PendingOptionChange("resolver", DnsmasqConfKeys.Server, new List<string> { "1.1.1.1" }, null, null)
};
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
Assert.Empty(ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues));
}
[Fact]
public void GetMulti_WhenPendingNewValueIsList_ReturnsPending_NotDisk()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["9.9.9.9"] };
var pending = new[]
{
new PendingOptionChange("resolver", DnsmasqConfKeys.Server, null, new List<string> { "1.1.1.1" }, null)
};
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
var v = ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues);
Assert.Single(v);
Assert.Equal("1.1.1.1", v[0]);
}
[Fact]
public void GetMulti_WhenPendingNewValueIsWrongType_ReturnsEmpty_NotDiskValues()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["1.1.1.1"] };
var pending = new[]
{
new PendingOptionChange("resolver", DnsmasqConfKeys.Server, null, "not-a-list", null)
};
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
Assert.Empty(ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues));
}
}
@@ -0,0 +1,30 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Rules;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
public class EffectiveConfigCrossOptionValidationServiceTests
{
[Fact]
public void Validate_aggregates_issues_from_all_rules()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
Conntrack = true,
QueryPort = 1,
Filterwin2k = true
};
var status = CrossOptionTestHelpers.Status(cfg);
IReadOnlyList<IEffectiveConfigCrossOptionRule> rules =
[
new ConntrackWithQueryPortRule(),
new Filterwin2kSrvWarningRule()
];
var service = new EffectiveConfigCrossOptionValidationService(rules);
var issues = service.Validate(status, []);
Assert.Equal(2, issues.Count);
}
}
@@ -0,0 +1,82 @@
using System.Linq;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
using Microsoft.Extensions.Logging.Abstractions;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
public class EffectiveConfigEditSessionChangedEventTests
{
[Fact]
public void TrackCommit_RaisesChanged()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
session.Ui.EnterEditMode();
var raised = false;
session.Changed += () => raised = true;
session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
Assert.True(raised);
}
[Fact]
public void EnterEditMode_RaisesChanged()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
var raised = false;
session.Changed += () => raised = true;
session.Ui.EnterEditMode();
Assert.True(raised);
}
[Fact]
public void RevertChange_RaisesChanged()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
session.Ui.EnterEditMode();
session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
var raised = false;
session.Changed += () => raised = true;
session.Draft.RevertChange("resolver", "port");
Assert.True(raised);
}
[Fact]
public void Ui_EnterEditMode_DoesNotClearDraft()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
var oldEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a" } } };
var newEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "b" } } };
session.Draft.SetManagedHostsDraft(oldEntries, newEntries, "/tmp/managed.hosts");
Assert.Single(session.Draft.PendingChanges);
session.Ui.EnterEditMode();
Assert.Single(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
}
[Fact]
public void SetManagedHostsDraft_StoresChange_AndRevertManagedHostsDraft_RemovesIt()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
session.Ui.EnterEditMode();
var oldEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a" } } };
var newEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a", "b" } } };
session.Draft.SetManagedHostsDraft(oldEntries, newEntries, "/etc/hosts");
Assert.Single(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
session.Draft.RevertManagedHostsDraft();
Assert.Empty(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
}
private sealed class StubSaveService : IEffectiveConfigSaveService
{
public Task<EffectiveConfigSaveResult> SaveAsync(IReadOnlyList<PendingDnsmasqChange> changes, CancellationToken ct = default) =>
Task.FromResult(EffectiveConfigSaveResult.NoChanges());
public Task<EffectiveConfigRestoreResult> RestoreAsync(IReadOnlyList<DnsmasqManagedBackup> backups, CancellationToken ct = default) =>
Task.FromResult(new EffectiveConfigRestoreResult(false, false, -1, null, "Stub"));
}
}
@@ -0,0 +1,140 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Tests for option -> feature mapping and capability checks.
/// </summary>
public class EffectiveConfigFeatureRequirementsTests
{
[Theory]
[InlineData(DnsmasqConfKeys.Dnssec, DnsmasqFeature.Dnssec)]
[InlineData(DnsmasqConfKeys.DnssecCheckUnsigned, DnsmasqFeature.Dnssec)]
[InlineData(DnsmasqConfKeys.TrustAnchor, DnsmasqFeature.Dnssec)]
[InlineData(DnsmasqConfKeys.DhcpRange, DnsmasqFeature.Dhcp)]
[InlineData(DnsmasqConfKeys.EnableTftp, DnsmasqFeature.Tftp)]
[InlineData(DnsmasqConfKeys.EnableDbus, DnsmasqFeature.Dbus)]
public void GetRequiredFeature_ReturnsExpectedFeature(string optionName, DnsmasqFeature expected)
{
var feature = EffectiveConfigFeatureRequirements.GetRequiredFeature(optionName);
Assert.NotNull(feature);
Assert.Equal(expected, feature.Value);
}
[Fact]
public void GetRequiredFeature_ForUnknownOption_ReturnsNull()
{
var feature = EffectiveConfigFeatureRequirements.GetRequiredFeature("no-such-option");
Assert.Null(feature);
}
[Fact]
public void IsSupportedByCapabilities_WhenCapabilityPresent_ReturnsTrue()
{
var caps = new DnsmasqCompileCapabilities(true, true, true, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP", "DNSSEC" });
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, caps));
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DhcpRange, caps));
}
[Fact]
public void IsSupportedByCapabilities_WhenDnssecMissing_ReturnsFalseForDnssecOptions()
{
var caps = new DnsmasqCompileCapabilities(true, true, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase));
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.Dnssec, caps));
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, caps));
}
[Fact]
public void IsSupportedByCapabilities_ForOptionWithNoRequiredFeature_ReturnsTrue()
{
var caps = new DnsmasqCompileCapabilities(false, false, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase));
Assert.True(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.Port, caps));
}
[Fact]
public void GetCapabilityDisabled_WhenStatusSupportsFeature_ReturnsNotDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: true);
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, status);
Assert.False(isDisabled);
Assert.Null(reason);
}
[Fact]
public void GetCapabilityDisabled_WhenStatusMissingDnssec_ReturnsDisabledWithReason()
{
var status = CreateStatusWithCapabilities(dnssec: false);
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, status);
Assert.True(isDisabled);
Assert.NotNull(reason);
}
[Fact]
public void GetCapabilityDisabled_WhenStatusNull_ReturnsNotDisabled()
{
var (isDisabled, _) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, null);
Assert.False(isDisabled);
}
/// <summary>
/// Save path uses IsSupportedByCapabilities(optionName, version.Capabilities) to reject changes before write.
/// This test asserts that logic: a build without DNSSEC would reject dnssec-check-unsigned.
/// </summary>
[Fact]
public void SaveGuard_WhenBuildHasNoDnssec_DnssecOptionChangesWouldBeRejected()
{
var noDnssecCaps = new DnsmasqCompileCapabilities(true, true, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP" });
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, noDnssecCaps));
Assert.NotNull(EffectiveConfigFeatureRequirements.GetUnsupportedReason(DnsmasqFeature.Dnssec));
}
private static DnsmasqServiceStatus CreateStatusWithCapabilities(bool dhcp = true, bool tftp = true, bool dnssec = true, bool dbus = false)
{
return new DnsmasqServiceStatus(
SystemHostsPath: null,
SystemHostsPathExists: false,
ManagedHostsFilePath: null,
ManagedHostsPathExists: false,
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
EffectiveConfig: null,
EffectiveConfigSources: null,
MainConfigPath: null,
ManagedFilePath: null,
LeasesPath: null,
MainConfigPathExists: false,
ManagedFilePathExists: false,
LeasesPathConfigured: false,
LeasesPathExists: false,
ConfigFiles: null,
ReloadCommandConfigured: false,
StatusCommandConfigured: false,
StatusShowConfigured: false,
LogsConfigured: false,
LogsPath: null,
StatusShowCommand: null,
LogsCommand: null,
DnsmasqStatus: "active",
StatusCommandExitCode: null,
StatusCommandStdout: null,
StatusCommandStderr: null,
StatusShowOutput: null,
LogsOutput: null,
DhcpRangeStart: null,
DhcpRangeEnd: null,
DnsmasqVersion: "2.91",
MinimumSupportedDnsmasqVersion: "2.91",
DnsmasqVersionSupported: true,
DnsmasqVersionError: null,
DnsmasqSupportsDhcp: dhcp,
DnsmasqSupportsTftp: tftp,
DnsmasqSupportsDnssec: dnssec,
DnsmasqSupportsDbus: dbus);
}
}
@@ -0,0 +1,103 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Tests that field descriptors get IsCapabilityDisabled set when the status indicates the option is not supported by the dnsmasq build.
/// </summary>
public class EffectiveConfigFieldBuilderCapabilityTests
{
[Fact]
public void GetSingleDescriptor_WhenDnssecOptionAndStatusHasNoDnssec_ReturnsDescriptorWithCapabilityDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: false);
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
null,
DnsmasqConfKeys.DnssecCheckUnsigned,
status,
_ => null,
_ => null,
null);
Assert.True(descriptor.IsCapabilityDisabled);
Assert.NotNull(descriptor.CapabilityDisabledReason);
}
[Fact]
public void GetSingleDescriptor_WhenDnssecOptionAndStatusHasDnssec_ReturnsDescriptorWithCapabilityNotDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: true);
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
null,
DnsmasqConfKeys.DnssecCheckUnsigned,
status,
_ => null,
_ => null,
null);
Assert.False(descriptor.IsCapabilityDisabled);
Assert.Null(descriptor.CapabilityDisabledReason);
}
[Fact]
public void GetSingleDescriptor_WhenOptionWithNoFeatureRequirement_ReturnsDescriptorWithCapabilityNotDisabled()
{
var status = CreateStatusWithCapabilities(dnssec: false);
var descriptor = EffectiveConfigFieldBuilder.GetSingleDescriptor(
null,
DnsmasqConfKeys.Port,
status,
_ => null,
_ => null,
null);
Assert.False(descriptor.IsCapabilityDisabled);
Assert.Null(descriptor.CapabilityDisabledReason);
}
private static DnsmasqServiceStatus CreateStatusWithCapabilities(bool dhcp = true, bool tftp = true, bool dnssec = true, bool dbus = false)
{
return new DnsmasqServiceStatus(
SystemHostsPath: null,
SystemHostsPathExists: false,
ManagedHostsFilePath: null,
ManagedHostsPathExists: false,
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
EffectiveConfig: null,
EffectiveConfigSources: null,
MainConfigPath: null,
ManagedFilePath: null,
LeasesPath: null,
MainConfigPathExists: false,
ManagedFilePathExists: false,
LeasesPathConfigured: false,
LeasesPathExists: false,
ConfigFiles: null,
ReloadCommandConfigured: false,
StatusCommandConfigured: false,
StatusShowConfigured: false,
LogsConfigured: false,
LogsPath: null,
StatusShowCommand: null,
LogsCommand: null,
DnsmasqStatus: "active",
StatusCommandExitCode: null,
StatusCommandStdout: null,
StatusCommandStderr: null,
StatusShowOutput: null,
LogsOutput: null,
DhcpRangeStart: null,
DhcpRangeEnd: null,
DnsmasqVersion: "2.91",
MinimumSupportedDnsmasqVersion: "2.91",
DnsmasqVersionSupported: true,
DnsmasqVersionError: null,
DnsmasqSupportsDhcp: dhcp,
DnsmasqSupportsTftp: tftp,
DnsmasqSupportsDnssec: dnssec,
DnsmasqSupportsDbus: dbus);
}
}
@@ -0,0 +1,45 @@
using DnsmasqWebUI.Components.EffectiveConfig;
using DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
public class EffectiveConfigRenderFragmentRegistryMultiTests
{
[Fact]
public void BuildMultiFieldComponentFragment_Domain_ReturnsFragment()
{
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DomainSemanticHandler()]));
var fragment = registry.BuildMultiFieldComponentFragment(
EffectiveConfigSections.SectionHosts,
DnsmasqConfKeys.Domain,
EventCallback<IReadOnlyList<string>>.Empty);
Assert.NotNull(fragment);
}
[Fact]
public void DomainMultiValueDisplay_InheritsMultiBase()
{
Assert.Equal(typeof(EffectiveConfigMultiValueDisplayBase), typeof(DomainMultiValueDisplay).BaseType);
}
/// <summary>Custom domain UI must still use RegisterSemanticMultiDescriptor wiring so per-item validation is not dropped.</summary>
[Fact]
public void GetMultiDescriptorFactory_Domain_WiresSemanticValidator()
{
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DomainSemanticHandler()]));
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.Domain);
Assert.NotNull(factory);
var descriptor = factory!(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.Domain, status: null, getItems: _ => null);
Assert.NotNull(descriptor.Validator);
Assert.Null(descriptor.Validator!.ValidateItem("example.com", Array.Empty<string>(), null));
Assert.NotNull(descriptor.Validator.ValidateItem("not a valid domain name!", Array.Empty<string>(), null));
}
}
@@ -0,0 +1,119 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Save path capability guard: unsupported-option changes return UnsupportedCapabilities
/// before validation or reload can run.
/// </summary>
public class EffectiveConfigSaveServiceCapabilityTests
{
[Fact]
public async Task SaveAsync_WhenCapabilitiesMissingDnssec_ReturnsUnsupportedCapabilitiesErrorCode()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cap-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var versionService = new StubVersionService(CreateSupportedVersionInfo(dnssec: false));
var semanticValidationService = new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>()));
var saveService = new EffectiveConfigSaveService(
setService,
configService,
cache,
new StubHostsFileService(),
new UnexpectedValidationService(),
semanticValidationService,
new UnexpectedReloadService(),
versionService,
NullLogger<EffectiveConfigSaveService>.Instance);
var changes = new List<PendingDnsmasqChange>
{
new PendingOptionChange(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null)
};
var result = await saveService.SaveAsync(changes);
Assert.Equal(EffectiveConfigSaveResult.ErrorCodes.UnsupportedCapabilities, result.ErrorCode);
Assert.False(File.Exists(Path.Combine(dir, managedName)));
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
private sealed class StubVersionService : IDnsmasqVersionService
{
private readonly DnsmasqVersionInfo _info;
public StubVersionService(DnsmasqVersionInfo info) => _info = info;
public Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default) =>
Task.FromResult(_info);
}
private static DnsmasqVersionInfo CreateSupportedVersionInfo(bool dnssec)
{
var capabilities = new DnsmasqCompileCapabilities(
Dhcp: true,
Tftp: true,
Dnssec: dnssec,
Dbus: false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP" });
return new DnsmasqVersionInfo(
new Version(2, 91),
new Version(2, 91),
ProbeSucceeded: true,
IsSupported: true,
"dnsmasq --version",
null,
capabilities);
}
private sealed class UnexpectedValidationService : IConfigValidationService
{
public Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default) =>
throw new InvalidOperationException("ValidateAsync should not be called when capabilities are unsupported.");
}
private sealed class UnexpectedReloadService : IReloadService
{
public Task<ReloadResult> ReloadAsync(CancellationToken ct = default) =>
throw new InvalidOperationException("ReloadAsync should not be called when capabilities are unsupported.");
}
private sealed class StubHostsFileService : IHostsFileService
{
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) => Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
}
}
@@ -0,0 +1,216 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Dnsmasq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Tests for multi-file backup/restore and managed-hosts-only save.
/// </summary>
public class EffectiveConfigSaveServiceMultiBackupTests
{
[Fact]
public async Task SaveAsync_WhenOnlyManagedHostsChange_BacksUpManagedHostsFile_WritesHosts_ValidatesAndReloads()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hosts-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions
{
MainConfigPath = mainPath,
ManagedFileName = "zz-managed.conf",
ManagedHostsFileName = "zz-dnsmasq-webui.hosts"
});
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var set = await setService.GetConfigSetAsync();
var managedHostsPath = set.ManagedHostsFilePath!;
File.WriteAllText(managedHostsPath, "192.168.1.1 host1\n");
var writtenEntries = new List<IReadOnlyList<HostEntry>>();
var capturingHostsService = new CapturingHostsFileService(writtenEntries);
var versionService = new StubVersionService(CreateSupportedVersionInfo());
var semanticValidationService = new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>()));
var saveService = new EffectiveConfigSaveService(
setService,
configService,
cache,
capturingHostsService,
new StubValidationService(success: true),
semanticValidationService,
new StubReloadService(success: true),
versionService,
NullLogger<EffectiveConfigSaveService>.Instance);
var oldEntries = new List<HostEntry> { new() { Address = "192.168.1.1", Names = new List<string> { "host1" }, Id = "192.168.1.1|host1" } };
var newEntries = new List<HostEntry>
{
new() { Address = "192.168.1.1", Names = new List<string> { "host1" }, Id = "192.168.1.1|host1" },
new() { Address = "192.168.1.2", Names = new List<string> { "host2" }, Id = "192.168.1.2|host2" }
};
var changes = new List<PendingDnsmasqChange>
{
new PendingManagedHostsChange(oldEntries, newEntries, managedHostsPath)
};
var result = await saveService.SaveAsync(changes);
Assert.True(result.Saved);
Assert.True(result.Validated);
Assert.True(result.Restarted);
Assert.Null(result.ErrorCode);
Assert.Single(result.Backups);
Assert.Equal(DnsmasqManagedTargetKind.ManagedHosts, result.Backups[0].Kind);
Assert.Equal(managedHostsPath, result.Backups[0].TargetPath);
Assert.True(File.Exists(result.Backups[0].BackupPath));
Assert.Single(writtenEntries);
Assert.Equal(2, writtenEntries[0].Count);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task RestoreAsync_WhenBackupsContainTwoTargets_RestoresBothThenReloads()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-restore-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(mainPath, "port=53\n");
var target1 = Path.Combine(dir, "target1.conf");
var target2 = Path.Combine(dir, "target2.hosts");
var backup1 = Path.Combine(dir, "backup1.bak");
var backup2 = Path.Combine(dir, "backup2.bak");
File.WriteAllText(target1, "original1");
File.WriteAllText(target2, "original2");
File.WriteAllText(backup1, "restored1");
File.WriteAllText(backup2, "restored2");
var backups = new List<DnsmasqManagedBackup>
{
new(DnsmasqManagedTargetKind.ManagedConfig, target1, backup1),
new(DnsmasqManagedTargetKind.ManagedHosts, target2, backup2)
};
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "x.conf" });
var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
try
{
var saveService = new EffectiveConfigSaveService(
setService,
new StubConfigService(),
cache,
new StubHostsFileService(),
new StubValidationService(success: true),
new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>())),
new StubReloadService(success: true),
new StubVersionService(CreateSupportedVersionInfo()),
NullLogger<EffectiveConfigSaveService>.Instance);
var result = await saveService.RestoreAsync(backups);
Assert.True(result.Restored);
Assert.True(result.Restarted);
Assert.Equal("restored1", File.ReadAllText(target1));
Assert.Equal("restored2", File.ReadAllText(target2));
}
finally
{
cache.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
private static DnsmasqVersionInfo CreateSupportedVersionInfo()
{
var capabilities = new DnsmasqCompileCapabilities(
Dhcp: true, Tftp: true, Dnssec: true, Dbus: false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP", "DNSSEC" });
return new DnsmasqVersionInfo(
new Version(2, 91), new Version(2, 91),
ProbeSucceeded: true, IsSupported: true, "dnsmasq --version", null, capabilities);
}
private sealed class CapturingHostsFileService : IHostsFileService
{
private readonly List<IReadOnlyList<HostEntry>> _written;
public CapturingHostsFileService(List<IReadOnlyList<HostEntry>> written) => _written = written;
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
{
_written.Add(entries);
return Task.CompletedTask;
}
}
private sealed class StubVersionService : IDnsmasqVersionService
{
private readonly DnsmasqVersionInfo _info;
public StubVersionService(DnsmasqVersionInfo info) => _info = info;
public Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default) => Task.FromResult(_info);
}
private sealed class StubValidationService : IConfigValidationService
{
private readonly bool _success;
public StubValidationService(bool success) => _success = success;
public Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default) =>
Task.FromResult(new ConfigValidationResult(_success, true, _success ? 0 : 1, null, null, null));
}
private sealed class StubReloadService : IReloadService
{
private readonly bool _success;
public StubReloadService(bool success) => _success = success;
public Task<ReloadResult> ReloadAsync(CancellationToken ct = default) =>
Task.FromResult(new ReloadResult(_success, _success ? 0 : 1, null, null));
}
private sealed class StubHostsFileService : IHostsFileService
{
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
}
private sealed class StubConfigService : IDnsmasqConfigService
{
public Task ApplyEffectiveConfigChangesAsync(IReadOnlyList<PendingEffectiveConfigChange> changes, CancellationToken ct = default) => Task.CompletedTask;
public Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default) => Task.FromResult<IReadOnlyList<DhcpHostEntry>>(Array.Empty<DhcpHostEntry>());
public Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
public Task<ManagedConfigContent> ReadManagedConfigAsync(CancellationToken ct = default) => Task.FromResult(new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), ""));
public Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default) => Task.CompletedTask;
}
}
@@ -0,0 +1,60 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Wiring tests that ensure special-option semantics actually feed parser and registry behavior.
/// </summary>
public class EffectiveConfigSemanticsWiringTests
{
[Fact]
public void Registry_WiresSemanticValidator_ForDnsRr()
{
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DnsRrSemanticHandler()]));
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.DnsRr);
Assert.NotNull(factory);
var descriptor = factory!(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.DnsRr, status: null, getItems: _ => null);
Assert.NotNull(descriptor.Validator);
Assert.Null(descriptor.Validator!.ValidateItem("example.com,16,01:02", Array.Empty<string>()));
Assert.NotNull(descriptor.Validator.ValidateItem("example.com,not-a-number", Array.Empty<string>()));
}
[Fact]
public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics()
{
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new LeasequerySemanticHandler()]));
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery);
Assert.NotNull(factory);
var descriptor = factory!(
EffectiveConfigSections.SectionDhcp,
DnsmasqConfKeys.Leasequery,
status: null,
getItems: _ => null);
Assert.NotNull(descriptor.Validator);
Assert.NotNull(descriptor.Validator!.ValidateItem("not-an-ip", Array.Empty<string>()));
Assert.Null(descriptor.Validator.ValidateItem("10.0.0.0/24", Array.Empty<string>()));
}
/// <summary>Pre-save semantic blocking: invalid leasequery value produces errors; SaveService uses this and returns SemanticValidationFailed before write.</summary>
[Fact]
public void SemanticValidationService_InvalidLeasequery_ReturnsError()
{
var validator = new OptionSemanticValidator([new LeasequerySemanticHandler()]);
var service = new EffectiveConfigSemanticValidationService(validator);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery, null,
new List<string> { "not-an-ip" }, null)
};
var issues = service.Validate(changes);
Assert.NotEmpty(issues);
Assert.Contains(issues, i => i.Severity == FieldIssueSeverity.Error);
}
}
@@ -0,0 +1,247 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Tests for EffectiveDnsmasqConfig, including GetLogsPath (log-facility as file path).
/// </summary>
public class EffectiveDnsmasqConfigTests
{
private static EffectiveDnsmasqConfig CreateConfig(string? logFacility = null) =>
new(
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
HostsdirPath: null,
ServerValues: Array.Empty<string>(),
LocalValues: Array.Empty<string>(),
RevServerValues: Array.Empty<string>(),
AddressValues: Array.Empty<string>(),
Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(),
ExceptInterfaces: Array.Empty<string>(),
DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(),
DhcpOptionLines: Array.Empty<string>(),
DhcpMatchValues: Array.Empty<string>(),
DhcpBootValues: Array.Empty<string>(),
DhcpIgnoreValues: Array.Empty<string>(),
DhcpVendorclassValues: Array.Empty<string>(),
DhcpUserclassValues: Array.Empty<string>(),
RaParamValues: Array.Empty<string>(),
SlaacValues: Array.Empty<string>(),
PxeServiceValues: Array.Empty<string>(),
TrustAnchorValues: Array.Empty<string>(),
ResolvFiles: Array.Empty<string>(),
RebindDomainOkValues: Array.Empty<string>(),
BogusNxdomainValues: Array.Empty<string>(),
IgnoreAddressValues: Array.Empty<string>(),
AliasValues: Array.Empty<string>(),
FilterRrValues: Array.Empty<string>(),
CacheRrValues: Array.Empty<string>(),
AuthServerValues: Array.Empty<string>(),
NoDhcpInterfaceValues: Array.Empty<string>(),
NoDhcpv4InterfaceValues: Array.Empty<string>(),
NoDhcpv6InterfaceValues: Array.Empty<string>(),
DomainValues: Array.Empty<string>(), CnameValues: Array.Empty<string>(), MxHostValues: Array.Empty<string>(), SrvValues: Array.Empty<string>(),
PtrRecordValues: Array.Empty<string>(), TxtRecordValues: Array.Empty<string>(), NaptrRecordValues: Array.Empty<string>(),
HostRecordValues: Array.Empty<string>(), DynamicHostValues: Array.Empty<string>(), InterfaceNameValues: Array.Empty<string>(),
DhcpOptionForceLines: Array.Empty<string>(),
IpsetValues: Array.Empty<string>(),
NftsetValues: Array.Empty<string>(),
DhcpMacValues: Array.Empty<string>(),
DhcpNameMatchValues: Array.Empty<string>(),
DhcpIgnoreNamesValues: Array.Empty<string>(),
DhcpHostsfilePaths: Array.Empty<string>(),
DhcpOptsfilePaths: Array.Empty<string>(),
DhcpHostsdirPaths: Array.Empty<string>(),
DhcpOptsdirPaths: Array.Empty<string>(),
ConnmarkAllowlistValues: Array.Empty<string>(),
CaaRecordValues: Array.Empty<string>(),
DnsRrValues: Array.Empty<string>(),
SynthDomainValues: Array.Empty<string>(),
AuthZoneValues: Array.Empty<string>(),
AuthSoaValues: Array.Empty<string>(),
AuthSecServersValues: Array.Empty<string>(),
AuthPeerValues: Array.Empty<string>(),
DhcpRelayValues: Array.Empty<string>(),
DhcpCircuitidValues: Array.Empty<string>(),
DhcpRemoteidValues: Array.Empty<string>(),
DhcpSubscridValues: Array.Empty<string>(),
DhcpProxyValues: Array.Empty<string>(),
TagIfValues: Array.Empty<string>(),
BridgeInterfaceValues: Array.Empty<string>(),
SharedNetworkValues: Array.Empty<string>(),
DhcpOptionPxeValues: Array.Empty<string>(),
ExpandHosts: false,
BogusPriv: false,
StrictOrder: false,
AllServers: false,
NoResolv: false,
DomainNeeded: false,
NoPoll: false,
BindInterfaces: false,
BindDynamic: false,
NoNegcache: false,
DnsLoopDetect: false,
StopDnsRebind: false,
RebindLocalhostOk: false,
ClearOnReload: false,
Filterwin2k: false,
FilterA: false,
FilterAaaa: false,
LocaliseQueries: false,
LogDebug: false,
DhcpAuthoritative: false,
LeasefileRo: false,
EnableTftp: false,
TftpSecure: false,
TftpNoFail: false,
TftpNoBlocksize: false,
Dnssec: false,
DnssecCheckUnsigned: null,
ReadEthers: false,
DhcpRapidCommit: false,
Localmx: false,
Selfmx: false,
EnableRa: false,
LogDhcp: false,
KeepInForeground: false,
NoDaemon: false,
ProxyDnssec: false,
ConnmarkAllowlistEnable: null,
NoRoundRobin: false,
DnssecNoTimecheck: false,
DnssecDebug: false,
LeasequeryValues: Array.Empty<string>(),
DhcpGenerateNames: null,
DhcpBroadcast: null,
DhcpSequentialIp: false,
DhcpIgnoreClid: false,
BootpDynamic: null,
NoPing: false,
ScriptArp: false,
ScriptOnRenewal: false,
DhcpNoOverride: false,
QuietDhcp: false,
QuietDhcp6: false,
QuietRa: false,
QuietTftp: false,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
LocalTtl: null,
PidFilePath: null,
User: null,
Group: null,
LogFacility: 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,
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]
public void GetLogsPath_NullConfig_ReturnsNull()
{
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(null));
}
[Fact]
public void GetLogsPath_NullLogFacility_ReturnsNull()
{
var config = CreateConfig(logFacility: null);
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_EmptyLogFacility_ReturnsNull()
{
var config = CreateConfig(logFacility: "");
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_WhitespaceLogFacility_ReturnsNull()
{
var config = CreateConfig(logFacility: " ");
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_AbsolutePath_ReturnsPath()
{
var path = "/data/dnsmasq.log";
var config = CreateConfig(logFacility: path);
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_AbsolutePathWithTrim_ReturnsPath()
{
var path = "/var/log/dnsmasq.log";
var pathWithSpaces = $" {path} ";
var config = CreateConfig(logFacility: pathWithSpaces);
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_RelativePathWithSlash_ReturnsPath()
{
var path = "logs/dnsmasq.log";
var config = CreateConfig(logFacility: path);
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_SyslogFacilityName_ReturnsNull()
{
var facility = "local0";
var config = CreateConfig(logFacility: facility);
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
}
[Fact]
public void GetLogsPath_Stderr_ReturnsNull()
{
var facility = "-";
var config = CreateConfig(logFacility: facility);
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
}
}
@@ -0,0 +1,39 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
public class EffectiveMultiValueProjectionServiceTests
{
private readonly EffectiveMultiValueProjectionService _sut = new();
[Fact]
public void Project_ReusesBaselineSources_ByOccurrenceOrder()
{
var baseline = new[]
{
new ValueWithSource("same", new ConfigValueSource("/a.conf", "a.conf", true, 10)),
new ValueWithSource("same", new ConfigValueSource("/b.conf", "b.conf", false, 20))
};
var projected = _sut.Project(["same", "same"], baseline, "/managed.conf");
Assert.Equal("/a.conf", projected[0].Source?.FilePath);
Assert.Equal("/b.conf", projected[1].Source?.FilePath);
Assert.False(projected[0].IsDraftOnly);
Assert.False(projected[1].IsDraftOnly);
}
[Fact]
public void Project_UnmatchedRows_AreDraftEditableAndPointAtManagedPath()
{
var projected = _sut.Project(["draft-value"], [], "/managed.conf");
var row = Assert.Single(projected);
Assert.True(row.IsDraftOnly);
Assert.True(row.IsEditable);
Assert.Null(row.Source);
Assert.Equal("/managed.conf", row.DisplaySourcePath);
Assert.Equal("managed.conf", row.DisplaySourceLabel);
}
}
@@ -0,0 +1,50 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>Corpus-driven semantic validation: bad samples produce errors for expected option keys only.</summary>
public class OptionSemanticValidatorRealWorldCorpusTests
{
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
new DomainSemanticHandler(),
new DnsRrSemanticHandler(),
new SynthDomainSemanticHandler(),
new AuthServerSemanticHandler(),
new SrvSemanticHandler(),
]);
public static IEnumerable<object[]> GetCasesWithInvalidOptions()
{
foreach (var c in RealWorldCasesHelper.LoadCases())
{
if (c.SemanticInvalidOptions.Count > 0)
yield return new object[] { c };
}
}
[Theory]
[MemberData(nameof(GetCasesWithInvalidOptions))]
public void SemanticInvalidOptions_FromCorpus_AreRejected(CorpusCase c)
{
var mainPath = RealWorldCasesHelper.Resolve(c);
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
foreach (var option in c.SemanticInvalidOptions)
{
var values = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, option);
Assert.NotEmpty(values);
var anyInvalid = values.Any(v =>
_validator.ValidateMultiItem(option, v, semantics) is not null);
Assert.True(anyInvalid, $"Expected at least one invalid value for option '{option}' in {c.File}");
}
}
}
@@ -0,0 +1,119 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Ensures every specialized option (one with a semantic handler) has at least one valid and one invalid
/// test case; asserts only Null/NotNull, not error message text.
/// </summary>
public class OptionSemanticValidatorSpecializedTests
{
private static readonly OptionValidationSemantics ComplexAllowEmpty = new(OptionValidationKind.Complex, allowEmpty: true);
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
new LeasequerySemanticHandler(),
new ServerSemanticHandler(),
new LocalSemanticHandler(),
new RevServerSemanticHandler(),
new AddressSemanticHandler(),
new TrustAnchorSemanticHandler(),
new AliasSemanticHandler(),
new IpsetSemanticHandler(),
new NftsetSemanticHandler(),
new IgnoreAddressSemanticHandler(),
new ConnmarkAllowlistSemanticHandler(),
new DhcpRangeSemanticHandler(),
new DhcpHostSemanticHandler(),
new DhcpOptionSemanticHandler(),
new DhcpMatchSemanticHandler(),
new DhcpMacSemanticHandler(),
new DhcpRelaySemanticHandler(),
new DhcpProxySemanticHandler(),
new RaParamSemanticHandler(),
new DhcpNameMatchSemanticHandler(),
new DhcpIgnoreSemanticHandler(),
new DhcpVendorclassSemanticHandler(),
new DhcpUserclassSemanticHandler(),
new TagIfSemanticHandler(),
new BridgeInterfaceSemanticHandler(),
new SharedNetworkSemanticHandler(),
new DhcpOptionPxeSemanticHandler(),
new RebindDomainOkSemanticHandler(),
new BogusNxdomainSemanticHandler(),
new DhcpIgnoreNamesSemanticHandler(),
new DhcpBootSemanticHandler(),
new SlaacSemanticHandler(),
new PxeServiceSemanticHandler(),
new DhcpCircuitidSemanticHandler(),
new DhcpRemoteidSemanticHandler(),
new DhcpSubscrIdSemanticHandler(),
new FilterRrSemanticHandler(),
new CacheRrSemanticHandler(),
new InterfaceNameSemanticHandler(),
new AuthServerSemanticHandler(),
new CnameSemanticHandler(),
new MxHostSemanticHandler(),
new PtrRecordSemanticHandler(),
new InterfaceNameRecordSemanticHandler(),
new CaaRecordSemanticHandler(),
new SrvSemanticHandler(),
new NaptrRecordSemanticHandler(),
new DnsRrSemanticHandler(),
new DynamicHostSemanticHandler(),
new AuthSoaSemanticHandler(),
new AuthSecServersSemanticHandler(),
new AuthPeerSemanticHandler(),
new HostRecordSemanticHandler(),
new TxtRecordSemanticHandler(),
new DomainSemanticHandler(),
new SynthDomainSemanticHandler(),
new AuthZoneSemanticHandler(),
]);
/// <summary>Option key, one valid value (validator returns null), one invalid value (validator returns non-null).</summary>
public static IEnumerable<object[]> GetSpecializedValidInvalidPairs()
{
yield return new object[] { DnsmasqConfKeys.Leasequery, "10.0.0.0/24", "not-an-ip" };
yield return new object[] { DnsmasqConfKeys.Server, "1.1.1.1", "" };
yield return new object[] { DnsmasqConfKeys.Local, "/lan/", "" };
yield return new object[] { DnsmasqConfKeys.Address, "/example.com/127.0.0.1", "/bad..name/1.2.3.4" };
yield return new object[] { DnsmasqConfKeys.Domain, "home.lan,192.168.1.0/24", "example.com,192.168.1.0/24,local,extra" };
yield return new object[] { DnsmasqConfKeys.AuthServer, "zone.example.com,eth0", ",eth0" };
yield return new object[] { DnsmasqConfKeys.SynthDomain, "dynamic.example.com,192.168.2.1,192.168.2.100", "example.com," };
yield return new object[] { DnsmasqConfKeys.DnsRr, "example.com,16,01:02", "example.com,not-a-number" };
yield return new object[] { DnsmasqConfKeys.Srv, "_http._tcp.example.com,host.example.com,80", "_sip._tcp,target,99999" };
yield return new object[] { DnsmasqConfKeys.NaptrRecord, "example.com,0,0,a,s,r", "example.com,not-num,0,a,s,r" };
yield return new object[] { DnsmasqConfKeys.TxtRecord, "example.com,text", "" };
yield return new object[] { DnsmasqConfKeys.Cname, "www.example.com,example.com", "bad..name,example.com" };
yield return new object[] { DnsmasqConfKeys.MxHost, "example.com,mail.example.com,10", "bad..name,mail.example.com,10" };
yield return new object[] { DnsmasqConfKeys.PtrRecord, "1.168.192.in-addr.arpa,router.lan", "bad..arpa,host" };
yield return new object[] { DnsmasqConfKeys.RebindDomainOk, "example.com", "bad..name" };
yield return new object[] { DnsmasqConfKeys.BogusNxdomain, "192.168.1.1", "not-an-ip" };
yield return new object[] { DnsmasqConfKeys.ConnmarkAllowlist, "0xff,example.com", "not-a-mark,example.com" };
yield return new object[] { DnsmasqConfKeys.DhcpRange, "192.168.1.50,192.168.1.150,12h", "not-an-ip,192.168.1.150" };
yield return new object[] { DnsmasqConfKeys.DhcpHost, "aa:bb:cc:dd:ee:ff,192.168.1.10", "ignore" };
yield return new object[] { DnsmasqConfKeys.DhcpOption, "3,192.168.1.1", "option:," };
yield return new object[] { DnsmasqConfKeys.DhcpMatch, "set:efi,option:client-arch,6", "option:client-arch,6" };
yield return new object[] { DnsmasqConfKeys.DhcpMac, "set:vendor,aa:bb:cc:dd:ee:ff", "aa:bb:cc:dd:ee:ff" };
yield return new object[] { DnsmasqConfKeys.DhcpNameMatch, "set:tag,hostname*", "hostname*" };
yield return new object[] { DnsmasqConfKeys.DhcpIgnoreNames, "tag:guest", "guest" };
yield return new object[] { DnsmasqConfKeys.AuthZone, "example.com", "bad..name" };
yield return new object[] { DnsmasqConfKeys.FilterRr, "A,AAAA", "" };
yield return new object[] { DnsmasqConfKeys.CacheRr, "A,TXT", "" };
}
[Theory]
[MemberData(nameof(GetSpecializedValidInvalidPairs))]
public void SpecializedOption_ValidAccepted_InvalidRejected(string optionKey, string validValue, string invalidValue)
{
var validErr = _validator.ValidateMultiItem(optionKey, validValue, ComplexAllowEmpty);
Assert.Null(validErr);
var invalidErr = _validator.ValidateMultiItem(optionKey, invalidValue, ComplexAllowEmpty);
Assert.NotNull(invalidErr);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,474 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Filters;
using Xunit;
namespace DnsmasqWebUI.Tests.Services.Filters;
public class FiltersPageProjectionServiceTests
{
private static DnsmasqServiceStatus MinimalStatus(
IReadOnlyList<string> addresses,
IReadOnlyList<ValueWithSource>? addressSources = null)
{
var cfg = TestConfig(addresses);
var sources = addressSources != null && addressSources.Count == addresses.Count
? MinimalSources(addressSources)
: null;
return EmptyStatus() with { EffectiveConfig = cfg, EffectiveConfigSources = sources };
}
private static EffectiveConfigSources MinimalSources(IReadOnlyList<ValueWithSource> addressValues) =>
new(
NoHosts: null,
AddnHostsPaths: [],
HostsdirPath: null,
ServerValues: [],
LocalValues: [],
RevServerValues: [],
AddressValues: addressValues,
Interfaces: [],
ListenAddresses: [],
ExceptInterfaces: [],
DhcpRanges: [],
DhcpHostLines: [],
DhcpOptionLines: [],
DhcpMatchValues: [],
DhcpBootValues: [],
DhcpIgnoreValues: [],
DhcpVendorclassValues: [],
DhcpUserclassValues: [],
RaParamValues: [],
SlaacValues: [],
PxeServiceValues: [],
TrustAnchorValues: [],
ResolvFiles: [],
RebindDomainOkValues: [],
BogusNxdomainValues: [],
IgnoreAddressValues: [],
AliasValues: [],
FilterRrValues: [],
CacheRrValues: [],
AuthServerValues: [],
NoDhcpInterfaceValues: [],
NoDhcpv4InterfaceValues: [],
NoDhcpv6InterfaceValues: [],
DomainValues: [],
CnameValues: [],
MxHostValues: [],
SrvValues: [],
PtrRecordValues: [],
TxtRecordValues: [],
NaptrRecordValues: [],
HostRecordValues: [],
DynamicHostValues: [],
InterfaceNameValues: [],
DhcpOptionForceLines: [],
IpsetValues: [],
NftsetValues: [],
DhcpMacValues: [],
DhcpNameMatchValues: [],
DhcpIgnoreNamesValues: [],
DhcpHostsfilePaths: [],
DhcpOptsfilePaths: [],
DhcpHostsdirPaths: [],
DhcpOptsdirPaths: [],
ConnmarkAllowlistValues: [],
CaaRecordValues: [],
DnsRrValues: [],
SynthDomainValues: [],
AuthZoneValues: [],
AuthSoaValues: [],
AuthSecServersValues: [],
AuthPeerValues: [],
DhcpRelayValues: [],
DhcpCircuitidValues: [],
DhcpRemoteidValues: [],
DhcpSubscridValues: [],
DhcpProxyValues: [],
TagIfValues: [],
BridgeInterfaceValues: [],
SharedNetworkValues: [],
DhcpOptionPxeValues: [],
ExpandHosts: null,
BogusPriv: null,
StrictOrder: null,
AllServers: null,
NoResolv: null,
DomainNeeded: null,
NoPoll: null,
BindInterfaces: null,
BindDynamic: null,
NoNegcache: null,
DnsLoopDetect: null,
StopDnsRebind: null,
RebindLocalhostOk: null,
ClearOnReload: null,
Filterwin2k: null,
FilterA: null,
FilterAaaa: null,
LocaliseQueries: null,
LogDebug: null,
DhcpAuthoritative: null,
LeasefileRo: null,
EnableTftp: null,
TftpSecure: null,
TftpNoFail: null,
TftpNoBlocksize: null,
Dnssec: null,
DnssecCheckUnsigned: null,
ReadEthers: null,
DhcpRapidCommit: null,
Localmx: null,
Selfmx: null,
EnableRa: null,
LogDhcp: null,
KeepInForeground: null,
NoDaemon: null,
ProxyDnssec: null,
ConnmarkAllowlistEnable: null,
NoRoundRobin: null,
DnssecNoTimecheck: null,
DnssecDebug: null,
LeasequeryValues: [],
DhcpGenerateNames: null,
DhcpBroadcast: null,
DhcpSequentialIp: null,
DhcpIgnoreClid: null,
BootpDynamic: null,
NoPing: null,
ScriptArp: null,
ScriptOnRenewal: null,
DhcpNoOverride: null,
QuietDhcp: null,
QuietDhcp6: null,
QuietRa: null,
QuietTftp: null,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
LocalTtl: null,
PidFilePath: null,
User: null,
Group: null,
LogFacility: null,
LogQueries: null,
AuthTtl: null,
EdnsPacketMax: null,
QueryPort: null,
PortLimit: null,
MinPort: null,
MaxPort: null,
LogAsync: null,
LocalService: null,
DhcpLeaseMax: null,
NegTtl: null,
MaxTtl: null,
MaxCacheTtl: null,
MinCacheTtl: null,
DhcpTtl: null,
TftpRootPath: null,
PxePrompt: null,
EnableDbus: null,
EnableUbus: null,
FastDnsRetry: null,
DhcpScriptPath: null,
MxTarget: null,
DnsForwardMax: null,
DumpfilePath: null,
Dumpmask: null,
AddCpeId: null,
DnssecTimestamp: null,
DnssecLimits: null,
DhcpAlternatePort: null,
DhcpDuid: null,
DhcpLuascriptPath: null,
DhcpScriptuser: null,
DhcpPxeVendor: null,
UseStaleCache: null,
AddMac: null,
StripMac: null,
AddSubnet: null,
StripSubnet: null,
Umbrella: null,
Do0x20Encode: null,
Conntrack: null);
private static EffectiveDnsmasqConfig TestConfig(IReadOnlyList<string> addresses) =>
EmptyConfig() with { AddressValues = addresses };
private static EffectiveDnsmasqConfig EmptyConfig()
{
// Minimal non-null lists / defaults — only AddressValues overridden in tests
return new EffectiveDnsmasqConfig(
NoHosts: false,
AddnHostsPaths: [],
HostsdirPath: null,
ServerValues: [],
LocalValues: [],
RevServerValues: [],
AddressValues: [],
Interfaces: [],
ListenAddresses: [],
ExceptInterfaces: [],
DhcpRanges: [],
DhcpHostLines: [],
DhcpOptionLines: [],
DhcpMatchValues: [],
DhcpBootValues: [],
DhcpIgnoreValues: [],
DhcpVendorclassValues: [],
DhcpUserclassValues: [],
RaParamValues: [],
SlaacValues: [],
PxeServiceValues: [],
TrustAnchorValues: [],
ResolvFiles: [],
RebindDomainOkValues: [],
BogusNxdomainValues: [],
IgnoreAddressValues: [],
AliasValues: [],
FilterRrValues: [],
CacheRrValues: [],
AuthServerValues: [],
NoDhcpInterfaceValues: [],
NoDhcpv4InterfaceValues: [],
NoDhcpv6InterfaceValues: [],
DomainValues: [],
CnameValues: [],
MxHostValues: [],
SrvValues: [],
PtrRecordValues: [],
TxtRecordValues: [],
NaptrRecordValues: [],
HostRecordValues: [],
DynamicHostValues: [],
InterfaceNameValues: [],
DhcpOptionForceLines: [],
IpsetValues: [],
NftsetValues: [],
DhcpMacValues: [],
DhcpNameMatchValues: [],
DhcpIgnoreNamesValues: [],
DhcpHostsfilePaths: [],
DhcpOptsfilePaths: [],
DhcpHostsdirPaths: [],
DhcpOptsdirPaths: [],
ConnmarkAllowlistValues: [],
CaaRecordValues: [],
DnsRrValues: [],
SynthDomainValues: [],
AuthZoneValues: [],
AuthSoaValues: [],
AuthSecServersValues: [],
AuthPeerValues: [],
DhcpRelayValues: [],
DhcpCircuitidValues: [],
DhcpRemoteidValues: [],
DhcpSubscridValues: [],
DhcpProxyValues: [],
TagIfValues: [],
BridgeInterfaceValues: [],
SharedNetworkValues: [],
DhcpOptionPxeValues: [],
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: [],
DhcpGenerateNames: null,
DhcpBroadcast: null,
DhcpSequentialIp: false,
DhcpIgnoreClid: false,
BootpDynamic: null,
NoPing: false,
ScriptArp: false,
ScriptOnRenewal: false,
DhcpNoOverride: false,
QuietDhcp: false,
QuietDhcp6: false,
QuietRa: false,
QuietTftp: false,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
LocalTtl: null,
PidFilePath: null,
User: null,
Group: null,
LogFacility: null,
LogQueries: null,
AuthTtl: null,
EdnsPacketMax: null,
QueryPort: null,
PortLimit: null,
MinPort: null,
MaxPort: null,
LogAsync: null,
LocalService: null,
DhcpLeaseMax: null,
NegTtl: null,
MaxTtl: null,
MaxCacheTtl: null,
MinCacheTtl: null,
DhcpTtl: null,
TftpRootPath: null,
PxePrompt: null,
EnableDbus: null,
EnableUbus: null,
FastDnsRetry: null,
DhcpScriptPath: null,
MxTarget: null,
DnsForwardMax: null,
DumpfilePath: null,
Dumpmask: null,
AddCpeId: null,
DnssecTimestamp: null,
DnssecLimits: null,
DhcpAlternatePort: null,
DhcpDuid: null,
DhcpLuascriptPath: null,
DhcpScriptuser: null,
DhcpPxeVendor: null,
UseStaleCache: null,
AddMac: null,
StripMac: false,
AddSubnet: null,
StripSubnet: false,
Umbrella: null,
Do0x20EncodeState: ExplicitToggleState.Default,
Conntrack: false);
}
private static DnsmasqServiceStatus EmptyStatus() =>
new(
SystemHostsPath: null,
SystemHostsPathExists: false,
ManagedHostsFilePath: null,
ManagedHostsPathExists: false,
NoHosts: false,
AddnHostsPaths: [],
EffectiveConfig: null,
EffectiveConfigSources: null,
MainConfigPath: null,
ManagedFilePath: "/managed.conf",
LeasesPath: null,
MainConfigPathExists: false,
ManagedFilePathExists: true,
LeasesPathConfigured: false,
LeasesPathExists: false,
ConfigFiles: [],
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: null,
MinimumSupportedDnsmasqVersion: "0",
DnsmasqVersionSupported: true,
DnsmasqVersionError: null,
DnsmasqSupportsDhcp: false,
DnsmasqSupportsTftp: false,
DnsmasqSupportsDnssec: false,
DnsmasqSupportsDbus: false);
[Fact]
public void BuildGroups_MarksManagedAddressEditable()
{
var managed = new ConfigValueSource("/managed.conf", "managed.conf", IsManaged: true, 10);
var status = MinimalStatus(
["/ads.example/#"],
[new ValueWithSource("/ads.example/#", managed)]);
var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter(), new EffectiveMultiValueProjectionService());
var groups = svc.BuildGroups(status, new FilterPolicyQueryState("", null, null, null, null));
var blocking = groups.First(g => g.Category == FilterPolicyCategory.Blocking);
var row = Assert.Single(blocking.Rows);
Assert.True(row.IsEditable);
Assert.Equal(FilterPolicyKind.Address, row.Kind);
}
[Fact]
public void BuildGroups_ReadOnlyWhenSourceNotManaged()
{
var ro = new ConfigValueSource("/etc/dnsmasq.d/z.conf", "z.conf", IsManaged: false, 2);
var status = MinimalStatus(
["/ads.example/#"],
[new ValueWithSource("/ads.example/#", ro)]);
var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter(), new EffectiveMultiValueProjectionService());
var groups = svc.BuildGroups(status, new FilterPolicyQueryState("", null, null, null, null));
var row = Assert.Single(groups.First(g => g.Category == FilterPolicyCategory.Blocking).Rows);
Assert.False(row.IsEditable);
}
[Fact]
public void BuildGroups_CurrentValuesAccessor_UsesDraftManagedRows()
{
var ro = new ConfigValueSource("/etc/dnsmasq.d/z.conf", "z.conf", IsManaged: false, 2);
var status = MinimalStatus(
["/ads.example/#"],
[new ValueWithSource("/ads.example/#", ro)]);
var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter(), new EffectiveMultiValueProjectionService());
var groups = svc.BuildGroups(
status,
new FilterPolicyQueryState("", null, null, null, null),
optionName => optionName == DnsmasqConfKeys.Address ? ["/draft.example/#"] : []);
var row = Assert.Single(groups.First(g => g.Category == FilterPolicyCategory.Blocking).Rows);
Assert.Equal("/draft.example/#", row.RawValue);
Assert.True(row.IsEditable);
Assert.True(row.IsDraftOnly);
Assert.Equal("/managed.conf", row.SourcePath);
}
}
@@ -0,0 +1,80 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Services.Hosts;
public class HostsPageProjectionServiceTests
{
private readonly HostsPageProjectionService _sut = new();
[Fact]
public void BuildGroups_SourcePathFilter_KeepsOnlyThatFile()
{
var rows = new HostsPageRow[]
{
new(
"m1",
HostsRowSourceKind.Managed,
"/data/managed.hosts",
IsEditable: true,
IsActive: true,
InactiveReason: null,
Address: "10.0.0.1",
Names: new[] { "a" },
EffectiveNames: new[] { "a" },
IsComment: false,
LineNumber: 1),
new(
"x1",
HostsRowSourceKind.AddnHosts,
"/etc/extra.hosts",
IsEditable: false,
IsActive: true,
InactiveReason: null,
Address: "10.0.0.2",
Names: new[] { "b" },
EffectiveNames: new[] { "b" },
IsComment: false,
LineNumber: 1),
};
var query = new HostsPageQueryState(
Search: "",
Grouping: HostsGroupingMode.Source,
Sort: HostsSortMode.LineNumber,
Descending: false,
SourceKindFilter: null,
SourcePathFilter: "/etc/extra.hosts",
EditableFilter: null,
ActiveFilter: null);
var groups = _sut.BuildGroups(rows, query);
Assert.Single(groups);
Assert.Equal("/etc/extra.hosts", groups[0].Subtitle);
Assert.Single(groups[0].Rows);
}
[Fact]
public void BuildGroups_SourcePathFilter_EmptyMeansAllFiles()
{
var rows = new HostsPageRow[]
{
new("m1", HostsRowSourceKind.Managed, "/a", true, true, null, "1.1.1.1", new[] { "a" }, new[] { "a" }, false, 1),
new("x1", HostsRowSourceKind.AddnHosts, "/b", false, true, null, "2.2.2.2", new[] { "b" }, new[] { "b" }, false, 1),
};
var query = new HostsPageQueryState(
"",
HostsGroupingMode.Source,
HostsSortMode.LineNumber,
false,
null,
SourcePathFilter: null,
null,
null);
var groups = _sut.BuildGroups(rows, query);
Assert.Equal(2, groups.Count);
}
}
@@ -1,9 +0,0 @@
using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET api/config/set.</summary>
public interface IConfigSetClient
{
Task<DnsmasqConfigSet> GetConfigSetAsync(CancellationToken ct = default);
}
@@ -1,11 +0,0 @@
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Status;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET/PUT api/dhcp/hosts.</summary>
public interface IDhcpHostsClient
{
Task<IReadOnlyList<DhcpHostEntry>> GetDhcpHostsAsync(CancellationToken ct = default);
Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default);
}
@@ -1,12 +0,0 @@
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Models.Status;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET/PUT api/hosts.</summary>
public interface IHostsClient
{
Task<IReadOnlyList<HostEntry>> GetHostsAsync(CancellationToken ct = default);
Task<IReadOnlyList<ReadOnlyHostsFile>> GetReadOnlyHostsAsync(CancellationToken ct = default);
Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default);
}
@@ -1,9 +0,0 @@
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for POST api/reload.</summary>
public interface IReloadClient
{
Task<ReloadResult> ReloadAsync(CancellationToken ct = default);
}
@@ -1,9 +0,0 @@
using DnsmasqWebUI.Models.Status;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET api/status.</summary>
public interface IStatusClient
{
Task<DnsmasqServiceStatus> GetStatusAsync(CancellationToken ct = default);
}
@@ -1,45 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Status;
namespace DnsmasqWebUI.Client.Http;
public sealed class DhcpHostsClient : IDhcpHostsClient
{
private readonly HttpClient _http;
public DhcpHostsClient(HttpClient http) => _http = http;
public async Task<IReadOnlyList<DhcpHostEntry>> GetDhcpHostsAsync(CancellationToken ct = default)
{
var list = await _http.GetFromJsonAsync<List<DhcpHostEntry>>("api/dhcp/hosts", ct);
return list ?? new List<DhcpHostEntry>();
}
public async Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
{
var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
var msg = response.ReasonPhrase ?? response.StatusCode.ToString();
if (!string.IsNullOrEmpty(body) && body.TrimStart().StartsWith("{"))
{
try
{
var err = System.Text.Json.JsonSerializer.Deserialize<JsonError>(body);
if (!string.IsNullOrEmpty(err?.Error))
msg = err.Error;
}
catch { /* use msg as-is */ }
}
throw new HttpRequestException(msg);
}
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct)
?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts.");
}
private sealed class JsonError { [JsonPropertyName("error")] public string? Error { get; set; } }
}
@@ -1,33 +0,0 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Models.Status;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
public sealed class HostsClient : IHostsClient
{
private readonly HttpClient _http;
public HostsClient(HttpClient http) => _http = http;
public async Task<IReadOnlyList<HostEntry>> GetHostsAsync(CancellationToken ct = default)
{
var list = await _http.GetFromJsonAsync<List<HostEntry>>("api/hosts", ct);
return list ?? new List<HostEntry>();
}
public async Task<IReadOnlyList<ReadOnlyHostsFile>> GetReadOnlyHostsAsync(CancellationToken ct = default)
{
var list = await _http.GetFromJsonAsync<List<ReadOnlyHostsFile>>("api/hosts/readonly", ct);
return list ?? new List<ReadOnlyHostsFile>();
}
public async Task<SaveWithReloadResult> SaveHostsAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
{
var response = await _http.PutAsJsonAsync("api/hosts", entries, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct)
?? throw new InvalidOperationException("Unexpected null from api/hosts.");
}
}
@@ -1,24 +0,0 @@
using System.Text.Json;
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models.Status;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Client.Http;
public sealed class StatusClient : IStatusClient
{
private readonly HttpClient _http;
/// <summary>Matches API serialization (camelCase) so nested EffectiveConfigSources and tuple (value, source) deserialize correctly.</summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public StatusClient(HttpClient http) => _http = http;
public async Task<DnsmasqServiceStatus> GetStatusAsync(CancellationToken ct = default) =>
await _http.GetFromJsonAsync<DnsmasqServiceStatus>("api/status", JsonOptions, ct)
?? throw new InvalidOperationException("Unexpected null from api/status.");
}
@@ -1,16 +0,0 @@
namespace DnsmasqWebUI.Client.Models;
/// <summary>
/// Client-side preferences persisted in browser local storage.
/// </summary>
public class ClientSettings
{
/// <summary>Polling interval for the service status block (seconds).</summary>
public int ServiceStatusPollingIntervalSeconds { get; set; } = 15;
/// <summary>Polling interval for the recent logs block (seconds).</summary>
public int RecentLogsPollingIntervalSeconds { get; set; } = 5;
/// <summary>Polling interval for the DHCP leases list (seconds).</summary>
public int LeasesPollingIntervalSeconds { get; set; } = 5;
}
@@ -1,19 +0,0 @@
namespace DnsmasqWebUI.Client.Models;
/// <summary>
/// Which subset of client settings to show in the settings modal.
/// </summary>
public enum SettingsModalContext
{
/// <summary>Show all settings with optional search.</summary>
All,
/// <summary>Show only service status polling interval.</summary>
ServicePolling,
/// <summary>Show only recent logs polling interval.</summary>
LogsPolling,
/// <summary>Show only DHCP leases refresh interval.</summary>
LeasesPolling
}
@@ -1,72 +0,0 @@
using System.Text.Json;
using DnsmasqWebUI.Client.Models;
using DnsmasqWebUI.Client.Services.Abstractions;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Client.Services;
/// <summary>
/// Loads and saves client-side settings to browser local storage via JS interop.
/// Scoped per Blazor circuit; disposes the JS module when the scope is disposed.
/// </summary>
public sealed class ClientSettingsService : IClientSettingsService, IAsyncDisposable
{
private readonly IJSRuntime _jsRuntime;
private IJSObjectReference? _module;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
public ClientSettingsService(IJSRuntime jsRuntime) => _jsRuntime = jsRuntime;
private async Task<IJSObjectReference> GetModuleAsync()
{
if (_module != null) return _module;
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/client-settings-storage.js");
return _module;
}
/// <inheritdoc />
public async Task<ClientSettings> LoadSettingsAsync()
{
var module = await GetModuleAsync();
var json = await module.InvokeAsync<string?>("getItem");
if (string.IsNullOrWhiteSpace(json)) return new ClientSettings();
try
{
return JsonSerializer.Deserialize<ClientSettings>(json, JsonOptions) ?? new ClientSettings();
}
catch
{
return new ClientSettings();
}
}
/// <inheritdoc />
public async Task SaveSettingsAsync(ClientSettings settings)
{
var json = JsonSerializer.Serialize(settings, JsonOptions);
var module = await GetModuleAsync();
await module.InvokeVoidAsync("setItem", json);
}
/// <summary>
/// Disposes the JS module reference. Called when the Blazor circuit scope is disposed.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_module == null) return;
try
{
await _module.DisposeAsync();
}
finally
{
_module = null;
}
}
}
+15 -11
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 />
<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;
}
@@ -0,0 +1,171 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject INotificationService NotificationService
@if (Rows.Count == 0)
{
<p class="text-muted small">No rows for the current filters.</p>
}
else
{
<div class="app-table-scroll-shell" data-table-scroll-cue>
<div class="app-table-scroll-region" data-table-scroll-region role="region" aria-label="DNS records table" tabindex="0">
<table class="table table-bordered table-hover app-data-table dns-records-table mb-0" id="@TableId">
<caption class="visually-hidden">@TableCaption</caption>
<thead>
<tr>
<th scope="col" class="dns-records-type-th">
<span class="align-middle">Type</span>
<button type="button"
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
title="@TypeColumnHelpText"
aria-label="@TypeColumnHelpText"
@onclick="OnTypeHelpClick"
@onclick:stopPropagation="true">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</th>
<th scope="col" class="dns-records-summary-th">
<span class="align-middle">Summary</span>
<button type="button"
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
title="@SummaryColumnHelpText"
aria-label="@SummaryColumnHelpText"
@onclick="OnSummaryHelpClick"
@onclick:stopPropagation="true">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</th>
<th scope="col" class="dns-records-source-th">
<span class="align-middle">Source</span>
<button type="button"
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
title="@SourceColumnHelpText"
aria-label="@SourceColumnHelpText"
@onclick="OnSourceHelpClick"
@onclick:stopPropagation="true">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</th>
<th scope="col" class="dns-records-status-th">
<span class="align-middle">Status</span>
<button type="button"
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
title="@StatusColumnHelpText"
aria-label="@StatusColumnHelpText"
@onclick="OnStatusHelpClick"
@onclick:stopPropagation="true">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</th>
<th scope="col" class="dns-records-actions-col">
<span class="align-middle">Actions</span>
<button type="button"
class="btn btn-link btn-sm text-muted app-th-help-btn p-0 ms-1 align-middle border-0"
title="@ActionsColumnHelpText"
aria-label="@ActionsColumnHelpText"
@onclick="OnActionsHelpClick"
@onclick:stopPropagation="true">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</th>
</tr>
</thead>
<tbody>
@foreach (var row in Rows)
{
<tr @key="@row.OccurrenceId">
<td><span class="badge bg-secondary">@row.OptionName</span></td>
<td>
<div class="dns-records-summary" title="@row.RawValue">@row.Summary</div>
<code class="small text-muted dns-records-raw">@row.RawValue</code>
</td>
<td class="small">
@if (string.IsNullOrWhiteSpace(row.SourcePath))
{
<span class="text-muted">—</span>
}
else
{
<span title="@row.SourcePath">@(row.SourceLabel ?? row.SourcePath)</span>
@if (row.IsDraftOnly)
{
<span class="badge bg-primary-subtle text-primary-emphasis ms-1">draft</span>
}
else if (row.Source?.IsReadOnly == true)
{
<span class="badge bg-warning text-dark ms-1">read-only</span>
}
else
{
<span class="badge bg-success ms-1">editable</span>
}
}
</td>
<td class="small">
@if (row.Issues.Count == 0)
{
<span class="text-muted">OK</span>
}
else
{
@foreach (var issue in row.Issues.Take(3))
{
var cls = issue.Severity == FieldIssueSeverity.Error ? "text-danger" : "text-warning";
<div class="@cls">@issue.Message</div>
}
@if (row.Issues.Count > 3)
{
<div class="text-muted">…</div>
}
}
</td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@(!row.IsEditable)" @onclick="() => OnEditRow.InvokeAsync(row)">Edit</button>
<button type="button" class="btn btn-outline-danger btn-sm" disabled="@(!row.IsEditable)" @onclick="() => OnDeleteRow.InvokeAsync(row)">Remove</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@code {
private const string TypeColumnHelpText =
"The dnsmasq option name for this line (for example cname, host-record, srv-host).";
private const string SummaryColumnHelpText =
"A short plain-language description of the record. The monospace line below is the exact config value dnsmasq uses.";
private const string SourceColumnHelpText =
"Which configuration file defined this line. Editable means the app can write changes to that source; read-only means it appears in the effective config but is not managed from the app’s writable file.";
private const string StatusColumnHelpText =
"Per-line syntax validation from option semantics plus page-local warnings (duplicates, incomplete rows, or unusual combinations such as CNAME vs host-record on the same name).";
private const string ActionsColumnHelpText =
"Edit opens the structured form for this directive when the row is editable. Remove drops this exact line from the effective multi-value list (same as deleting in Effective configuration).";
[Parameter] public string TableId { get; set; } = "dns-records-table";
[Parameter] public string TableCaption { get; set; } = "DNS record lines for scanning and editing";
[Parameter] public IReadOnlyList<DnsRecordRow> Rows { get; set; } = [];
[Parameter] public EventCallback<DnsRecordRow> OnEditRow { get; set; }
[Parameter] public EventCallback<DnsRecordRow> OnDeleteRow { get; set; }
private void OnTypeHelpClick() => NotificationService.ShowInfo(TypeColumnHelpText);
private void OnSummaryHelpClick() => NotificationService.ShowInfo(SummaryColumnHelpText);
private void OnSourceHelpClick() => NotificationService.ShowInfo(SourceColumnHelpText);
private void OnStatusHelpClick() => NotificationService.ShowInfo(StatusColumnHelpText);
private void OnActionsHelpClick() => NotificationService.ShowInfo(ActionsColumnHelpText);
}
@@ -0,0 +1,253 @@
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
<h4 class="h6 mb-2">@_title</h4>
@if (OptionName == DnsmasqConfKeys.NaptrRecord)
{
<div class="row g-2">
<div class="col-md-3"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_nName" @bind:event="oninput" /></div>
<div class="col-md-1"><label class="form-label small mb-0">Order</label><input class="form-control form-control-sm" @bind="_nOrder" @bind:event="oninput" /></div>
<div class="col-md-1"><label class="form-label small mb-0">Pref</label><input class="form-control form-control-sm" @bind="_nPref" @bind:event="oninput" /></div>
<div class="col-md-2"><label class="form-label small mb-0">Flags</label><input class="form-control form-control-sm" @bind="_nFlags" @bind:event="oninput" /></div>
<div class="col-md-2"><label class="form-label small mb-0">Service</label><input class="form-control form-control-sm" @bind="_nService" @bind:event="oninput" /></div>
<div class="col-md-3"><label class="form-label small mb-0">Regexp</label><input class="form-control form-control-sm" @bind="_nRegexp" @bind:event="oninput" /></div>
<div class="col-md-6"><label class="form-label small mb-0">Replacement (optional)</label><input class="form-control form-control-sm" @bind="_nRepl" @bind:event="oninput" /></div>
</div>
}
else if (OptionName == DnsmasqConfKeys.CaaRecord)
{
<div class="row g-2">
<div class="col-md-3"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_cName" @bind:event="oninput" /></div>
<div class="col-md-1"><label class="form-label small mb-0">Flags</label><input class="form-control form-control-sm" @bind="_cFlags" @bind:event="oninput" /></div>
<div class="col-md-2"><label class="form-label small mb-0">Tag</label><input class="form-control form-control-sm" @bind="_cTag" @bind:event="oninput" /></div>
<div class="col-md-6"><label class="form-label small mb-0">Value</label><input class="form-control form-control-sm" @bind="_cValue" @bind:event="oninput" /></div>
</div>
}
else if (OptionName == DnsmasqConfKeys.DnsRr)
{
<div class="row g-2">
<div class="col-md-3"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_dName" @bind:event="oninput" /></div>
<div class="col-md-2"><label class="form-label small mb-0">RR number</label><input class="form-control form-control-sm" @bind="_dNum" @bind:event="oninput" /></div>
<div class="col-md-7"><label class="form-label small mb-0">Hex data (optional)</label><input class="form-control form-control-sm" @bind="_dHex" @bind:event="oninput" /></div>
</div>
}
else if (OptionName == DnsmasqConfKeys.DynamicHost)
{
<div class="row g-2">
<div class="col-md-2"><label class="form-label small mb-0">Name</label><input class="form-control form-control-sm" @bind="_yName" @bind:event="oninput" /></div>
<div class="col-md-2"><label class="form-label small mb-0">IPv4</label><input class="form-control form-control-sm" @bind="_yV4" @bind:event="oninput" /></div>
<div class="col-md-2"><label class="form-label small mb-0">IPv6</label><input class="form-control form-control-sm" @bind="_yV6" @bind:event="oninput" /></div>
<div class="col-md-3"><label class="form-label small mb-0">Interface</label><input class="form-control form-control-sm" @bind="_yIface" @bind:event="oninput" /></div>
</div>
}
else if (OptionName == DnsmasqConfKeys.InterfaceName)
{
<div class="row g-2">
<div class="col-md-4"><label class="form-label small mb-0">DNS name</label><input class="form-control form-control-sm" @bind="_iName" @bind:event="oninput" /></div>
<div class="col-md-4"><label class="form-label small mb-0">Interface[/4|/6]</label><input class="form-control form-control-sm" @bind="_iIface" @bind:event="oninput" /></div>
</div>
}
else if (OptionName == DnsmasqConfKeys.SynthDomain)
{
<div class="row g-2">
<div class="col-md-3"><label class="form-label small mb-0">Domain</label><input class="form-control form-control-sm" @bind="_sDomain" @bind:event="oninput" /></div>
<div class="col-md-3"><label class="form-label small mb-0">Address range</label><input class="form-control form-control-sm" @bind="_sRange" @bind:event="oninput" /></div>
<div class="col-md-4"><label class="form-label small mb-0">Prefix (optional)</label><input class="form-control form-control-sm" @bind="_sPrefix" @bind:event="oninput" /></div>
</div>
}
else if (OptionName == DnsmasqConfKeys.AuthZone)
{
<div class="row g-2">
<div class="col-md-3"><label class="form-label small mb-0">Domain</label><input class="form-control form-control-sm" @bind="_zDomain" @bind:event="oninput" /></div>
<div class="col-md-9"><label class="form-label small mb-0">Subnets / exclude:… (comma-separated)</label><input class="form-control form-control-sm" @bind="_zRest" @bind:event="oninput" /></div>
</div>
}
else if (OptionName == DnsmasqConfKeys.AuthSoa)
{
<div class="row g-2">
<div class="col-md-2"><label class="form-label small mb-0">Serial</label><input class="form-control form-control-sm" @bind="_aSerial" @bind:event="oninput" /></div>
<div class="col-md-3"><label class="form-label small mb-0">Hostmaster</label><input class="form-control form-control-sm" @bind="_aHm" @bind:event="oninput" /></div>
<div class="col-md-2"><label class="form-label small mb-0">Refresh</label><input class="form-control form-control-sm" @bind="_aRef" @bind:event="oninput" /></div>
<div class="col-md-2"><label class="form-label small mb-0">Retry</label><input class="form-control form-control-sm" @bind="_aRetry" @bind:event="oninput" /></div>
<div class="col-md-2"><label class="form-label small mb-0">Expiry</label><input class="form-control form-control-sm" @bind="_aEx" @bind:event="oninput" /></div>
</div>
}
else if (OptionName == DnsmasqConfKeys.AuthSecServers)
{
<div class="row g-2">
<div class="col-md-12"><label class="form-label small mb-0">Domains (comma-separated)</label><input class="form-control form-control-sm" @bind="_secDom" @bind:event="oninput" /></div>
</div>
}
else if (OptionName == DnsmasqConfKeys.AuthPeer)
{
<div class="row g-2">
<div class="col-md-12"><label class="form-label small mb-0">IPs (comma-separated)</label><input class="form-control form-control-sm" @bind="_peerIp" @bind:event="oninput" /></div>
</div>
}
<div class="mt-2 d-flex gap-1">
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
</div>
@if (_error != null)
{
<p class="text-danger small mt-2 mb-0">@_error</p>
}
@code {
[Parameter, EditorRequired] public string OptionName { get; set; } = "";
[Parameter] public DnsRecordRow? Existing { get; set; }
[Parameter] public EventCallback<DnsRecordEditorResult> OnSave { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
private string _title = "Advanced record";
private string? _error;
private string _nName = "", _nOrder = "", _nPref = "", _nFlags = "", _nService = "", _nRegexp = "", _nRepl = "";
private string _cName = "", _cFlags = "", _cTag = "", _cValue = "";
private string _dName = "", _dNum = "", _dHex = "";
private string _yName = "", _yV4 = "", _yV6 = "", _yIface = "";
private string _iName = "", _iIface = "";
private string _sDomain = "", _sRange = "", _sPrefix = "";
private string _zDomain = "", _zRest = "";
private string _aSerial = "", _aHm = "", _aRef = "", _aRetry = "", _aEx = "";
private string _secDom = "";
private string _peerIp = "";
private string? _boundRowKey;
protected override void OnParametersSet()
{
_title = OptionName switch
{
DnsmasqConfKeys.NaptrRecord => "NAPTR",
DnsmasqConfKeys.CaaRecord => "CAA",
DnsmasqConfKeys.DnsRr => "dns-rr",
DnsmasqConfKeys.DynamicHost => "dynamic-host",
DnsmasqConfKeys.InterfaceName => "interface-name",
DnsmasqConfKeys.SynthDomain => "synth-domain",
DnsmasqConfKeys.AuthZone => "auth-zone",
DnsmasqConfKeys.AuthSoa => "auth-soa",
DnsmasqConfKeys.AuthSecServers => "auth-sec-servers",
DnsmasqConfKeys.AuthPeer => "auth-peer",
_ => OptionName
};
if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing, $"add:{OptionName}"))
return;
_error = null;
switch (Existing?.Payload)
{
case NaptrPayload n:
_nName = n.Name; _nOrder = n.Order; _nPref = n.Preference; _nFlags = n.Flags; _nService = n.Service; _nRegexp = n.Regexp; _nRepl = n.Replacement ?? "";
break;
case CaaPayload c:
_cName = c.Name; _cFlags = c.Flags; _cTag = c.Tag; _cValue = c.Value;
break;
case DnsRrPayload d:
_dName = d.Name; _dNum = d.RrNumber; _dHex = d.HexData ?? "";
break;
case DynamicHostPayload y:
_yName = y.Name; _yV4 = y.IPv4 ?? ""; _yV6 = y.IPv6 ?? ""; _yIface = y.Interface;
break;
case InterfaceNamePayload i:
_iName = i.DnsName; _iIface = i.InterfaceSpec;
break;
case SynthDomainPayload s:
_sDomain = s.Domain; _sRange = s.AddressRange; _sPrefix = s.Prefix ?? "";
break;
case AuthZonePayload z:
_zDomain = z.Domain; _zRest = z.SubnetsAndExcludes.Count > 0 ? string.Join(",", z.SubnetsAndExcludes) : "";
break;
case AuthSoaPayload a:
_aSerial = a.Serial; _aHm = a.Hostmaster ?? ""; _aRef = a.Refresh ?? ""; _aRetry = a.Retry ?? ""; _aEx = a.Expiry ?? "";
break;
case AuthSecServersPayload sec:
_secDom = string.Join(",", sec.Domains);
break;
case AuthPeerPayload ap:
_peerIp = string.Join(",", ap.Ips);
break;
default:
ClearIfNew();
break;
}
}
private void ClearIfNew()
{
if (Existing != null) return;
_nName = _nOrder = _nPref = _nFlags = _nService = _nRegexp = _nRepl = "";
_cName = _cFlags = _cTag = _cValue = "";
_dName = _dNum = _dHex = "";
_yName = _yV4 = _yV6 = _yIface = "";
_iName = _iIface = "";
_sDomain = _sRange = _sPrefix = "";
_zDomain = _zRest = "";
_aSerial = _aHm = _aRef = _aRetry = _aEx = "";
_secDom = "";
_peerIp = "";
}
private DnsRecordFamily FamilyForOption() => OptionName switch
{
DnsmasqConfKeys.NaptrRecord => DnsRecordFamily.Naptr,
DnsmasqConfKeys.CaaRecord => DnsRecordFamily.Caa,
DnsmasqConfKeys.DnsRr => DnsRecordFamily.DnsRr,
DnsmasqConfKeys.DynamicHost => DnsRecordFamily.DynamicHost,
DnsmasqConfKeys.InterfaceName => DnsRecordFamily.InterfaceName,
DnsmasqConfKeys.SynthDomain => DnsRecordFamily.SynthDomain,
DnsmasqConfKeys.AuthZone => DnsRecordFamily.AuthZone,
DnsmasqConfKeys.AuthSoa => DnsRecordFamily.AuthSoa,
DnsmasqConfKeys.AuthSecServers => DnsRecordFamily.AuthSecServers,
DnsmasqConfKeys.AuthPeer => DnsRecordFamily.AuthPeer,
_ => DnsRecordFamily.Cname
};
private async Task SaveAsync()
{
_error = null;
DnsRecordPayload payload;
try
{
payload = OptionName switch
{
DnsmasqConfKeys.NaptrRecord => new NaptrPayload(_nName.Trim(), _nOrder.Trim(), _nPref.Trim(), _nFlags.Trim(), _nService.Trim(), _nRegexp.Trim(), string.IsNullOrWhiteSpace(_nRepl) ? null : _nRepl.Trim()),
DnsmasqConfKeys.CaaRecord => new CaaPayload(_cName.Trim(), _cFlags.Trim(), _cTag.Trim(), _cValue.Trim()),
DnsmasqConfKeys.DnsRr => new DnsRrPayload(_dName.Trim(), _dNum.Trim(), string.IsNullOrWhiteSpace(_dHex) ? null : _dHex.Trim()),
DnsmasqConfKeys.DynamicHost => new DynamicHostPayload(_yName.Trim(), string.IsNullOrWhiteSpace(_yV4) ? null : _yV4.Trim(), string.IsNullOrWhiteSpace(_yV6) ? null : _yV6.Trim(), _yIface.Trim()),
DnsmasqConfKeys.InterfaceName => new InterfaceNamePayload(_iName.Trim(), _iIface.Trim()),
DnsmasqConfKeys.SynthDomain => new SynthDomainPayload(_sDomain.Trim(), _sRange.Trim(), string.IsNullOrWhiteSpace(_sPrefix) ? null : _sPrefix.Trim()),
DnsmasqConfKeys.AuthZone => new AuthZonePayload(_zDomain.Trim(), string.IsNullOrWhiteSpace(_zRest) ? [] : _zRest.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToArray()),
DnsmasqConfKeys.AuthSoa => new AuthSoaPayload(_aSerial.Trim(), string.IsNullOrWhiteSpace(_aHm) ? null : _aHm.Trim(), string.IsNullOrWhiteSpace(_aRef) ? null : _aRef.Trim(), string.IsNullOrWhiteSpace(_aRetry) ? null : _aRetry.Trim(), string.IsNullOrWhiteSpace(_aEx) ? null : _aEx.Trim()),
DnsmasqConfKeys.AuthSecServers => new AuthSecServersPayload(string.IsNullOrWhiteSpace(_secDom) ? [] : _secDom.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToArray()),
DnsmasqConfKeys.AuthPeer => new AuthPeerPayload(string.IsNullOrWhiteSpace(_peerIp) ? [] : _peerIp.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToArray()),
_ => throw new InvalidOperationException($"Unsupported option {OptionName}")
};
}
catch
{
_error = "Invalid input.";
return;
}
var fam = FamilyForOption();
var isNew = Existing == null;
var row = isNew
? new DnsRecordRow(
Id: $"{OptionName}:new",
OccurrenceId: $"{OptionName}:new",
OptionName: OptionName,
Family: fam,
IndexInOption: -1,
RawValue: "",
Source: null,
SourcePath: null,
SourceLabel: null,
IsDraftOnly: false,
IsEditable: true,
Payload: payload,
Issues: [],
Summary: "")
: Existing! with { Payload = payload };
await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew));
}
}
@@ -0,0 +1,102 @@
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
<h4 class="h6 mb-2">CNAME</h4>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small mb-0">Aliases (comma-separated)</label>
<input class="form-control form-control-sm" @bind="_aliases" @bind:event="oninput" />
</div>
<div class="col-md-4">
<label class="form-label small mb-0">Target</label>
<input class="form-control form-control-sm" @bind="_target" @bind:event="oninput" />
</div>
<div class="col-md-2">
<label class="form-label small mb-0">TTL (optional)</label>
<input class="form-control form-control-sm" type="number" min="1" @bind="_ttl" @bind:event="oninput" />
</div>
<div class="col-md-2 d-flex gap-1">
<button type="button" class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnCancel">Cancel</button>
</div>
</div>
@if (_error != null)
{
<p class="text-danger small mt-2 mb-0">@_error</p>
}
@code {
[Parameter] public DnsRecordRow? Existing { get; set; }
[Parameter] public EventCallback<DnsRecordEditorResult> OnSave { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
private string _aliases = "";
private string _target = "";
private string _ttl = "";
private string? _error;
private string? _boundRowKey;
protected override void OnParametersSet()
{
if (!DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref _boundRowKey, Existing))
return;
if (Existing?.Payload is CnamePayload p)
{
_aliases = string.Join(", ", p.Aliases);
_target = p.Target;
_ttl = p.Ttl?.ToString() ?? "";
}
else
{
_aliases = "";
_target = "";
_ttl = "";
}
_error = null;
}
private async Task SaveAsync()
{
_error = null;
var parts = _aliases.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList();
if (parts.Count == 0 || string.IsNullOrWhiteSpace(_target))
{
_error = "At least one alias and a target are required.";
return;
}
int? ttl = null;
if (!string.IsNullOrWhiteSpace(_ttl))
{
if (!int.TryParse(_ttl, out var t) || t <= 0)
{
_error = "TTL must be a positive integer.";
return;
}
ttl = t;
}
var payload = new CnamePayload(parts, _target.Trim(), ttl);
var isNew = Existing == null;
var row = isNew
? new DnsRecordRow(
Id: $"{DnsmasqConfKeys.Cname}:new",
OccurrenceId: $"{DnsmasqConfKeys.Cname}:new",
OptionName: DnsmasqConfKeys.Cname,
Family: DnsRecordFamily.Cname,
IndexInOption: -1,
RawValue: "",
Source: null,
SourcePath: null,
SourceLabel: null,
IsDraftOnly: false,
IsEditable: true,
Payload: payload,
Issues: [],
Summary: "")
: Existing! with { Payload = payload };
await OnSave.InvokeAsync(new DnsRecordEditorResult(row, isNew));
}
}

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