mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
+5
-1
@@ -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
|
||||
|
||||
@@ -19,6 +19,8 @@ A self-hosted web UI for managing [dnsmasq](https://thekelleys.org.uk/dnsmasq/do
|
||||
- **DHCP host entries** (reservations) if you use DHCP; edit in the UI, written into the managed config.
|
||||
- **Reload dnsmasq** after config changes (configurable command, e.g. `systemctl reload dnsmasq` or `pkill -HUP -x dnsmasq`).
|
||||
- Optional **status** and **recent logs** commands (e.g. systemctl/journalctl or custom scripts) shown on the Dnsmasq page.
|
||||
- **Minimum dnsmasq version** check (configurable; e.g. 2.91). The UI can refuse to start or block config save if the detected version is too old. Version is shown on the Dnsmasq page with a link to release notes.
|
||||
- **Readiness endpoint** `GET /healthz/ready` for orchestration (Kubernetes, Docker, load balancers). Returns healthy when dnsmasq version meets the minimum and, if configured, dnsmasq is running. The Docker image includes a HEALTHCHECK that uses this endpoint.
|
||||
- **Self-contained Linux binaries** per OS/arch (RID); no .NET install required. Can run in Docker or directly on the host.
|
||||
|
||||
<!-- SUGGESTED SCREENSHOT: MAIN CONFIG OR CONFIG EDITOR PAGE IN THE BROWSER -->
|
||||
@@ -163,7 +165,7 @@ COPY entrypoint.sh /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
```
|
||||
|
||||
Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via environment variables or an `appsettings.json` in `/app`. Mount your config dir and expose 8080.
|
||||
Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via environment variables or an `appsettings.json` in `/app`. Mount your config dir and expose 8080. The image includes a **HEALTHCHECK** that calls `GET /healthz/ready`; orchestration (e.g. Kubernetes readiness probe, or Compose `depends_on: condition: service_healthy`) can use the same endpoint.
|
||||
|
||||
**Config summary for containers:**
|
||||
|
||||
@@ -173,8 +175,11 @@ Set `Dnsmasq__MainConfigPath`, `Dnsmasq__ReloadCommand`, and other options via e
|
||||
| `Dnsmasq__MainConfigPath` | `/data/dnsmasq.conf` | Main dnsmasq config path (required) |
|
||||
| `Dnsmasq__ReloadCommand` | `pkill -HUP -x dnsmasq` | Run after config changes |
|
||||
| `Dnsmasq__StatusShowCommand` | `/app/dnsmasq-status.sh` | Optional status output (e.g. our script in test harness) |
|
||||
| `Dnsmasq__MinimumVersion` | `2.91` | Minimum dnsmasq version (optional; default 2.91). Set `Dnsmasq__EnforceMinimumVersion=false` to allow older versions. |
|
||||
| `ASPNETCORE_URLS` | `http://+:8080` | Port the app listens on |
|
||||
|
||||
**Readiness:** `GET http://localhost:8080/healthz/ready` returns JSON `{"status":"ok"}` when dnsmasq version meets the minimum and (if `StatusCommand` is set) dnsmasq is running. Use this for Kubernetes readiness probes, Docker HEALTHCHECK, or load balancer health checks.
|
||||
|
||||
See [Configuration](#configuration) for all options.
|
||||
|
||||
<!-- SUGGESTED SCREENSHOT: EXAMPLE DOCKERFILE SNIPPET OR DOCKER RUN COMMAND SHOWING DNSMASQ + APP IN ONE CONTAINER -->
|
||||
@@ -205,6 +210,9 @@ The app is configured via **appsettings.json**, **environment variables**, and *
|
||||
| `StatusCommand` | Optional: check if dnsmasq is running | `pgrep -x dnsmasq` |
|
||||
| `StatusShowCommand` | Optional: full status output (e.g. systemctl status) | `systemctl status dnsmasq --no-pager` |
|
||||
| `LogsCommand` | Optional: recent logs (e.g. journalctl) | `journalctl -u dnsmasq -n 100 --no-pager` |
|
||||
| `VersionCommand` | Command to probe dnsmasq version (used for minimum-version check and UI display) | `dnsmasq --version` |
|
||||
| `MinimumVersion` | Minimum dnsmasq version required (e.g. 2.91). Some options need newer dnsmasq. | `2.91` |
|
||||
| `EnforceMinimumVersion` | If true, app fails to start when version probe fails or version is below minimum. If false, only save and readiness checks enforce. | `true` |
|
||||
|
||||
**Application options** (use `Application__` prefix for env, `Application` section in JSON):
|
||||
|
||||
|
||||
@@ -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]`
|
||||
- **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
|
||||
|
||||
|
||||
+14
-4
@@ -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:
|
||||
|
||||
@@ -24,6 +24,22 @@ else if (_error != null)
|
||||
else if (_status != null)
|
||||
{
|
||||
<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>
|
||||
<p class="mb-1"><strong>Config files (load order):</strong></p>
|
||||
<ul class="list-unstyled small ms-2 mb-0">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
@@ -17,17 +18,20 @@ public class StatusController : ControllerBase
|
||||
private readonly DnsmasqOptions _options;
|
||||
private readonly IDnsmasqConfigSetService _configSetService;
|
||||
private readonly IProcessRunner _processRunner;
|
||||
private readonly IDnsmasqVersionService _versionService;
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
|
||||
public StatusController(
|
||||
IOptions<DnsmasqOptions> options,
|
||||
IDnsmasqConfigSetService configSetService,
|
||||
IProcessRunner processRunner,
|
||||
IDnsmasqVersionService versionService,
|
||||
ILogger<StatusController> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_configSetService = configSetService;
|
||||
_processRunner = processRunner;
|
||||
_versionService = versionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -52,14 +56,16 @@ public class StatusController : ControllerBase
|
||||
if (statusResult.ExceptionMessage != null)
|
||||
statusCommandStderr = (statusCommandStderr ?? "") + (statusCommandStderr != null ? "\n" : "") + statusResult.ExceptionMessage;
|
||||
|
||||
var versionTask = _versionService.GetVersionInfoAsync(ct);
|
||||
var showTask = string.IsNullOrWhiteSpace(_options.StatusShowCommand)
|
||||
? Task.FromResult(new ProcessRunResult(null, "", "", false))
|
||||
: _processRunner.RunAsync(_options.StatusShowCommand, _options.StatusShowTimeout, ct);
|
||||
var logsTask = string.IsNullOrWhiteSpace(_options.LogsCommand)
|
||||
? Task.FromResult(new ProcessRunResult(null, "", "", false))
|
||||
: _processRunner.RunAsync(_options.LogsCommand, _options.LogsTimeout, ct);
|
||||
await Task.WhenAll(showTask, logsTask);
|
||||
await Task.WhenAll(versionTask, showTask, logsTask);
|
||||
|
||||
var versionInfo = await versionTask;
|
||||
var showResult = await showTask;
|
||||
var logsResult = await logsTask;
|
||||
var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand)
|
||||
@@ -100,7 +106,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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions.Hosting;
|
||||
|
||||
@@ -39,4 +42,22 @@ public static class WebApplicationExtensions
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the readiness health check at /healthz/ready (checks with tag "ready", returns JSON status).
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapReadyHealthCheck(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapHealthChecks("/healthz/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Tags.Contains("ready"),
|
||||
ResponseWriter = static async (context, report) =>
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
var status = report.Status == HealthStatus.Healthy ? "ok" : "unhealthy";
|
||||
await context.Response.WriteAsync($"{{\"status\":\"{status}\"}}", context.RequestAborted);
|
||||
}
|
||||
});
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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<EffectiveConfigSaveService> _logger;
|
||||
|
||||
public EffectiveConfigSaveService(
|
||||
@@ -22,6 +24,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
|
||||
IConfigSetCache configSetCache,
|
||||
IConfigValidationService validationService,
|
||||
IReloadService reloadService,
|
||||
IDnsmasqVersionService versionService,
|
||||
ILogger<EffectiveConfigSaveService> 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))
|
||||
{
|
||||
|
||||
@@ -64,6 +64,21 @@ public class DnsmasqOptions
|
||||
/// <summary>Timeout in seconds for <see cref="ValidateCommand"/>. Default 10.</summary>
|
||||
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>
|
||||
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}.");
|
||||
if (options.ValidateTimeoutSeconds < minTimeoutSeconds || options.ValidateTimeoutSeconds > 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;
|
||||
|
||||
@@ -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="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="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(
|
||||
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
|
||||
);
|
||||
|
||||
@@ -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 ValidateFailed = "validate_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>
|
||||
|
||||
@@ -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<DnsmasqVersionHealthCheck>("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<LogsHub>("/hubs/logs");
|
||||
// UseStaticFiles: MapStaticAssets returns 0-byte responses for fingerprinted assets (known bug). Serve from wwwroot directly.
|
||||
app.UseStaticFiles();
|
||||
|
||||
Reference in New Issue
Block a user