From d91068874279b7ba304880ee401fef8bc0d4aed4 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sun, 8 Mar 2026 23:12:38 +1000 Subject: [PATCH] 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 --- Dockerfile | 6 +- README.md | 10 ++- agents.md | 2 +- docker-compose.test.yml | 18 ++++-- .../Components/Pages/Dnsmasq.razor | 16 +++++ .../Controllers/StatusController.cs | 14 +++- .../Hosting/WebApplicationExtensions.cs | 21 ++++++ .../Helpers/Dnsmasq/DnsmasqVersionParser.cs | 21 ++++++ .../Abstractions/IDnsmasqVersionService.cs | 11 ++++ .../DnsmasqVersionEnforcementHostedService.cs | 39 +++++++++++ .../Version/DnsmasqVersionHealthCheck.cs | 54 ++++++++++++++++ .../Dnsmasq/Version/DnsmasqVersionService.cs | 64 +++++++++++++++++++ .../EffectiveConfigSaveService.cs | 42 ++++++++++++ .../Models/Config/DnsmasqOptions.cs | 15 +++++ .../Models/Config/DnsmasqOptionsValidator.cs | 5 ++ .../Models/Dnsmasq/DnsmasqServiceStatus.cs | 10 ++- .../Models/Dnsmasq/DnsmasqVersionInfo.cs | 16 +++++ .../EffectiveConfigSaveResult.cs | 3 + src/DnsmasqWebUI/Program.cs | 6 ++ 19 files changed, 363 insertions(+), 10 deletions(-) create mode 100644 src/DnsmasqWebUI/Infrastructure/Helpers/Dnsmasq/DnsmasqVersionParser.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/Abstractions/IDnsmasqVersionService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionEnforcementHostedService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionHealthCheck.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionService.cs create mode 100644 src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqVersionInfo.cs diff --git a/Dockerfile b/Dockerfile index 3aff504..601555a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,8 +52,12 @@ RUN if [ "$DNSMASQ_VERSION" = "distro" ]; then \ && apt-get autoremove -y --purge \ && rm -rf /var/lib/apt/lists/*; \ fi -COPY scripts/entrypoint.sh scripts/dnsmasq-status.sh . +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 diff --git a/README.md b/README.md index 94b96b3..375f026 100644 --- a/README.md +++ b/README.md @@ -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. @@ -163,7 +165,7 @@ COPY entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] ``` -Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via environment variables or an `appsettings.json` in `/app`. Mount your config dir and expose 8080. +Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via environment variables or an `appsettings.json` in `/app`. Mount your config dir and expose 8080. The image includes a **HEALTHCHECK** that calls `GET /healthz/ready`; orchestration (e.g. Kubernetes readiness probe, or Compose `depends_on: condition: service_healthy`) can use the same endpoint. **Config summary for containers:** @@ -173,8 +175,11 @@ Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via e | `Dnsmasq__MainConfigPath` | `/data/dnsmasq.conf` | Main dnsmasq config path (required) | | `Dnsmasq__ReloadCommand` | `pkill -HUP -x dnsmasq` | Run after config changes | | `Dnsmasq__StatusShowCommand` | `/app/dnsmasq-status.sh` | Optional status output (e.g. our script in test harness) | +| `Dnsmasq__MinimumVersion` | `2.91` | Minimum dnsmasq version (optional; default 2.91). Set `Dnsmasq__EnforceMinimumVersion=false` to allow older versions. | | `ASPNETCORE_URLS` | `http://+:8080` | Port the app listens on | +**Readiness:** `GET http://localhost:8080/healthz/ready` returns JSON `{"status":"ok"}` when dnsmasq version meets the minimum and (if `StatusCommand` is set) dnsmasq is running. Use this for Kubernetes readiness probes, Docker HEALTHCHECK, or load balancer health checks. + See [Configuration](#configuration) for all options. @@ -205,6 +210,9 @@ The app is configured via **appsettings.json**, **environment variables**, and * | `StatusCommand` | Optional: check if dnsmasq is running | `pgrep -x dnsmasq` | | `StatusShowCommand` | Optional: full status output (e.g. systemctl status) | `systemctl status dnsmasq --no-pager` | | `LogsCommand` | Optional: recent logs (e.g. journalctl) | `journalctl -u dnsmasq -n 100 --no-pager` | +| `VersionCommand` | Command to probe dnsmasq version (used for minimum-version check and UI display) | `dnsmasq --version` | +| `MinimumVersion` | Minimum dnsmasq version required (e.g. 2.91). Some options need newer dnsmasq. | `2.91` | +| `EnforceMinimumVersion` | If true, app fails to start when version probe fails or version is below minimum. If false, only save and readiness checks enforce. | `true` | **Application options** (use `Application__` prefix for env, `Application` section in JSON): diff --git a/agents.md b/agents.md index badba67..b0c632f 100644 --- a/agents.md +++ b/agents.md @@ -30,7 +30,7 @@ This will: Then start manually with the version printed by the script: `TESTDATA_MOUNT=./testdata-mount DNSMASQ_VERSION= docker compose -f docker-compose.test.yml up -d [--build]` - **Preserve mount (e.g. keep leases):** `./scripts/prepare-test-mount.sh --no-build` (default mount behavior) -App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory. +App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory. The app service has a healthcheck (`GET /healthz/ready`); DHCP client services use `depends_on: app: condition: service_healthy` so they start only after the app (and dnsmasq) is ready. ### Stop the harness diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 4ee3dd5..3443f1c 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -18,6 +18,12 @@ services: 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: @@ -46,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: @@ -65,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: @@ -84,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: @@ -93,7 +102,8 @@ services: networks: testnet: {} depends_on: - - app + app: + condition: service_healthy networks: testnet: diff --git a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor index 39b03a1..1469070 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor @@ -24,6 +24,22 @@ else if (_error != null) else if (_status != null) {
+ @if (!string.IsNullOrWhiteSpace(_status.DnsmasqVersion) || !string.IsNullOrWhiteSpace(_status.DnsmasqVersionError)) + { +

+ @if (!string.IsNullOrWhiteSpace(_status.DnsmasqVersion)) + { + var versionTooltip = "Minimum required by this UI: " + _status.MinimumSupportedDnsmasqVersion + ". " + (_status.DnsmasqVersionSupported ? "Supported." : "Unsupported."); + + dnsmasq @_status.DnsmasqVersion + + } + @if (!string.IsNullOrWhiteSpace(_status.DnsmasqVersionError)) + { + @_status.DnsmasqVersionError + } +

+ }

Service configuration

Config files (load order):

    diff --git a/src/DnsmasqWebUI/Controllers/StatusController.cs b/src/DnsmasqWebUI/Controllers/StatusController.cs index 287416c..6638a51 100644 --- a/src/DnsmasqWebUI/Controllers/StatusController.cs +++ b/src/DnsmasqWebUI/Controllers/StatusController.cs @@ -1,5 +1,6 @@ using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions; using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Contracts; using DnsmasqWebUI.Models.Dnsmasq; @@ -17,17 +18,20 @@ public class StatusController : ControllerBase private readonly DnsmasqOptions _options; private readonly IDnsmasqConfigSetService _configSetService; private readonly IProcessRunner _processRunner; + private readonly IDnsmasqVersionService _versionService; private readonly ILogger _logger; public StatusController( IOptions options, IDnsmasqConfigSetService configSetService, IProcessRunner processRunner, + IDnsmasqVersionService versionService, ILogger logger) { _options = options.Value; _configSetService = configSetService; _processRunner = processRunner; + _versionService = versionService; _logger = logger; } @@ -52,14 +56,16 @@ public class StatusController : ControllerBase if (statusResult.ExceptionMessage != null) statusCommandStderr = (statusCommandStderr ?? "") + (statusCommandStderr != null ? "\n" : "") + statusResult.ExceptionMessage; + var versionTask = _versionService.GetVersionInfoAsync(ct); var showTask = string.IsNullOrWhiteSpace(_options.StatusShowCommand) ? Task.FromResult(new ProcessRunResult(null, "", "", false)) : _processRunner.RunAsync(_options.StatusShowCommand, _options.StatusShowTimeout, ct); var logsTask = string.IsNullOrWhiteSpace(_options.LogsCommand) ? Task.FromResult(new ProcessRunResult(null, "", "", false)) : _processRunner.RunAsync(_options.LogsCommand, _options.LogsTimeout, ct); - await Task.WhenAll(showTask, logsTask); + await Task.WhenAll(versionTask, showTask, logsTask); + var versionInfo = await versionTask; var showResult = await showTask; var logsResult = await logsTask; var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand) @@ -100,7 +106,11 @@ public class StatusController : ControllerBase StatusShowOutput: statusShowOutput, LogsOutput: logsOutput, DhcpRangeStart: dhcpRangeStart, - DhcpRangeEnd: dhcpRangeEnd + DhcpRangeEnd: dhcpRangeEnd, + DnsmasqVersion: versionInfo.InstalledVersion?.ToString(), + MinimumSupportedDnsmasqVersion: versionInfo.MinimumVersion.ToString(), + DnsmasqVersionSupported: versionInfo.IsSupported, + DnsmasqVersionError: versionInfo.Error ); return Ok(status); } diff --git a/src/DnsmasqWebUI/Extensions/Hosting/WebApplicationExtensions.cs b/src/DnsmasqWebUI/Extensions/Hosting/WebApplicationExtensions.cs index c99cf81..5457dbe 100644 --- a/src/DnsmasqWebUI/Extensions/Hosting/WebApplicationExtensions.cs +++ b/src/DnsmasqWebUI/Extensions/Hosting/WebApplicationExtensions.cs @@ -1,6 +1,9 @@ using System.Net; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Diagnostics.HealthChecks; namespace DnsmasqWebUI.Extensions.Hosting; @@ -39,4 +42,22 @@ public static class WebApplicationExtensions return app; } + + /// + /// Maps the readiness health check at /healthz/ready (checks with tag "ready", returns JSON status). + /// + public static IEndpointRouteBuilder MapReadyHealthCheck(this IEndpointRouteBuilder endpoints) + { + endpoints.MapHealthChecks("/healthz/ready", new HealthCheckOptions + { + Predicate = check => check.Tags.Contains("ready"), + ResponseWriter = static async (context, report) => + { + context.Response.ContentType = "application/json"; + var status = report.Status == HealthStatus.Healthy ? "ok" : "unhealthy"; + await context.Response.WriteAsync($"{{\"status\":\"{status}\"}}", context.RequestAborted); + } + }); + return endpoints; + } } diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Dnsmasq/DnsmasqVersionParser.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Dnsmasq/DnsmasqVersionParser.cs new file mode 100644 index 0000000..c22be6e --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Dnsmasq/DnsmasqVersionParser.cs @@ -0,0 +1,21 @@ +using System.Text.RegularExpressions; + +namespace DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq; + +/// Parses dnsmasq version from command output (e.g. "dnsmasq --version" stdout/stderr). +public static class DnsmasqVersionParser +{ + private static readonly Regex Rx = new(@"\b(\d+)\.(\d+)(?:\.(\d+))?\b", RegexOptions.Compiled); + + /// Finds the first X.Y or X.Y.Z token in combined stdout and stderr; returns null if none found. + public static Version? TryParse(string? stdout, string? stderr) + { + var text = $"{stdout}\n{stderr}"; + var m = Rx.Match(text); + if (!m.Success) return null; + var major = int.Parse(m.Groups[1].Value); + var minor = int.Parse(m.Groups[2].Value); + var patch = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0; + return new Version(major, minor, patch); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/Abstractions/IDnsmasqVersionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/Abstractions/IDnsmasqVersionService.cs new file mode 100644 index 0000000..eb94ce2 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/Abstractions/IDnsmasqVersionService.cs @@ -0,0 +1,11 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions; + +/// Probes installed dnsmasq version and compares it to the configured minimum. +public interface IDnsmasqVersionService : IApplicationScopedService +{ + /// Runs the version command, parses output, and returns version info including support status. + Task GetVersionInfoAsync(CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionEnforcementHostedService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionEnforcementHostedService.cs new file mode 100644 index 0000000..edfa544 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionEnforcementHostedService.cs @@ -0,0 +1,39 @@ +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using Microsoft.Extensions.Options; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version; + +/// At startup, verifies dnsmasq version meets minimum when is true. +public sealed class DnsmasqVersionEnforcementHostedService : IApplicationHostedService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IOptions _options; + + public DnsmasqVersionEnforcementHostedService( + IServiceScopeFactory scopeFactory, + IOptions options) + { + _scopeFactory = scopeFactory; + _options = options; + } + + public async Task StartAsync(CancellationToken ct) + { + var opts = _options.Value; + if (!opts.EnforceMinimumVersion) return; + + using var scope = _scopeFactory.CreateScope(); + var versionService = scope.ServiceProvider.GetRequiredService(); + var info = await versionService.GetVersionInfoAsync(ct); + + if (!info.ProbeSucceeded) + throw new InvalidOperationException($"dnsmasq version probe failed: {info.Error}"); + + if (!info.IsSupported) + throw new InvalidOperationException( + $"Installed dnsmasq version {info.InstalledVersion} is below minimum {info.MinimumVersion}."); + } + + public Task StopAsync(CancellationToken ct) => Task.CompletedTask; +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionHealthCheck.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionHealthCheck.cs new file mode 100644 index 0000000..1e46525 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionHealthCheck.cs @@ -0,0 +1,54 @@ +using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions; +using DnsmasqWebUI.Models.Config; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version; + +/// Readiness check for /healthz/ready: dnsmasq version meets minimum and, when StatusCommand is configured, dnsmasq is running. +public sealed class DnsmasqVersionHealthCheck : IHealthCheck +{ + private readonly IDnsmasqVersionService _versionService; + private readonly IProcessRunner _processRunner; + private readonly DnsmasqOptions _options; + + public DnsmasqVersionHealthCheck( + IDnsmasqVersionService versionService, + IProcessRunner processRunner, + IOptions options) + { + _versionService = versionService; + _processRunner = processRunner; + _options = options.Value; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default) + { + var info = await _versionService.GetVersionInfoAsync(ct); + + if (!info.ProbeSucceeded) + return HealthCheckResult.Unhealthy("dnsmasq version probe failed", data: new Dictionary { ["error"] = info.Error ?? "" }); + + if (!info.IsSupported) + return HealthCheckResult.Unhealthy( + $"dnsmasq version {info.InstalledVersion} is below minimum {info.MinimumVersion}", + data: new Dictionary + { + ["installed"] = info.InstalledVersion?.ToString() ?? "", + ["minimum"] = info.MinimumVersion.ToString() + }); + + if (!string.IsNullOrWhiteSpace(_options.StatusCommand)) + { + var statusResult = await _processRunner.RunAsync(_options.StatusCommand, _options.StatusTimeout, ct); + var active = statusResult.ExitCode == 0 && !statusResult.TimedOut && statusResult.ExceptionMessage == null; + if (!active) + return HealthCheckResult.Unhealthy( + "dnsmasq is not running", + data: new Dictionary { ["error"] = statusResult.ExceptionMessage ?? (statusResult.TimedOut ? "status command timed out" : "status command failed") }); + } + + return HealthCheckResult.Healthy($"dnsmasq {info.InstalledVersion} (minimum {info.MinimumVersion})"); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionService.cs new file mode 100644 index 0000000..e8feb4c --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Version/DnsmasqVersionService.cs @@ -0,0 +1,64 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq; +using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions; +using DnsmasqWebUI.Models.Config; +using DnsmasqWebUI.Models.Dnsmasq; +using Microsoft.Extensions.Options; + +namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version; + +public sealed class DnsmasqVersionService : IDnsmasqVersionService +{ + private readonly DnsmasqOptions _options; + private readonly IProcessRunner _processRunner; + + public DnsmasqVersionService( + IOptions options, + IProcessRunner processRunner) + { + _options = options.Value; + _processRunner = processRunner; + } + + public async Task GetVersionInfoAsync(CancellationToken ct = default) + { + var command = _options.VersionCommand?.Trim(); + var minimumVersion = System.Version.TryParse(_options.MinimumVersion, out var minVer) + ? minVer + : new System.Version(2, 91); + + if (string.IsNullOrWhiteSpace(command)) + { + return new DnsmasqVersionInfo( + InstalledVersion: null, + MinimumVersion: minimumVersion, + ProbeSucceeded: false, + IsSupported: false, + ProbeCommand: "", + Error: "Version command is not configured."); + } + + var result = await _processRunner.RunAsync(command, _options.VersionTimeout, ct); + + var error = result.TimedOut + ? "Version command timed out." + : !string.IsNullOrWhiteSpace(result.ExceptionMessage) + ? result.ExceptionMessage + : null; + + var installed = DnsmasqVersionParser.TryParse(result.Stdout, result.Stderr); + if (installed == null && error == null) + error = "Could not parse version from command output."; + + var probeSucceeded = installed != null; + var isSupported = probeSucceeded && installed!.CompareTo(minimumVersion) >= 0; + + return new DnsmasqVersionInfo( + InstalledVersion: installed, + MinimumVersion: minimumVersion, + ProbeSucceeded: probeSucceeded, + IsSupported: isSupported, + ProbeCommand: command, + Error: error); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs index caaf4b1..25ef43a 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs @@ -1,6 +1,7 @@ using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using Microsoft.Extensions.Logging; @@ -14,6 +15,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService private readonly IConfigSetCache _configSetCache; private readonly IConfigValidationService _validationService; private readonly IReloadService _reloadService; + private readonly IDnsmasqVersionService _versionService; private readonly ILogger _logger; public EffectiveConfigSaveService( @@ -22,6 +24,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService IConfigSetCache configSetCache, IConfigValidationService validationService, IReloadService reloadService, + IDnsmasqVersionService versionService, ILogger logger) { _configSetService = configSetService; @@ -29,6 +32,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService _configSetCache = configSetCache; _validationService = validationService; _reloadService = reloadService; + _versionService = versionService; _logger = logger; } @@ -40,6 +44,44 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService if (changes.Count == 0) return EffectiveConfigSaveResult.NoChanges(); + var version = await _versionService.GetVersionInfoAsync(ct); + if (!version.ProbeSucceeded) + { + return new EffectiveConfigSaveResult( + BackupCreated: false, + BackupPath: null, + Saved: false, + Validated: false, + ValidationExitCode: -1, + ValidationStdOut: null, + ValidationStdErr: null, + Restarted: false, + RestartExitCode: -1, + RestartStdOut: null, + RestartStdErr: null, + ErrorCode: EffectiveConfigSaveResult.ErrorCodes.VersionProbeFailed, + UserMessage: string.IsNullOrWhiteSpace(version.Error) + ? "Cannot save: dnsmasq version could not be determined." + : $"Cannot save: dnsmasq version could not be determined. {version.Error}"); + } + if (!version.IsSupported) + { + return new EffectiveConfigSaveResult( + BackupCreated: false, + BackupPath: null, + Saved: false, + Validated: false, + ValidationExitCode: -1, + ValidationStdOut: null, + ValidationStdErr: null, + Restarted: false, + RestartExitCode: -1, + RestartStdOut: null, + RestartStdErr: null, + ErrorCode: EffectiveConfigSaveResult.ErrorCodes.UnsupportedVersion, + UserMessage: $"Installed dnsmasq {version.InstalledVersion} is below required {version.MinimumVersion}."); + } + var set = await _configSetService.GetConfigSetAsync(ct); if (string.IsNullOrWhiteSpace(set.ManagedFilePath)) { diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs index c42e6b4..6aaeff9 100644 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs @@ -64,6 +64,21 @@ public class DnsmasqOptions /// Timeout in seconds for . Default 10. public int ValidateTimeoutSeconds { get; set; } = 10; + /// Command to probe dnsmasq version (e.g. "dnsmasq --version"). Used for minimum-version checks. + public string? VersionCommand { get; set; } = "dnsmasq --version"; + + /// Timeout in seconds for . Default 5. + public int VersionTimeoutSeconds { get; set; } = 5; + + /// Minimum dnsmasq version required (e.g. "2.91"). Checked when is true. + public string MinimumVersion { get; set; } = "2.91"; + + /// When true, application fails to start if dnsmasq version probe fails or version is below . + public bool EnforceMinimumVersion { get; set; } = true; + + /// as . + public TimeSpan VersionTimeout => TimeSpan.FromSeconds(VersionTimeoutSeconds); + /// as . public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds); diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs index 4cdc7c9..4cb5fa5 100644 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs @@ -46,6 +46,11 @@ public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator maxTimeoutSeconds) failures.Add($"Dnsmasq:ValidateTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.ValidateTimeoutSeconds}."); + if (options.VersionTimeoutSeconds < minTimeoutSeconds || options.VersionTimeoutSeconds > maxTimeoutSeconds) + failures.Add($"Dnsmasq:VersionTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.VersionTimeoutSeconds}."); + + if (!string.IsNullOrWhiteSpace(options.VersionCommand) && !Version.TryParse(options.MinimumVersion, out _)) + failures.Add($"Dnsmasq:MinimumVersion must be a valid version (e.g. 2.91). Current value: {options.MinimumVersion}."); if (failures.Count == 0) return ValidateOptionsResult.Success; diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqServiceStatus.cs b/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqServiceStatus.cs index 487a05e..b09856e 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqServiceStatus.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqServiceStatus.cs @@ -37,6 +37,10 @@ namespace DnsmasqWebUI.Models.Dnsmasq; /// Output of LogsCommand (recent logs preview). Null when not configured or command produced no output. /// Start IP of the first dhcp-range= (e.g. 172.28.0.10). Null when not set or unparseable. /// End IP of the first dhcp-range= (e.g. 172.28.0.50). Null when not set or unparseable. +/// Installed dnsmasq version string (e.g. "2.91") from version probe. Null when probe not run or failed. +/// Configured minimum required dnsmasq version (e.g. "2.91"). +/// True when version probe succeeded and installed version is at least minimum. +/// Error message when version probe failed or could not parse; null on success. public record DnsmasqServiceStatus( string? SystemHostsPath, bool SystemHostsPathExists, @@ -68,5 +72,9 @@ public record DnsmasqServiceStatus( string? StatusShowOutput, string? LogsOutput, string? DhcpRangeStart, - string? DhcpRangeEnd + string? DhcpRangeEnd, + string? DnsmasqVersion, + string MinimumSupportedDnsmasqVersion, + bool DnsmasqVersionSupported, + string? DnsmasqVersionError ); diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqVersionInfo.cs b/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqVersionInfo.cs new file mode 100644 index 0000000..cb9f235 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/DnsmasqVersionInfo.cs @@ -0,0 +1,16 @@ +namespace DnsmasqWebUI.Models.Dnsmasq; + +/// Result of probing dnsmasq version and comparing to minimum required. +/// Parsed version from version command output; null if probe failed or could not parse. +/// Configured minimum required version. +/// True when the version command ran and output could be parsed. +/// True when probe succeeded and installed version is at least minimum. +/// The command that was run (e.g. "dnsmasq --version"). +/// Error message when probe failed or version could not be parsed; null on success. +public record DnsmasqVersionInfo( + Version? InstalledVersion, + Version MinimumVersion, + bool ProbeSucceeded, + bool IsSupported, + string ProbeCommand, + string? Error); diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs index acb7ba9..f372a2a 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs @@ -37,6 +37,9 @@ public record EffectiveConfigSaveResult( public const string WriteFailed = "write_failed"; public const string ValidateFailed = "validate_failed"; public const string RestartFailed = "restart_failed"; + public const string UnsupportedVersion = "unsupported_version"; + /// Version probe failed (timeout, command missing, unparseable output). Distinct from (probe succeeded but version below minimum). + public const string VersionProbeFailed = "version_probe_failed"; } /// True when config was written but validation failed (restart not attempted). diff --git a/src/DnsmasqWebUI/Program.cs b/src/DnsmasqWebUI/Program.cs index 8e6dac8..cf1b18c 100644 --- a/src/DnsmasqWebUI/Program.cs +++ b/src/DnsmasqWebUI/Program.cs @@ -1,9 +1,11 @@ using DnsmasqWebUI.Components; using DnsmasqWebUI.Infrastructure.Realtime.Hubs; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version; using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Extensions.DependencyInjection; using DnsmasqWebUI.Extensions.Hosting; using DnsmasqWebUI.Infrastructure.Helpers.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Options; // When not in Development, use the app's directory (not CWD) so static assets work when run via symlink or from any CWD. @@ -38,6 +40,9 @@ builder.Services.AddApplicationServices(); builder.Services.AddHttpContextAccessor(); builder.Services.AddDnsmasqApiHttpClients(); +builder.Services.AddHealthChecks() + .AddCheck("dnsmasq_version", failureStatus: HealthStatus.Unhealthy, tags: new[] { "ready" }); + builder.Services.AddControllers() .AddJsonOptions(o => ApiJsonOptions.ConfigureServer(o.JsonSerializerOptions)); builder.Services.AddSignalR(); @@ -71,6 +76,7 @@ app.UseAntiforgery(); // ---- Endpoints ---- app.MapControllers(); +app.MapReadyHealthCheck(); app.MapHub("/hubs/logs"); // UseStaticFiles: MapStaticAssets returns 0-byte responses for fingerprinted assets (known bug). Serve from wwwroot directly. app.UseStaticFiles();