Process runner, save/restore flow, status script, listen-address IPv6

- Process: Add IProcessHandle, ProcessOutputLine, StartAsync; RunAsync via StartAsync+WaitForExitAsync. Wait for stdout/stderr stream close before reading to fix empty restart output. ProcessTimeouts; ReloadService/StatusController/LogsService use config timeouts.
- Effective config: Rename reload→restart in save/restore result types and UI. Save modal: show restart output, danger alert, Stop editing/Continue editing (yellow), footer one-line; fallback exit code when no output; ensure pre visibility (CSS). Restore failed: combined output helper.
- Status script: When dnsmasq dead, run dnsmasq --test and show config error in status block (Docker harness; DNSMASQ_CONF).
- listen-address: Allow IPv6 (ValidateDraftValue accepts InterNetworkV6).
- Docker test: RestartCommand uses DnsmasqOptions timeout; DnsmasqOptions RestartTimeoutSeconds.
This commit is contained in:
2026-03-01 22:23:17 +10:00
parent a776f445b6
commit 8ed66cdf48
29 changed files with 499 additions and 183 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ services:
Dnsmasq__MainConfigPath: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf
# No systemd in container: restart by killing and re-running dnsmasq (same as entrypoint.sh). Config path must match DNSMASQ_CONF/MainConfigPath.
Dnsmasq__RestartCommand: "pkill -x dnsmasq || true; sleep 1; dnsmasq --test --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} >/tmp/dnsmasq-restart.err 2>&1 || { cat /tmp/dnsmasq-restart.err >&2; exit 1; }; dnsmasq -k --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} >/tmp/dnsmasq-restart.err 2>&1 & sleep 1; pgrep -x dnsmasq >/dev/null || { cat /tmp/dnsmasq-restart.err >&2; exit 1; }"
Dnsmasq__RestartCommand: "pkill -x dnsmasq || true; sleep 1; dnsmasq --test --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} && dnsmasq --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}"
Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
+10
View File
@@ -1,12 +1,22 @@
#!/bin/sh
# Simulates "systemctl status dnsmasq" output when run in a container without systemd.
# Uses ps/pgrep. Memory is RSS. Uptime line omitted; Active line has break after semicolon with continuation indented.
# When dnsmasq is not running, runs dnsmasq --test to show config/startup errors (use DNSMASQ_CONF for config path).
pid=$(pgrep -x dnsmasq)
if [ -z "$pid" ]; then
echo "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server"
echo " Loaded: (container, no systemd)"
echo " Active: inactive (dead)"
conf="${DNSMASQ_CONF:-/etc/dnsmasq.conf}"
if [ -f "$conf" ]; then
err=$(dnsmasq --test --conf-file="$conf" 2>&1)
if [ -n "$err" ]; then
echo ""
echo " Config test (dnsmasq --test):"
echo "$err" | sed 's/^/ /'
fi
fi
echo ""
exit 0
fi
@@ -16,7 +16,7 @@
<div class="input-group input-group-sm mb-2">
<input type="text" class="form-control" placeholder="e.g. Microsoft.AspNetCore."
@bind="_newPrefix" @bind:event="oninput" @onkeydown="OnNewPrefixKeydown" />
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)">Add</button>
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)"><i class="bi bi-plus me-1" aria-hidden="true"></i>Add</button>
</div>
<ul class="list-group list-group-flush" style="max-height: 12rem; overflow-y: auto;">
@foreach (var prefix in _prefixes)
@@ -35,10 +35,10 @@
</ul>
</div>
<div class="modal-footer d-flex justify-content-between align-items-center">
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving">Restore defaults</button>
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore defaults</button>
<div>
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@(_saving ? "Saving…" : "Save")</button>
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@if (_saving) { <span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> } else { <i class="bi bi-floppy-fill me-1" aria-hidden="true"></i> }@(_saving ? "Saving…" : "Save")</button>
</div>
</div>
</div>
@@ -27,14 +27,14 @@
<LogsPreView Id="dnsmasq-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
}
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing">Refresh</button>
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
@if (_status.LogsPath != null)
{
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm"><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</a>
}
else
{
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download.">Download logs</button>
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download."><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</button>
}
</div>
}
@@ -25,6 +25,6 @@
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
}
<div class="mt-2">
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
</div>
}
@@ -3,7 +3,7 @@
<div class="d-flex align-items-center gap-1 @CssClass">
<code class="flex-grow-1 text-break @CodeCssClass">@Text</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyAsync" title="@CopyTitle">Copy</button>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyAsync" title="@CopyTitle"><i class="bi bi-clipboard me-1" aria-hidden="true"></i>Copy</button>
</div>
@code {
@@ -26,7 +26,7 @@
</CascadingValue>
</CascadingValue>
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.PendingChanges" Status="@Status" Session="@Session"
OnClose="CloseSaveModal" OnSaveCompleted="OnSaveCompleted" />
OnClose="CloseSaveModal" OnSaveCompleted="OnSaveCompleted" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
</div>
}
@@ -198,4 +198,11 @@
_showSaveModal = false;
StateHasChanged();
}
private void CloseSaveModalAndEndEdit()
{
_showSaveModal = false;
Session.ExitEditModeDiscard();
StateHasChanged();
}
}
@@ -208,8 +208,8 @@ else
foreach (var raw in values)
{
var v = raw.Trim();
if (!IPAddress.TryParse(v, out var ip) || ip.AddressFamily != AddressFamily.InterNetwork)
return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 address.";
if (!IPAddress.TryParse(v, out var ip) || (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6))
return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 or IPv6 address.";
}
}
return null;
@@ -18,7 +18,8 @@
OnClose="@OnClose"
OnSave="@HandleSave"
OnRestore="@HandleRestore"
OnReverted="@HandleReverted" />
OnReverted="@HandleReverted"
OnCloseAndEndEdit="@OnCloseAndEndEdit" />
}
@code {
@@ -37,6 +38,7 @@
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnSaveCompleted { get; set; }
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
private SaveUiState _saveState = SaveUiState.Review;
@@ -70,9 +72,9 @@
var result = await Session.ApplyAsync();
_saveResult = result;
if (result.Saved && result.Reloaded)
if (result.Saved && result.Restarted)
{
var message = result.UserMessage ?? "Config saved and dnsmasq reloaded.";
var message = result.UserMessage ?? "Config saved and dnsmasq restarted.";
NotificationService.ShowSuccess(message);
_saveState = SaveUiState.Review;
_saveResult = null;
@@ -81,7 +83,7 @@
return;
}
if (result.Saved && !result.Reloaded)
if (result.Saved && !result.Restarted)
_saveState = SaveUiState.ReloadFailed;
else if (!result.Saved)
_saveState = SaveUiState.WriteFailed;
@@ -102,9 +104,9 @@
var result = await SaveService.RestoreAsync(backupPath);
_restoreResult = result;
if (result.Restored && result.Reloaded)
if (result.Restored && result.Restarted)
{
var message = result.UserMessage ?? "Backup restored and dnsmasq reloaded.";
var message = result.UserMessage ?? "Backup restored and dnsmasq restarted.";
NotificationService.ShowSuccess(message);
_saveState = SaveUiState.Review;
_saveResult = null;
@@ -15,24 +15,27 @@
{
<div class="ec-save-modal-loading text-center py-4">
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div>
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and reloading…" : "Restoring backup and reloading…")</p>
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and restarting…" : "Restoring backup and restarting…")</p>
</div>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
{
<div class="alert alert-danger mb-0">
<strong>Save failed.</strong> @SaveResult.UserMessage
@if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr))
@if (!string.IsNullOrEmpty(SaveResult.RestartStdErr))
{
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.ReloadStdErr</pre>
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.RestartStdErr</pre>
}
</div>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult != null)
{
<div class="ec-save-modal-reload-failed">
<div class="alert alert-warning mb-3">
<strong>Config was saved, but reload failed.</strong> You can restore the backup or keep editing.
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
<div class="ec-save-modal-alert-text">
<strong>Config was saved, but the restart command failed.</strong> You can restore the backup or keep editing.
</div>
</div>
@if (SaveResult.BackupPath != null)
{
@@ -40,28 +43,23 @@
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
</p>
}
@if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr))
{
<div class="ec-save-modal-stderr mb-3">
<button type="button" class="btn btn-sm btn-outline-secondary mb-2 @(_stderrExpanded ? "active" : "")" @onclick="() => _stderrExpanded = !_stderrExpanded" aria-expanded="@_stderrExpanded">
@(_stderrExpanded ? "Hide" : "Show") reload stderr
</button>
@if (_stderrExpanded)
{
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0">@SaveResult.ReloadStdErr</pre>
}
</div>
}
<div class="ec-save-modal-stderr mb-3">
<p class="text-muted small mb-1"><strong>Command output</strong></p>
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestartFailedOutput(SaveResult)</pre>
</div>
</div>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed && RestoreResult != null)
{
<div class="alert alert-warning mb-0">
@RestoreResult.UserMessage
@if (!string.IsNullOrEmpty(RestoreResult.ReloadStdErr))
{
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@RestoreResult.ReloadStdErr</pre>
}
<div class="ec-save-modal-restore-failed">
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
<div class="ec-save-modal-alert-text">@RestoreResult.UserMessage</div>
</div>
<div class="ec-save-modal-stderr mb-3">
<p class="text-muted small mb-1"><strong>Command output</strong></p>
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestoreFailedOutput(RestoreResult)</pre>
</div>
</div>
}
else
@@ -122,7 +120,7 @@
}
</div>
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="() => Revert(change)" title="Revert this change" aria-label="Revert @change.OptionName">
Revert
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
</button>
</div>
}
@@ -130,24 +128,26 @@
}
}
</div>
<div class="modal-footer">
<div class="modal-footer ec-save-modal-footer">
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult?.BackupPath != null)
{
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)">Restore backup</button>
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Continue editing</button>
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backup</button>
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
{
<button type="button" class="btn btn-secondary" @onclick="Close">Continue editing</button>
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
{
<button type="button" class="btn btn-secondary" @onclick="Close">Continue editing</button>
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
}
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
{
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0)">Save</button>
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0)"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
}
</div>
</div>
@@ -166,13 +166,32 @@
[Parameter] public EventCallback OnSave { get; set; }
[Parameter] public EventCallback<string> OnRestore { get; set; }
[Parameter] public EventCallback OnReverted { get; set; }
private bool _stderrExpanded;
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress;
private string? ManagedFilePath => Status?.ManagedFilePath;
private static string GetRestartFailedOutput(EffectiveConfigSaveResult result)
{
var err = (result.RestartStdErr ?? "").Trim();
var outp = (result.RestartStdOut ?? "").Trim();
if (err.Length > 0 && outp.Length > 0)
return err + "\n\n" + outp;
if (err.Length > 0) return err;
if (outp.Length > 0) return outp;
var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : "";
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
}
private static string GetRestoreFailedOutput(EffectiveConfigRestoreResult result)
{
var err = (result.RestartStdErr ?? "").Trim();
if (err.Length > 0) return err;
var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : "";
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
}
/// <summary>If the value is a list of strings (multi-value option), return it; otherwise null.</summary>
private static IReadOnlyList<string>? AsStringList(object? v)
{
@@ -219,4 +238,10 @@
{
await OnRestore.InvokeAsync(backupPath);
}
private async Task CloseAndEndEdit()
{
if (!AllowClose) return;
await OnCloseAndEndEdit.InvokeAsync();
}
}
@@ -1,3 +1,8 @@
/* Keep footer buttons on one line */
.ec-save-modal-footer {
flex-wrap: nowrap;
}
/* Ensure the dialog stacks above the backdrop when both are siblings inside .modal */
.modal-dialog {
position: relative;
@@ -54,8 +59,29 @@
word-break: break-all;
}
/* Command output: ensure text is always visible (avoid inherited color/visibility issues) */
.ec-save-modal-pre {
font-size: 0.8125rem;
max-height: 12rem;
overflow: auto;
color: var(--bs-body-color, #212529);
white-space: pre-wrap;
word-break: break-word;
min-height: 2.5rem;
}
/* Alert icon in reload/restore failed states: larger, spacing, aligned with text */
.ec-save-modal-alert {
--ec-save-modal-alert-icon-gap: 0.5rem;
}
.ec-save-modal-alert-icon {
flex-shrink: 0;
font-size: 1.25rem;
margin-right: var(--ec-save-modal-alert-icon-gap);
line-height: 1;
}
.ec-save-modal-alert-text {
min-width: 0;
}
@@ -146,8 +146,8 @@
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save">Save</button>
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
</div>
</div>
</dialog>
+11 -11
View File
@@ -97,7 +97,7 @@ else
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" title="Add ignore flag so dnsmasq will not assign this client a lease" /></td>
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
<td class="text-muted small">—</td>
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button></td>
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button></td>
}
else
{
@@ -203,7 +203,7 @@ else
@if (editable)
{
<div class="table-card-row mt-2">
<button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button>
<button class="btn btn-sm btn-danger" @onclick="() => Delete(e)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
</div>
}
</div>
@@ -212,11 +212,11 @@ else
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="Add">Add host</button>
<button class="btn btn-primary" @onclick="Save" disabled="@_saving">Save</button>
<button class="btn btn-primary" @onclick="Add"><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add host</button>
<button class="btn btn-primary" @onclick="Save" disabled="@_saving"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
@if (_status.ReloadCommandConfigured)
{
<button class="btn btn-primary" @onclick="Reload" disabled="@_saving">Reload config</button>
<button class="btn btn-primary" @onclick="Reload" disabled="@_saving"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload config</button>
}
</div>
}
@@ -253,7 +253,7 @@ else
{
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
</div>
}
else
@@ -286,8 +286,8 @@ else
<td>@e.ClientId</td>
<td>
<div class="d-flex flex-wrap gap-1">
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)">Add static</button>
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)">Ignore</button>
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)"><i class="bi bi-plus me-1" aria-hidden="true"></i>Add static</button>
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)"><i class="bi bi-slash-circle me-1" aria-hidden="true"></i>Ignore</button>
</div>
</td>
</tr>
@@ -309,8 +309,8 @@ else
<div class="table-card-row"><span class="table-card-label">Name</span> @e.Name</div>
<div class="table-card-row"><span class="table-card-label">Client ID</span> @e.ClientId</div>
<div class="table-card-row mt-2 d-flex flex-wrap gap-1">
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)">Add static</button>
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)">Ignore</button>
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)"><i class="bi bi-plus me-1" aria-hidden="true"></i>Add static</button>
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)"><i class="bi bi-slash-circle me-1" aria-hidden="true"></i>Ignore</button>
</div>
</div>
</div>
@@ -318,7 +318,7 @@ else
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
</div>
}
</section>
@@ -67,6 +67,10 @@ else if (_status != null)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
}
else
{
<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>
}
@(_reloading ? "Reloading…" : "Reload config")
</button>
</div>
@@ -45,7 +45,7 @@ else
}
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
@if (_readOnlyHosts?.Count > 0)
{
@@ -43,8 +43,10 @@ public class StatusController : ControllerBase
var (dhcpRangeStart, dhcpRangeEnd) = _configSetService.GetDhcpRange();
var systemHostsPath = _options.SystemHostsPath?.Trim();
var statusResult = await _processRunner.RunAsync(_options.StatusCommand, TimeSpan.FromSeconds(5), ct);
var dnsmasqStatus = statusResult.ExitCode == 0 ? "active" : (statusResult.ExitCode.HasValue ? "inactive" : "unknown");
var statusResult = await _processRunner.RunAsync(_options.StatusCommand, _options.StatusTimeout, ct);
var dnsmasqStatus = statusResult.TimedOut || statusResult.ExitCode is null || statusResult.ExceptionMessage is not null
? "unknown"
: statusResult.ExitCode == 0 ? "active" : "inactive";
var statusCommandStdout = string.IsNullOrWhiteSpace(statusResult.Stdout) ? null : statusResult.Stdout.Trim();
var statusCommandStderr = string.IsNullOrWhiteSpace(statusResult.Stderr) ? null : statusResult.Stderr.Trim();
if (statusResult.ExceptionMessage != null)
@@ -52,19 +54,19 @@ public class StatusController : ControllerBase
var showTask = string.IsNullOrWhiteSpace(_options.StatusShowCommand)
? Task.FromResult(new ProcessRunResult(null, "", "", false))
: _processRunner.RunAsync(_options.StatusShowCommand, TimeSpan.FromSeconds(5), ct);
: _processRunner.RunAsync(_options.StatusShowCommand, _options.StatusShowTimeout, ct);
var logsTask = string.IsNullOrWhiteSpace(_options.LogsCommand)
? Task.FromResult(new ProcessRunResult(null, "", "", false))
: _processRunner.RunAsync(_options.LogsCommand, TimeSpan.FromSeconds(10), ct);
: _processRunner.RunAsync(_options.LogsCommand, _options.LogsTimeout, ct);
await Task.WhenAll(showTask, logsTask);
var showResult = await showTask;
var logsResult = await logsTask;
var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand)
? FormatStatusShowOutput(showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : ""))
? FormatStatusShowOutput(AppendCommandFailureMarker(showResult.Stdout, showResult.TimedOut, showResult.ExceptionMessage))
: null;
var logsOutput = !string.IsNullOrWhiteSpace(_options.LogsCommand)
? logsResult.Stdout + (logsResult.TimedOut ? "\n(Command timed out.)" : "")
? AppendCommandFailureMarker(logsResult.Stdout, logsResult.TimedOut, logsResult.ExceptionMessage)
: null;
var status = new DnsmasqServiceStatus(
@@ -109,6 +111,15 @@ public class StatusController : ControllerBase
}
}
private static string AppendCommandFailureMarker(string output, bool timedOut, string? exceptionMessage)
{
if (timedOut)
return output + "\n(Command timed out.)";
if (!string.IsNullOrEmpty(exceptionMessage))
return output + "\n(Command failed: " + exceptionMessage + ")";
return output;
}
/// <summary>
/// Reformats StatusShow output: Active line gets a line break after the semicolon;
/// continuation (e.g. "4 min ago") is indented to align with the value after "Active: ".
@@ -0,0 +1,20 @@
using DnsmasqWebUI.Models.Contracts;
namespace DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
/// <summary>
/// Handle to a started process. Call <see cref="ReadOutputAsync"/> for streaming output,
/// and/or <see cref="WaitForExitAsync"/> to wait for completion and get final result.
/// Disposing the handle kills the process if still running.
/// </summary>
public interface IProcessHandle : IAsyncDisposable
{
/// <summary>Stream output lines as they are produced. Completes when the process exits and all output is read.</summary>
IAsyncEnumerable<ProcessOutputLine> ReadOutputAsync(CancellationToken ct = default);
/// <summary>Wait for the process to exit (or timeout). Returns final stdout/stderr and exit code. If timeout is set and exceeded, kills the process and returns with <see cref="ProcessRunResult.TimedOut"/> true.</summary>
Task<ProcessRunResult> WaitForExitAsync(TimeSpan? timeout = null, CancellationToken ct = default);
/// <summary>Kill the process.</summary>
Task KillAsync(CancellationToken ct = default);
}
@@ -3,7 +3,7 @@ using DnsmasqWebUI.Models.Contracts;
namespace DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
/// <summary>Runs a shell command with timeout and cancellation. Used for status, reload, status show, and logs commands.</summary>
/// <summary>Runs a shell command with timeout and cancellation. Used for status, reload, status show, and logs commands. Supports both run-to-completion and start-then-stream.</summary>
public interface IProcessRunner : IApplicationScopedService
{
/// <summary>Runs a shell command with timeout and cancellation. Returns exit code (null if timed out or failed to start), stdout, stderr, and whether the run timed out.</summary>
@@ -11,4 +11,7 @@ public interface IProcessRunner : IApplicationScopedService
/// <summary>Same as RunAsync but caps stdout/stderr length. When exceeded, truncates and appends a notice.</summary>
Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, int? maxOutputChars, CancellationToken ct = default);
/// <summary>Starts a shell command and returns a handle for streaming output and/or waiting for exit. Command must be non-null and non-empty.</summary>
Task<IProcessHandle> StartAsync(string command, int? maxOutputChars = null, CancellationToken ct = default);
}
@@ -0,0 +1,178 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Channels;
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
using DnsmasqWebUI.Models.Contracts;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services.Common.Process;
/// <summary>Handle to a started process; buffers output and exposes streaming and wait-for-exit.</summary>
internal sealed class ProcessHandle : IProcessHandle
{
private const string TruncMsg = "\n\n(output truncated)\n";
private static readonly TimeSpan StreamCloseWaitTimeout = TimeSpan.FromSeconds(2);
private readonly System.Diagnostics.Process _process;
private readonly Channel<ProcessOutputLine> _channel;
private readonly StringBuilder _stdout = new();
private readonly StringBuilder _stderr = new();
private readonly int? _maxOutputChars;
private readonly ILogger? _logger;
private bool _channelCompleted;
private readonly object _gate = new();
private readonly TaskCompletionSource _stdoutClosed = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _stderrClosed = new(TaskCreationOptions.RunContinuationsAsynchronously);
public ProcessHandle(System.Diagnostics.Process process, Channel<ProcessOutputLine> channel, int? maxOutputChars, ILogger? logger)
{
_process = process;
_channel = channel;
_maxOutputChars = maxOutputChars;
_logger = logger;
process.EnableRaisingEvents = true;
process.Exited += (_, _) =>
{
lock (_gate)
{
if (!_channelCompleted)
{
_channelCompleted = true;
_channel.Writer.Complete();
}
}
};
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
{
AppendCapped(_stdout, e.Data + "\n", _maxOutputChars);
_channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdOut, e.Data, DateTime.UtcNow));
}
else
_stdoutClosed.TrySetResult();
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
{
AppendCapped(_stderr, e.Data + "\n", _maxOutputChars);
_channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdErr, e.Data, DateTime.UtcNow));
}
else
_stderrClosed.TrySetResult();
};
}
public async IAsyncEnumerable<ProcessOutputLine> ReadOutputAsync([EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var line in _channel.Reader.ReadAllAsync(ct))
yield return line;
}
public async Task<ProcessRunResult> WaitForExitAsync(TimeSpan? timeout, CancellationToken ct = default)
{
using var timeoutCts = timeout.HasValue ? CancellationTokenSource.CreateLinkedTokenSource(ct) : null;
if (timeoutCts != null && timeout!.Value > TimeSpan.Zero)
timeoutCts.CancelAfter(timeout.Value);
var exitToken = timeoutCts?.Token ?? ct;
// Drain channel so it does not grow unbounded when no one is calling ReadOutputAsync
var drainTask = Task.Run(async () =>
{
try
{
await foreach (var _ in _channel.Reader.ReadAllAsync(ct)) { }
}
catch (OperationCanceledException) { }
}, ct);
try
{
await _process.WaitForExitAsync(exitToken);
}
catch (OperationCanceledException) when (timeoutCts?.IsCancellationRequested == true && !ct.IsCancellationRequested)
{
var timeoutSeconds = timeout?.TotalSeconds ?? 0;
_logger?.LogWarning("Command timed out after {Timeout}s", timeoutSeconds);
try { _process.Kill(); } catch { /* best effort */ }
lock (_gate)
{
if (!_channelCompleted)
{
_channelCompleted = true;
_channel.Writer.Complete();
}
}
try { await drainTask; } catch (OperationCanceledException) { }
await WaitForStreamsClosedAsync();
var err = _stderr.ToString();
if (!string.IsNullOrEmpty(err)) err += "\n";
err += $"Command timed out after {timeoutSeconds} seconds.";
return new ProcessRunResult(null, _stdout.ToString(), err, true);
}
try { await drainTask; } catch (OperationCanceledException) { }
await WaitForStreamsClosedAsync();
var exitCode = _process.HasExited ? _process.ExitCode : -1;
_logger?.LogDebug("Command completed, exit code={ExitCode}", exitCode);
return new ProcessRunResult(exitCode, _stdout.ToString(), _stderr.ToString(), false);
}
public Task KillAsync(CancellationToken ct = default)
{
try { _process.Kill(); } catch { /* best effort */ }
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
lock (_gate)
{
if (!_channelCompleted)
{
_channelCompleted = true;
_channel.Writer.Complete();
}
}
if (!_process.HasExited)
{
try { _process.Kill(); } catch { /* best effort */ }
}
_process.Dispose();
await Task.CompletedTask;
}
/// <summary>Wait for stdout/stderr async read streams to close so buffers are complete before we read them. Best practice: wait for both stream-close signals (e.Data == null) after process exit to avoid lost output (see e.g. dotnet/runtime#18789).</summary>
private async Task WaitForStreamsClosedAsync()
{
try
{
await Task.WhenAll(_stdoutClosed.Task, _stderrClosed.Task)
.WaitAsync(StreamCloseWaitTimeout);
}
catch (TimeoutException)
{
_logger?.LogDebug("Stream close wait timed out after {Seconds}s", StreamCloseWaitTimeout.TotalSeconds);
}
}
private static void AppendCapped(StringBuilder sb, string line, int? maxChars)
{
if (maxChars.HasValue && sb.Length >= maxChars.Value) return;
sb.Append(line);
if (maxChars.HasValue && sb.Length > maxChars.Value)
{
var keep = Math.Max(0, maxChars.Value - TruncMsg.Length);
sb.Length = keep;
sb.Append(TruncMsg);
}
}
}
@@ -0,0 +1,14 @@
namespace DnsmasqWebUI.Infrastructure.Services.Common.Process;
/// <summary>Which stream a line came from when reading process output asynchronously.</summary>
public enum ProcessOutputStream
{
StdOut,
StdErr,
}
/// <summary>Single line of process output (stdout or stderr) with timestamp. Used by <see cref="Abstractions.IProcessHandle.ReadOutputAsync"/>.</summary>
public record ProcessOutputLine(
ProcessOutputStream Stream,
string Line,
DateTime TimestampUtc);
@@ -1,12 +1,12 @@
using System.Diagnostics;
using System.Text;
using System.Threading.Channels;
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
using DnsmasqWebUI.Models.Contracts;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services.Common.Process;
/// <summary>Runs shell commands via /bin/sh with async output capture and timeout. Used by StatusController and ReloadService.</summary>
/// <summary>Runs shell commands via /bin/sh with async output capture and timeout. Supports run-to-completion and start-then-stream.</summary>
public sealed class ProcessRunner : IProcessRunner
{
private const int MaxCommandPrefixLength = 80;
@@ -27,76 +27,10 @@ public sealed class ProcessRunner : IProcessRunner
var prefix = trimmed.Length <= MaxCommandPrefixLength ? trimmed : trimmed[..MaxCommandPrefixLength] + "...";
_logger.LogDebug("Running command (length={Length}, timeout={Timeout}s): {CommandPrefix}", trimmed.Length, timeout.TotalSeconds, prefix);
var stdout = new StringBuilder();
var stderr = new StringBuilder();
using var process = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "/bin/sh",
Arguments = "-c \"" + command.Replace("\"", "\\\"") + "\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
var truncMsg = "\n\n(output truncated)\n";
process.OutputDataReceived += (_, e) =>
{
if (e.Data == null) return;
if (maxOutputChars.HasValue && stdout.Length >= maxOutputChars.Value) return;
stdout.AppendLine(e.Data);
if (maxOutputChars.HasValue && stdout.Length > maxOutputChars.Value)
{
var keep = Math.Max(0, maxOutputChars.Value - truncMsg.Length);
stdout.Length = keep;
stdout.Append(truncMsg);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data == null) return;
if (maxOutputChars.HasValue && stderr.Length >= maxOutputChars.Value) return;
stderr.AppendLine(e.Data);
if (maxOutputChars.HasValue && stderr.Length > maxOutputChars.Value)
{
var keep = Math.Max(0, maxOutputChars.Value - truncMsg.Length);
stderr.Length = keep;
stderr.Append(truncMsg);
}
};
try
{
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
_logger.LogWarning("Command timed out after {Timeout}s", timeout.TotalSeconds);
try { process.Kill(); } catch { /* best effort */ }
var err = stderr.ToString();
if (!string.IsNullOrEmpty(err)) err += "\n";
err += $"Command timed out after {timeout.TotalSeconds} seconds.";
return new ProcessRunResult(null, stdout.ToString(), err, true);
}
var exitCode = process.HasExited ? process.ExitCode : -1;
_logger.LogDebug("Command completed, exit code={ExitCode}", exitCode);
return new ProcessRunResult(
exitCode,
stdout.ToString(),
stderr.ToString(),
false);
await using var handle = await StartAsync(trimmed, maxOutputChars, ct);
return await handle.WaitForExitAsync(timeout, ct);
}
catch (Exception ex)
{
@@ -104,4 +38,36 @@ public sealed class ProcessRunner : IProcessRunner
return new ProcessRunResult(null, "", "", false, ex.Message);
}
}
public async Task<IProcessHandle> StartAsync(string command, int? maxOutputChars = null, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(command))
throw new ArgumentException("Command must be non-null and non-empty.", nameof(command));
var trimmed = command.Trim();
var prefix = trimmed.Length <= MaxCommandPrefixLength ? trimmed : trimmed[..MaxCommandPrefixLength] + "...";
_logger.LogDebug("Starting command (length={Length}): {CommandPrefix}", trimmed.Length, prefix);
var process = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "/bin/sh",
Arguments = "-c \"" + trimmed.Replace("\"", "\\\"") + "\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
var channel = Channel.CreateUnbounded<ProcessOutputLine>(new UnboundedChannelOptions { SingleReader = false, SingleWriter = true });
var handle = new ProcessHandle(process, channel, maxOutputChars, _logger);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
return await Task.FromResult(handle);
}
}
@@ -40,10 +40,10 @@ public class ReloadService : IReloadService
try
{
var result = await _processRunner.RunAsync(command, TimeSpan.FromSeconds(30), ct);
var result = await _processRunner.RunAsync(command, _options.RestartTimeout, ct);
var stderr = result.Stderr;
if (result.TimedOut)
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + "Reload command timed out after 30 seconds.";
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + $"Reload command timed out after {_options.RestartTimeoutSeconds} seconds.";
if (result.ExceptionMessage != null)
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + result.ExceptionMessage;
@@ -68,7 +68,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
return EffectiveConfigSaveResult.NoChanges();
var result = await _saveService.SaveAsync(_pending.ToList(), ct);
if (result.Saved && result.Reloaded)
if (result.Saved && result.Restarted)
ExitEditModeDiscard();
return result;
}
@@ -66,22 +66,22 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
"Failed to write config.");
}
var reload = await _reloadService.ReloadAsync(ct);
var restartResult = await _reloadService.ReloadAsync(ct);
var backupCreated = File.Exists(backupPath);
if (!reload.Success)
_logger.LogWarning("Config saved but reload failed: exit {ExitCode}, stderr: {Stderr}", reload.ExitCode, reload.StdErr);
if (!restartResult.Success)
_logger.LogWarning("Config saved but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
return new EffectiveConfigSaveResult(
BackupCreated: backupCreated,
BackupPath: backupCreated ? backupPath : null,
Saved: true,
Reloaded: reload.Success,
ReloadExitCode: reload.ExitCode,
ReloadStdOut: reload.StdOut,
ReloadStdErr: reload.StdErr,
ErrorCode: reload.Success ? null : "reload_failed",
UserMessage: reload.Success ? "Saved and reloaded." : "Saved, but reload failed.");
Restarted: restartResult.Success,
RestartExitCode: restartResult.ExitCode,
RestartStdOut: restartResult.StdOut,
RestartStdErr: restartResult.StdErr,
ErrorCode: restartResult.Success ? null : "restart_failed",
UserMessage: restartResult.Success ? "Saved and dnsmasq restarted." : "Saved, but the restart command failed.");
}
/// <inheritdoc />
@@ -106,19 +106,19 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
File.Copy(backupPath, managedPath, overwrite: true);
_logger.LogInformation("Restored managed config from backup: {BackupPath}", backupPath);
var reload = await _reloadService.ReloadAsync(ct);
var restartResult = await _reloadService.ReloadAsync(ct);
if (!reload.Success)
_logger.LogWarning("Restore completed but reload failed: exit {ExitCode}, stderr: {Stderr}", reload.ExitCode, reload.StdErr);
if (!restartResult.Success)
_logger.LogWarning("Restore completed but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
return new EffectiveConfigRestoreResult(
Restored: true,
Reloaded: reload.Success,
ReloadExitCode: reload.ExitCode,
ReloadStdErr: reload.StdErr,
UserMessage: reload.Success
? "Backup restored and dnsmasq reloaded."
: "Backup restored, but reload still failed.");
Restarted: restartResult.Success,
RestartExitCode: restartResult.ExitCode,
RestartStdErr: restartResult.StdErr,
UserMessage: restartResult.Success
? "Backup restored and dnsmasq restarted."
: "Backup restored, but the restart command still failed.");
}
private static string BuildBackupPath(string managedPath) =>
@@ -54,8 +54,21 @@ public sealed class LogsService : ILogsService
{
using var scope = _scopeFactory.CreateScope();
var processRunner = scope.ServiceProvider.GetRequiredService<IProcessRunner>();
var result = await processRunner.RunAsync(cmd, TimeSpan.FromSeconds(10), MaxCommandOutputChars, ct);
var raw = result.Stdout + (result.TimedOut ? "\n(Command timed out.)" : "");
var result = await processRunner.RunAsync(cmd, _options.Value.LogsTimeout, MaxCommandOutputChars, ct);
if (result.TimedOut || result.ExceptionMessage is not null)
{
var err = "(Logs command failed";
if (result.TimedOut) err += ": timed out";
else err += ": " + result.ExceptionMessage;
err += ")\n";
if (!string.IsNullOrWhiteSpace(result.Stderr))
err += result.Stderr + "\n";
await PushChunkedAsync("DnsmasqLogsUpdate", "replace", err, ct);
return;
}
var raw = result.Stdout;
string mode;
string content;
@@ -43,4 +43,30 @@ public class DnsmasqOptions
/// <summary>Optional command for recent logs (e.g. "journalctl -u dnsmasq -n 100 --no-pager"). Output shown on Dnsmasq page.</summary>
public string? LogsCommand { get; set; }
// --- Timeouts (seconds) for the commands above ---
/// <summary>Timeout in seconds for <see cref="RestartCommand"/> / <see cref="ReloadCommand"/>. Default 30.</summary>
public int RestartTimeoutSeconds { get; set; } = 15;
/// <summary>Timeout in seconds for <see cref="StatusCommand"/>. Default 5.</summary>
public int StatusTimeoutSeconds { get; set; } = 5;
/// <summary>Timeout in seconds for <see cref="StatusShowCommand"/>. Default 5.</summary>
public int StatusShowTimeoutSeconds { get; set; } = 5;
/// <summary>Timeout in seconds for <see cref="LogsCommand"/>. Default 10.</summary>
public int LogsTimeoutSeconds { get; set; } = 10;
/// <summary><see cref="RestartTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds);
/// <summary><see cref="StatusTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan StatusTimeout => TimeSpan.FromSeconds(StatusTimeoutSeconds);
/// <summary><see cref="StatusShowTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan StatusShowTimeout => TimeSpan.FromSeconds(StatusShowTimeoutSeconds);
/// <summary><see cref="LogsTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan LogsTimeout => TimeSpan.FromSeconds(LogsTimeoutSeconds);
}
@@ -34,6 +34,17 @@ public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator<Dnsma
// managed hosts file (ManagedHostsFileName in the same directory as main config). Hosts UI is available when
// MainConfigPath is set (managed hosts path is then derived from it and ManagedHostsFileName).
const int minTimeoutSeconds = 1;
const int maxTimeoutSeconds = 600;
if (options.RestartTimeoutSeconds < minTimeoutSeconds || options.RestartTimeoutSeconds > maxTimeoutSeconds)
failures.Add($"Dnsmasq:RestartTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.RestartTimeoutSeconds}.");
if (options.StatusTimeoutSeconds < minTimeoutSeconds || options.StatusTimeoutSeconds > maxTimeoutSeconds)
failures.Add($"Dnsmasq:StatusTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.StatusTimeoutSeconds}.");
if (options.StatusShowTimeoutSeconds < minTimeoutSeconds || options.StatusShowTimeoutSeconds > maxTimeoutSeconds)
failures.Add($"Dnsmasq:StatusShowTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.StatusShowTimeoutSeconds}.");
if (options.LogsTimeoutSeconds < minTimeoutSeconds || options.LogsTimeoutSeconds > maxTimeoutSeconds)
failures.Add($"Dnsmasq:LogsTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.LogsTimeoutSeconds}.");
if (failures.Count == 0)
return ValidateOptionsResult.Success;
@@ -1,14 +1,14 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>Result of restoring the managed config from a backup file, then reloading dnsmasq.</summary>
/// <summary>Result of restoring the managed config from a backup file, then running the restart command.</summary>
/// <param name="Restored">True when the backup was copied over the managed file.</param>
/// <param name="Reloaded">True when dnsmasq reload succeeded after restore.</param>
/// <param name="ReloadExitCode">Reload process exit code; -1 when not run or failed to start.</param>
/// <param name="ReloadStdErr">Standard error from reload command.</param>
/// <param name="Restarted">True when the restart command succeeded after restore.</param>
/// <param name="RestartExitCode">Restart command exit code; -1 when not run or failed to start.</param>
/// <param name="RestartStdErr">Standard error from the restart command.</param>
/// <param name="UserMessage">Short message for the user.</param>
public record EffectiveConfigRestoreResult(
bool Restored,
bool Reloaded,
int ReloadExitCode,
string? ReloadStdErr,
bool Restarted,
int RestartExitCode,
string? RestartStdErr,
string? UserMessage);
@@ -1,23 +1,23 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>Result of the effective-config save flow (backup, write, reload). Used by UI to drive state and show restore/continue options.</summary>
/// <summary>Result of the effective-config save flow (backup, write, restart). Used by UI to drive state and show restore/continue options.</summary>
/// <param name="BackupCreated">True when a backup file was created before write.</param>
/// <param name="BackupPath">Path to the backup file when one was created; null otherwise.</param>
/// <param name="Saved">True when the managed config was written successfully.</param>
/// <param name="Reloaded">True when dnsmasq reload succeeded after write.</param>
/// <param name="ReloadExitCode">Reload process exit code; -1 when not run or failed to start.</param>
/// <param name="ReloadStdOut">Standard output from reload command.</param>
/// <param name="ReloadStdErr">Standard error from reload command.</param>
/// <param name="ErrorCode">Machine-readable code: no_changes, missing_managed_path, write_failed, reload_failed, or null when success.</param>
/// <param name="Restarted">True when the restart command succeeded after write (config changes require restart, not SIGHUP).</param>
/// <param name="RestartExitCode">Restart command process exit code; -1 when not run or failed to start.</param>
/// <param name="RestartStdOut">Standard output from the restart command.</param>
/// <param name="RestartStdErr">Standard error from the restart command.</param>
/// <param name="ErrorCode">Machine-readable code: no_changes, missing_managed_path, write_failed, restart_failed, or null when success.</param>
/// <param name="UserMessage">Short message for the user.</param>
public record EffectiveConfigSaveResult(
bool BackupCreated,
string? BackupPath,
bool Saved,
bool Reloaded,
int ReloadExitCode,
string? ReloadStdOut,
string? ReloadStdErr,
bool Restarted,
int RestartExitCode,
string? RestartStdOut,
string? RestartStdErr,
string? ErrorCode,
string? UserMessage)
{