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
This commit is contained in:
2026-03-08 23:12:38 +10:00
parent a03e93bcd3
commit d910688742
19 changed files with 363 additions and 10 deletions
+5 -1
View File
@@ -52,8 +52,12 @@ RUN if [ "$DNSMASQ_VERSION" = "distro" ]; then \
&& apt-get autoremove -y --purge \ && apt-get autoremove -y --purge \
&& rm -rf /var/lib/apt/lists/*; \ && rm -rf /var/lib/apt/lists/*; \
fi 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 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 # .NET publish last - only this layer invalidates when code changes
COPY --from=publish /app/publish . COPY --from=publish /app/publish .
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f -s http://localhost:8080/healthz/ready || exit 1
+9 -1
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. - **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`). - **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. - 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. - **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 --> <!-- SUGGESTED SCREENSHOT: MAIN CONFIG OR CONFIG EDITOR PAGE IN THE BROWSER -->
@@ -163,7 +165,7 @@ COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/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:** **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__MainConfigPath` | `/data/dnsmasq.conf` | Main dnsmasq config path (required) |
| `Dnsmasq__ReloadCommand` | `pkill -HUP -x dnsmasq` | Run after config changes | | `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__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 | | `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. See [Configuration](#configuration) for all options.
<!-- SUGGESTED SCREENSHOT: EXAMPLE DOCKERFILE SNIPPET OR DOCKER RUN COMMAND SHOWING DNSMASQ + APP IN ONE CONTAINER --> <!-- SUGGESTED SCREENSHOT: EXAMPLE DOCKERFILE SNIPPET OR DOCKER RUN COMMAND SHOWING DNSMASQ + APP IN ONE CONTAINER -->
@@ -205,6 +210,9 @@ The app is configured via **appsettings.json**, **environment variables**, and *
| `StatusCommand` | Optional: check if dnsmasq is running | `pgrep -x dnsmasq` | | `StatusCommand` | Optional: check if dnsmasq is running | `pgrep -x dnsmasq` |
| `StatusShowCommand` | Optional: full status output (e.g. systemctl status) | `systemctl status dnsmasq --no-pager` | | `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` | | `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): **Application options** (use `Application__` prefix for env, `Application` section in JSON):
+1 -1
View File
@@ -30,7 +30,7 @@ This will:
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]` Then start manually with the version printed by the script: `TESTDATA_MOUNT=./testdata-mount DNSMASQ_VERSION=<resolved-version> docker compose -f docker-compose.test.yml up -d [--build]`
- **Preserve mount (e.g. keep leases):** `./scripts/prepare-test-mount.sh --no-build` (default mount behavior) - **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 ### Stop the harness
+14 -4
View File
@@ -18,6 +18,12 @@ services:
DNSMASQ_VERSION: ${DNSMASQ_VERSION:-latest} DNSMASQ_VERSION: ${DNSMASQ_VERSION:-latest}
ports: ports:
- "8080:8080" - "8080:8080"
healthcheck:
test: ["CMD", "curl", "-f", "-s", "http://localhost:8080/healthz/ready"]
interval: 30s
timeout: 5s
start_period: 5s
retries: 3
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
environment: environment:
@@ -46,7 +52,8 @@ services:
networks: networks:
testnet: {} testnet: {}
depends_on: depends_on:
- app app:
condition: service_healthy
# DHCP + periodic DNS lookups (every 20s) so DNS cache/logs show activity. # DHCP + periodic DNS lookups (every 20s) so DNS cache/logs show activity.
dhcp-client-dns-a: dhcp-client-dns-a:
@@ -65,7 +72,8 @@ services:
networks: networks:
testnet: {} testnet: {}
depends_on: depends_on:
- app app:
condition: service_healthy
# DHCP + periodic DNS lookups (every 45s); different interval for variety. # DHCP + periodic DNS lookups (every 45s); different interval for variety.
dhcp-client-dns-b: dhcp-client-dns-b:
@@ -84,7 +92,8 @@ services:
networks: networks:
testnet: {} testnet: {}
depends_on: depends_on:
- app app:
condition: service_healthy
# Third DHCP-only client so the leases table has more entries. # Third DHCP-only client so the leases table has more entries.
dhcp-client-2: dhcp-client-2:
@@ -93,7 +102,8 @@ services:
networks: networks:
testnet: {} testnet: {}
depends_on: depends_on:
- app app:
condition: service_healthy
networks: networks:
testnet: testnet:
@@ -24,6 +24,22 @@ else if (_error != null)
else if (_status != null) else if (_status != null)
{ {
<section class="page-section"> <section class="page-section">
@if (!string.IsNullOrWhiteSpace(_status.DnsmasqVersion) || !string.IsNullOrWhiteSpace(_status.DnsmasqVersionError))
{
<p class="text-muted small mb-1 text-end">
@if (!string.IsNullOrWhiteSpace(_status.DnsmasqVersion))
{
var versionTooltip = "Minimum required by this UI: " + _status.MinimumSupportedDnsmasqVersion + ". " + (_status.DnsmasqVersionSupported ? "Supported." : "Unsupported.");
<a href="https://thekelleys.org.uk/dnsmasq/CHANGELOG" target="_blank" rel="noopener noreferrer" class="text-muted text-decoration-none" title="@versionTooltip">
dnsmasq <code>@_status.DnsmasqVersion</code>
</a>
}
@if (!string.IsNullOrWhiteSpace(_status.DnsmasqVersionError))
{
<span class="text-warning ms-1" title="@_status.DnsmasqVersionError">@_status.DnsmasqVersionError</span>
}
</p>
}
<h2 class="page-section-title">Service configuration</h2> <h2 class="page-section-title">Service configuration</h2>
<p class="mb-1"><strong>Config files (load order):</strong></p> <p class="mb-1"><strong>Config files (load order):</strong></p>
<ul class="list-unstyled small ms-2 mb-0"> <ul class="list-unstyled small ms-2 mb-0">
@@ -1,5 +1,6 @@
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts; using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq;
@@ -17,17 +18,20 @@ public class StatusController : ControllerBase
private readonly DnsmasqOptions _options; private readonly DnsmasqOptions _options;
private readonly IDnsmasqConfigSetService _configSetService; private readonly IDnsmasqConfigSetService _configSetService;
private readonly IProcessRunner _processRunner; private readonly IProcessRunner _processRunner;
private readonly IDnsmasqVersionService _versionService;
private readonly ILogger<StatusController> _logger; private readonly ILogger<StatusController> _logger;
public StatusController( public StatusController(
IOptions<DnsmasqOptions> options, IOptions<DnsmasqOptions> options,
IDnsmasqConfigSetService configSetService, IDnsmasqConfigSetService configSetService,
IProcessRunner processRunner, IProcessRunner processRunner,
IDnsmasqVersionService versionService,
ILogger<StatusController> logger) ILogger<StatusController> logger)
{ {
_options = options.Value; _options = options.Value;
_configSetService = configSetService; _configSetService = configSetService;
_processRunner = processRunner; _processRunner = processRunner;
_versionService = versionService;
_logger = logger; _logger = logger;
} }
@@ -52,14 +56,16 @@ public class StatusController : ControllerBase
if (statusResult.ExceptionMessage != null) if (statusResult.ExceptionMessage != null)
statusCommandStderr = (statusCommandStderr ?? "") + (statusCommandStderr != null ? "\n" : "") + statusResult.ExceptionMessage; statusCommandStderr = (statusCommandStderr ?? "") + (statusCommandStderr != null ? "\n" : "") + statusResult.ExceptionMessage;
var versionTask = _versionService.GetVersionInfoAsync(ct);
var showTask = string.IsNullOrWhiteSpace(_options.StatusShowCommand) var showTask = string.IsNullOrWhiteSpace(_options.StatusShowCommand)
? Task.FromResult(new ProcessRunResult(null, "", "", false)) ? Task.FromResult(new ProcessRunResult(null, "", "", false))
: _processRunner.RunAsync(_options.StatusShowCommand, _options.StatusShowTimeout, ct); : _processRunner.RunAsync(_options.StatusShowCommand, _options.StatusShowTimeout, ct);
var logsTask = string.IsNullOrWhiteSpace(_options.LogsCommand) var logsTask = string.IsNullOrWhiteSpace(_options.LogsCommand)
? Task.FromResult(new ProcessRunResult(null, "", "", false)) ? Task.FromResult(new ProcessRunResult(null, "", "", false))
: _processRunner.RunAsync(_options.LogsCommand, _options.LogsTimeout, ct); : _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 showResult = await showTask;
var logsResult = await logsTask; var logsResult = await logsTask;
var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand) var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand)
@@ -100,7 +106,11 @@ public class StatusController : ControllerBase
StatusShowOutput: statusShowOutput, StatusShowOutput: statusShowOutput,
LogsOutput: logsOutput, LogsOutput: logsOutput,
DhcpRangeStart: dhcpRangeStart, DhcpRangeStart: dhcpRangeStart,
DhcpRangeEnd: dhcpRangeEnd DhcpRangeEnd: dhcpRangeEnd,
DnsmasqVersion: versionInfo.InstalledVersion?.ToString(),
MinimumSupportedDnsmasqVersion: versionInfo.MinimumVersion.ToString(),
DnsmasqVersionSupported: versionInfo.IsSupported,
DnsmasqVersionError: versionInfo.Error
); );
return Ok(status); return Ok(status);
} }
@@ -1,6 +1,9 @@
using System.Net; using System.Net;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace DnsmasqWebUI.Extensions.Hosting; namespace DnsmasqWebUI.Extensions.Hosting;
@@ -39,4 +42,22 @@ public static class WebApplicationExtensions
return app; return app;
} }
/// <summary>
/// Maps the readiness health check at /healthz/ready (checks with tag "ready", returns JSON status).
/// </summary>
public static IEndpointRouteBuilder MapReadyHealthCheck(this IEndpointRouteBuilder endpoints)
{
endpoints.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = static async (context, report) =>
{
context.Response.ContentType = "application/json";
var status = report.Status == HealthStatus.Healthy ? "ok" : "unhealthy";
await context.Response.WriteAsync($"{{\"status\":\"{status}\"}}", context.RequestAborted);
}
});
return endpoints;
}
} }
@@ -0,0 +1,21 @@
using System.Text.RegularExpressions;
namespace DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq;
/// <summary>Parses dnsmasq version from command output (e.g. "dnsmasq --version" stdout/stderr).</summary>
public static class DnsmasqVersionParser
{
private static readonly Regex Rx = new(@"\b(\d+)\.(\d+)(?:\.(\d+))?\b", RegexOptions.Compiled);
/// <summary>Finds the first X.Y or X.Y.Z token in combined stdout and stderr; returns null if none found.</summary>
public static Version? TryParse(string? stdout, string? stderr)
{
var text = $"{stdout}\n{stderr}";
var m = Rx.Match(text);
if (!m.Success) return null;
var major = int.Parse(m.Groups[1].Value);
var minor = int.Parse(m.Groups[2].Value);
var patch = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0;
return new Version(major, minor, patch);
}
}
@@ -0,0 +1,11 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq;
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
/// <summary>Probes installed dnsmasq version and compares it to the configured minimum.</summary>
public interface IDnsmasqVersionService : IApplicationScopedService
{
/// <summary>Runs the version command, parses output, and returns version info including support status.</summary>
Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default);
}
@@ -0,0 +1,39 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version;
/// <summary>At startup, verifies dnsmasq version meets minimum when <see cref="Models.Config.DnsmasqOptions.EnforceMinimumVersion"/> is true.</summary>
public sealed class DnsmasqVersionEnforcementHostedService : IApplicationHostedService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions<Models.Config.DnsmasqOptions> _options;
public DnsmasqVersionEnforcementHostedService(
IServiceScopeFactory scopeFactory,
IOptions<Models.Config.DnsmasqOptions> options)
{
_scopeFactory = scopeFactory;
_options = options;
}
public async Task StartAsync(CancellationToken ct)
{
var opts = _options.Value;
if (!opts.EnforceMinimumVersion) return;
using var scope = _scopeFactory.CreateScope();
var versionService = scope.ServiceProvider.GetRequiredService<IDnsmasqVersionService>();
var info = await versionService.GetVersionInfoAsync(ct);
if (!info.ProbeSucceeded)
throw new InvalidOperationException($"dnsmasq version probe failed: {info.Error}");
if (!info.IsSupported)
throw new InvalidOperationException(
$"Installed dnsmasq version {info.InstalledVersion} is below minimum {info.MinimumVersion}.");
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
@@ -0,0 +1,54 @@
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version;
/// <summary>Readiness check for /healthz/ready: dnsmasq version meets minimum and, when StatusCommand is configured, dnsmasq is running.</summary>
public sealed class DnsmasqVersionHealthCheck : IHealthCheck
{
private readonly IDnsmasqVersionService _versionService;
private readonly IProcessRunner _processRunner;
private readonly DnsmasqOptions _options;
public DnsmasqVersionHealthCheck(
IDnsmasqVersionService versionService,
IProcessRunner processRunner,
IOptions<DnsmasqOptions> options)
{
_versionService = versionService;
_processRunner = processRunner;
_options = options.Value;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
{
var info = await _versionService.GetVersionInfoAsync(ct);
if (!info.ProbeSucceeded)
return HealthCheckResult.Unhealthy("dnsmasq version probe failed", data: new Dictionary<string, object> { ["error"] = info.Error ?? "" });
if (!info.IsSupported)
return HealthCheckResult.Unhealthy(
$"dnsmasq version {info.InstalledVersion} is below minimum {info.MinimumVersion}",
data: new Dictionary<string, object>
{
["installed"] = info.InstalledVersion?.ToString() ?? "",
["minimum"] = info.MinimumVersion.ToString()
});
if (!string.IsNullOrWhiteSpace(_options.StatusCommand))
{
var statusResult = await _processRunner.RunAsync(_options.StatusCommand, _options.StatusTimeout, ct);
var active = statusResult.ExitCode == 0 && !statusResult.TimedOut && statusResult.ExceptionMessage == null;
if (!active)
return HealthCheckResult.Unhealthy(
"dnsmasq is not running",
data: new Dictionary<string, object> { ["error"] = statusResult.ExceptionMessage ?? (statusResult.TimedOut ? "status command timed out" : "status command failed") });
}
return HealthCheckResult.Healthy($"dnsmasq {info.InstalledVersion} (minimum {info.MinimumVersion})");
}
}
@@ -0,0 +1,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<DnsmasqOptions> options,
IProcessRunner processRunner)
{
_options = options.Value;
_processRunner = processRunner;
}
public async Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default)
{
var command = _options.VersionCommand?.Trim();
var minimumVersion = System.Version.TryParse(_options.MinimumVersion, out var minVer)
? minVer
: new System.Version(2, 91);
if (string.IsNullOrWhiteSpace(command))
{
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);
}
}
@@ -1,6 +1,7 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -14,6 +15,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
private readonly IConfigSetCache _configSetCache; private readonly IConfigSetCache _configSetCache;
private readonly IConfigValidationService _validationService; private readonly IConfigValidationService _validationService;
private readonly IReloadService _reloadService; private readonly IReloadService _reloadService;
private readonly IDnsmasqVersionService _versionService;
private readonly ILogger<EffectiveConfigSaveService> _logger; private readonly ILogger<EffectiveConfigSaveService> _logger;
public EffectiveConfigSaveService( public EffectiveConfigSaveService(
@@ -22,6 +24,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
IConfigSetCache configSetCache, IConfigSetCache configSetCache,
IConfigValidationService validationService, IConfigValidationService validationService,
IReloadService reloadService, IReloadService reloadService,
IDnsmasqVersionService versionService,
ILogger<EffectiveConfigSaveService> logger) ILogger<EffectiveConfigSaveService> logger)
{ {
_configSetService = configSetService; _configSetService = configSetService;
@@ -29,6 +32,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
_configSetCache = configSetCache; _configSetCache = configSetCache;
_validationService = validationService; _validationService = validationService;
_reloadService = reloadService; _reloadService = reloadService;
_versionService = versionService;
_logger = logger; _logger = logger;
} }
@@ -40,6 +44,44 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
if (changes.Count == 0) if (changes.Count == 0)
return EffectiveConfigSaveResult.NoChanges(); 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); var set = await _configSetService.GetConfigSetAsync(ct);
if (string.IsNullOrWhiteSpace(set.ManagedFilePath)) if (string.IsNullOrWhiteSpace(set.ManagedFilePath))
{ {
@@ -64,6 +64,21 @@ public class DnsmasqOptions
/// <summary>Timeout in seconds for <see cref="ValidateCommand"/>. Default 10.</summary> /// <summary>Timeout in seconds for <see cref="ValidateCommand"/>. Default 10.</summary>
public int ValidateTimeoutSeconds { get; set; } = 10; public int ValidateTimeoutSeconds { get; set; } = 10;
/// <summary>Command to probe dnsmasq version (e.g. "dnsmasq --version"). Used for minimum-version checks.</summary>
public string? VersionCommand { get; set; } = "dnsmasq --version";
/// <summary>Timeout in seconds for <see cref="VersionCommand"/>. Default 5.</summary>
public int VersionTimeoutSeconds { get; set; } = 5;
/// <summary>Minimum dnsmasq version required (e.g. "2.91"). Checked when <see cref="EnforceMinimumVersion"/> is true.</summary>
public string MinimumVersion { get; set; } = "2.91";
/// <summary>When true, application fails to start if dnsmasq version probe fails or version is below <see cref="MinimumVersion"/>.</summary>
public bool EnforceMinimumVersion { get; set; } = true;
/// <summary><see cref="VersionTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan VersionTimeout => TimeSpan.FromSeconds(VersionTimeoutSeconds);
/// <summary><see cref="RestartTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary> /// <summary><see cref="RestartTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds); public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds);
@@ -46,6 +46,11 @@ public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator<Dnsma
failures.Add($"Dnsmasq:LogsTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.LogsTimeoutSeconds}."); failures.Add($"Dnsmasq:LogsTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.LogsTimeoutSeconds}.");
if (options.ValidateTimeoutSeconds < minTimeoutSeconds || options.ValidateTimeoutSeconds > maxTimeoutSeconds) if (options.ValidateTimeoutSeconds < minTimeoutSeconds || options.ValidateTimeoutSeconds > maxTimeoutSeconds)
failures.Add($"Dnsmasq:ValidateTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.ValidateTimeoutSeconds}."); 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) if (failures.Count == 0)
return ValidateOptionsResult.Success; return ValidateOptionsResult.Success;
@@ -37,6 +37,10 @@ namespace DnsmasqWebUI.Models.Dnsmasq;
/// <param name="LogsOutput">Output of LogsCommand (recent logs preview). Null when not configured or command produced no output.</param> /// <param name="LogsOutput">Output of LogsCommand (recent logs preview). Null when not configured or command produced no output.</param>
/// <param name="DhcpRangeStart">Start IP of the first dhcp-range= (e.g. 172.28.0.10). Null when not set or unparseable.</param> /// <param name="DhcpRangeStart">Start IP of the first dhcp-range= (e.g. 172.28.0.10). Null when not set or unparseable.</param>
/// <param name="DhcpRangeEnd">End IP of the first dhcp-range= (e.g. 172.28.0.50). Null when not set or unparseable.</param> /// <param name="DhcpRangeEnd">End IP of the first dhcp-range= (e.g. 172.28.0.50). Null when not set or unparseable.</param>
/// <param name="DnsmasqVersion">Installed dnsmasq version string (e.g. "2.91") from version probe. Null when probe not run or failed.</param>
/// <param name="MinimumSupportedDnsmasqVersion">Configured minimum required dnsmasq version (e.g. "2.91").</param>
/// <param name="DnsmasqVersionSupported">True when version probe succeeded and installed version is at least minimum.</param>
/// <param name="DnsmasqVersionError">Error message when version probe failed or could not parse; null on success.</param>
public record DnsmasqServiceStatus( public record DnsmasqServiceStatus(
string? SystemHostsPath, string? SystemHostsPath,
bool SystemHostsPathExists, bool SystemHostsPathExists,
@@ -68,5 +72,9 @@ public record DnsmasqServiceStatus(
string? StatusShowOutput, string? StatusShowOutput,
string? LogsOutput, string? LogsOutput,
string? DhcpRangeStart, string? DhcpRangeStart,
string? DhcpRangeEnd string? DhcpRangeEnd,
string? DnsmasqVersion,
string MinimumSupportedDnsmasqVersion,
bool DnsmasqVersionSupported,
string? DnsmasqVersionError
); );
@@ -0,0 +1,16 @@
namespace DnsmasqWebUI.Models.Dnsmasq;
/// <summary>Result of probing dnsmasq version and comparing to minimum required.</summary>
/// <param name="InstalledVersion">Parsed version from version command output; null if probe failed or could not parse.</param>
/// <param name="MinimumVersion">Configured minimum required version.</param>
/// <param name="ProbeSucceeded">True when the version command ran and output could be parsed.</param>
/// <param name="IsSupported">True when probe succeeded and installed version is at least minimum.</param>
/// <param name="ProbeCommand">The command that was run (e.g. "dnsmasq --version").</param>
/// <param name="Error">Error message when probe failed or version could not be parsed; null on success.</param>
public record DnsmasqVersionInfo(
Version? InstalledVersion,
Version MinimumVersion,
bool ProbeSucceeded,
bool IsSupported,
string ProbeCommand,
string? Error);
@@ -37,6 +37,9 @@ public record EffectiveConfigSaveResult(
public const string WriteFailed = "write_failed"; public const string WriteFailed = "write_failed";
public const string ValidateFailed = "validate_failed"; public const string ValidateFailed = "validate_failed";
public const string RestartFailed = "restart_failed"; public const string RestartFailed = "restart_failed";
public const string UnsupportedVersion = "unsupported_version";
/// <summary>Version probe failed (timeout, command missing, unparseable output). Distinct from <see cref="UnsupportedVersion"/> (probe succeeded but version below minimum).</summary>
public const string VersionProbeFailed = "version_probe_failed";
} }
/// <summary>True when config was written but validation failed (restart not attempted).</summary> /// <summary>True when config was written but validation failed (restart not attempted).</summary>
+6
View File
@@ -1,9 +1,11 @@
using DnsmasqWebUI.Components; using DnsmasqWebUI.Components;
using DnsmasqWebUI.Infrastructure.Realtime.Hubs; using DnsmasqWebUI.Infrastructure.Realtime.Hubs;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Extensions.DependencyInjection; using DnsmasqWebUI.Extensions.DependencyInjection;
using DnsmasqWebUI.Extensions.Hosting; using DnsmasqWebUI.Extensions.Hosting;
using DnsmasqWebUI.Infrastructure.Helpers.Http; using DnsmasqWebUI.Infrastructure.Helpers.Http;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options; 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. // 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.AddHttpContextAccessor();
builder.Services.AddDnsmasqApiHttpClients(); builder.Services.AddDnsmasqApiHttpClients();
builder.Services.AddHealthChecks()
.AddCheck<DnsmasqVersionHealthCheck>("dnsmasq_version", failureStatus: HealthStatus.Unhealthy, tags: new[] { "ready" });
builder.Services.AddControllers() builder.Services.AddControllers()
.AddJsonOptions(o => ApiJsonOptions.ConfigureServer(o.JsonSerializerOptions)); .AddJsonOptions(o => ApiJsonOptions.ConfigureServer(o.JsonSerializerOptions));
builder.Services.AddSignalR(); builder.Services.AddSignalR();
@@ -71,6 +76,7 @@ app.UseAntiforgery();
// ---- Endpoints ---- // ---- Endpoints ----
app.MapControllers(); app.MapControllers();
app.MapReadyHealthCheck();
app.MapHub<LogsHub>("/hubs/logs"); app.MapHub<LogsHub>("/hubs/logs");
// UseStaticFiles: MapStaticAssets returns 0-byte responses for fingerprinted assets (known bug). Serve from wwwroot directly. // UseStaticFiles: MapStaticAssets returns 0-byte responses for fingerprinted assets (known bug). Serve from wwwroot directly.
app.UseStaticFiles(); app.UseStaticFiles();