Release v0.0.5: option help modal, update check, effective config sections

- Config option help: modal with dnsmasq docs, label click/mouseleave
- Update check: background + manual check, nav footer tooltip with last check time
- Effective config: sectioned views (DNS, DHCP, etc.), section toggle
- Version bump to 0.0.5
This commit is contained in:
2026-02-13 00:11:02 +10:00
parent 439192058e
commit e7c2560d07
155 changed files with 3282 additions and 216 deletions
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env bash
# Extract per-option HTML from the dnsmasq man page for use as field help in the web UI.
#
# Runs a temporary Python (BeautifulSoup) container to fetch the page, parse the OPTIONS
# <DL>/<DT>/<DD> blocks, and write one .html file per option. The container is removed
# when done. If the page URL or structure changes, pass a new --url.
#
# Usage:
# ./scripts/extract-option-help.sh [--url URL] [--output-dir DIR]
#
# Requires: Docker (python:3-slim image will be pulled if missing)
set -e
DEFAULT_URL="https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html"
DEFAULT_OUTPUT_DIR="src/DnsmasqWebUI/wwwroot/option-help"
URL="$DEFAULT_URL"
OUTPUT_DIR="$DEFAULT_OUTPUT_DIR"
while [[ $# -gt 0 ]]; do
case "$1" in
--url) URL="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
[[ "$OUTPUT_DIR" != /* ]] && OUTPUT_DIR="$PWD/$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
SCRIPT_FILE="$(mktemp)"
trap 'rm -f "$SCRIPT_FILE"' EXIT
# Embed Python script (uses requests + BeautifulSoup; runs inside container)
cat << 'PYTHON_SCRIPT' > "$SCRIPT_FILE"
import os
import re
import sys
try:
import requests
from bs4 import BeautifulSoup
except ImportError:
sys.stderr.write("Installing requests and beautifulsoup4...\n")
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "requests", "beautifulsoup4"])
import requests
from bs4 import BeautifulSoup
OUT_DIR = "/out"
URL = os.environ.get("URL", "https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html")
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections. server + local = one UI row.
OPTION_KEYS = [
"no-hosts",
"addn-hosts",
"hostsdir",
"read-ethers",
"server",
"local",
"rev-server",
"address",
"resolv-file",
"no-resolv",
"domain-needed",
"port",
"log-queries",
"strict-order",
"all-servers",
"auth-ttl",
"edns-packet-max",
"query-port",
"port-limit",
"min-port",
"max-port",
"dns-loop-detect",
"stop-dns-rebind",
"rebind-localhost-ok",
"clear-on-reload",
"expand-hosts",
"bogus-priv",
"rebind-domain-ok",
"bogus-nxdomain",
"ignore-address",
"alias",
"filter-rr",
"filterwin2k",
"filter-A",
"filter-AAAA",
"localise-queries",
"fast-dns-retry",
"ipset",
"nftset",
"domain",
"cname",
"mx-host",
"srv-host",
"ptr-record",
"txt-record",
"naptr-record",
"host-record",
"dynamic-host",
"interface-name",
"mx-target",
"localmx",
"selfmx",
"dhcp-authoritative",
"dhcp-rapid-commit",
"leasefile-ro",
"dhcp-script",
"dhcp-leasefile",
"dhcp-lease-max",
"dhcp-ttl",
"dhcp-range",
"dhcp-host",
"dhcp-option",
"dhcp-option-force",
"dhcp-match",
"dhcp-mac",
"dhcp-name-match",
"dhcp-ignore-names",
"dhcp-hostsfile",
"dhcp-optsfile",
"dhcp-hostsdir",
"dhcp-boot",
"dhcp-ignore",
"dhcp-vendorclass",
"dhcp-userclass",
"ra-param",
"slaac",
"enable-tftp",
"tftp-secure",
"tftp-no-fail",
"tftp-no-blocksize",
"tftp-root",
"pxe-prompt",
"pxe-service",
"dnssec",
"dnssec-check-unsigned",
"proxy-dnssec",
"trust-anchor",
"cache-rr",
"cache-size",
"local-ttl",
"no-negcache",
"neg-ttl",
"max-ttl",
"max-cache-ttl",
"min-cache-ttl",
"no-poll",
"bind-interfaces",
"bind-dynamic",
"log-debug",
"log-async",
"local-service",
"interface",
"listen-address",
"except-interface",
"auth-server",
"no-dhcp-interface",
"no-dhcpv4-interface",
"no-dhcpv6-interface",
"pid-file",
"user",
"group",
"log-facility",
"enable-dbus",
"enable-ubus",
"enable-ra",
"log-dhcp",
"keep-in-foreground",
"no-daemon",
"conntrack",
]
long_opt_re = re.compile(r"--([a-z][a-z0-9-]*)(?:=[^,\s]*)?", re.IGNORECASE)
def option_names_from_dt(dt):
return [m.group(1).lower() for m in long_opt_re.finditer(dt.get_text())]
def main():
r = requests.get(URL, timeout=30)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
h2 = None
for el in soup.find_all("h2"):
if el.get_text(strip=True).upper() == "OPTIONS":
h2 = el
break
if not h2:
sys.stderr.write("Could not find OPTIONS H2\n")
sys.exit(1)
dl = h2.find_next("dl")
if not dl:
sys.stderr.write("Could not find DL after OPTIONS\n")
sys.exit(1)
# Build option -> (dt_html, dd_html) from raw HTML; split by <dt> so we get one block per option.
raw_dl = str(dl)
option_to_block = {}
blocks = re.split(r"<dt\b", raw_dl, flags=re.IGNORECASE)
for block in blocks[1:]:
p_dt = block.find("</dt>") if "</dt>" in block.lower() else len(block) + 1
m_dd = re.search(r"<dd\b", block, re.IGNORECASE)
p_dd = m_dd.start() if m_dd else len(block) + 1
end_pos = min(p_dt, p_dd)
if end_pos > len(block):
continue
dt_part = block[:end_pos].strip()
rest = block[end_pos:]
if rest.lower().startswith("</dt>"):
rest = rest[6:].lstrip()
if ">" in dt_part:
dt_part = dt_part.split(">", 1)[1]
dt_html = "<dt>" + dt_part + "</dt>"
dd_match = re.search(r"<dd\b[^>]*>(.*)", rest, re.DOTALL | re.IGNORECASE)
dd_body = dd_match.group(1) if dd_match else ""
dd_body = re.split(r"</dd>|<dt\s|<dt>", dd_body, flags=re.IGNORECASE)[0].strip()
dd_html = "<dd>" + dd_body + "</dd>" if dd_body else "<dd></dd>"
soup_dt = BeautifulSoup(dt_html, "html.parser").find("dt")
if not soup_dt:
continue
for n in option_names_from_dt(soup_dt):
if n not in option_to_block:
option_to_block[n] = (dt_html, dd_html)
os.makedirs(OUT_DIR, exist_ok=True)
written = 0
for key in OPTION_KEYS:
norm = key.lower()
if norm not in option_to_block:
continue
dt_html, dd_html = option_to_block[norm]
frag = "\n".join([dt_html, dd_html])
safe = re.sub(r"[^a-z0-9-]", "", norm) or key
path = os.path.join(OUT_DIR, f"{safe}.html")
with open(path, "w", encoding="utf-8") as f:
f.write(frag)
written += 1
print(f"Wrote {written} files to {OUT_DIR}")
if __name__ == "__main__":
main()
PYTHON_SCRIPT
echo "Fetching man page and extracting option help (Docker python:3-slim) ..."
docker run --rm \
-v "$OUTPUT_DIR:/out" \
-v "$SCRIPT_FILE:/script.py:ro" \
-e "URL=$URL" \
-e "PIP_ROOT_USER_ACTION=ignore" \
python:3-slim \
sh -c "pip install -q requests beautifulsoup4 && python /script.py"
echo "Done. Files in $OUTPUT_DIR"
@@ -5,8 +5,9 @@ using DnsmasqWebUI.Infrastructure.Parsers;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Positive parser tests for the 16 added options: key wired and "option present" behaviour.
/// Positive parser tests for added options: key wired and "option present" behaviour.
/// Ensures key names in DnsmasqConfKeys match what the parser expects (complement to OfficialExampleTests which assert "all commented → false/empty").
/// Covers process flags (keep-in-foreground, no-daemon), DHCP include paths (dhcp-hostsfile, dhcp-optsfile, dhcp-hostsdir), proxy-dnssec, conntrack.
/// </summary>
public class DnsmasqConfIncludeParserNewOptionsTests
{
@@ -60,6 +61,24 @@ public class DnsmasqConfIncludeParserNewOptionsTests
WriteFlagAndAssertTrue(DnsmasqConfKeys.LogDhcp);
}
[Fact]
public void GetFlagFromConfigFiles_KeepInForeground_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.KeepInForeground);
}
[Fact]
public void GetFlagFromConfigFiles_NoDaemon_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.NoDaemon);
}
[Fact]
public void GetFlagFromConfigFiles_ProxyDnssec_WhenPresent_ReturnsTrue()
{
WriteFlagAndAssertTrue(DnsmasqConfKeys.ProxyDnssec);
}
// --- Multi-value (6): key=value line(s) → GetMultiValue returns value(s) ---
[Fact]
@@ -158,6 +177,54 @@ public class DnsmasqConfIncludeParserNewOptionsTests
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpHostsfile_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/hosts.d";
try
{
File.WriteAllText(conf, $"dhcp-hostsfile={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHostsfile);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpOptsfile_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/opts.conf";
try
{
File.WriteAllText(conf, $"dhcp-optsfile={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOptsfile);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetMultiValueFromConfigFiles_DhcpHostsdir_WhenSet_ReturnsValues()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var path = "/etc/dnsmasq.d/hosts.d";
try
{
File.WriteAllText(conf, $"dhcp-hostsdir={path}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHostsdir);
Assert.Single(result);
Assert.Equal(path, result[0]);
}
finally { Directory.Delete(dir, recursive: true); }
}
// --- Single-value (2): key=value → GetLastValue returns value ---
[Fact]
@@ -190,6 +257,21 @@ public class DnsmasqConfIncludeParserNewOptionsTests
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void GetLastValueFromConfigFiles_Conntrack_WhenSet_ReturnsValue()
{
var dir = CreateTempDir();
var conf = Path.Combine(dir, "dnsmasq.conf");
var value = "42";
try
{
File.WriteAllText(conf, $"conntrack={value}\n");
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Conntrack);
Assert.Equal(value, result);
}
finally { Directory.Delete(dir, recursive: true); }
}
private static void WriteFlagAndAssertTrue(string key)
{
var dir = CreateTempDir();
@@ -50,6 +50,9 @@ public class EffectiveDnsmasqConfigTests
DhcpMacValues: Array.Empty<string>(),
DhcpNameMatchValues: Array.Empty<string>(),
DhcpIgnoreNamesValues: Array.Empty<string>(),
DhcpHostsfilePaths: Array.Empty<string>(),
DhcpOptsfilePaths: Array.Empty<string>(),
DhcpHostsdirPaths: Array.Empty<string>(),
ExpandHosts: false,
BogusPriv: false,
StrictOrder: false,
@@ -83,6 +86,9 @@ public class EffectiveDnsmasqConfigTests
Selfmx: false,
EnableRa: false,
LogDhcp: false,
KeepInForeground: false,
NoDaemon: false,
ProxyDnssec: false,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
@@ -112,7 +118,8 @@ public class EffectiveDnsmasqConfigTests
EnableUbus: null,
FastDnsRetry: null,
DhcpScriptPath: null,
MxTarget: null
MxTarget: null,
Conntrack: null
);
[Fact]
+1
View File
@@ -23,6 +23,7 @@
<body>
<Routes @rendermode="InteractiveServer" />
<script src="js/ec-option-help.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
@@ -0,0 +1,173 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Extensions
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using System.Text.Json.Serialization
@inject IHttpClientFactory HttpFactory
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@if (Visible)
{
<div class="config-option-help-popover" id="@_popoverId" style="@_positionStyle" @onmouseenter="OnModalMouseEnter" @onmouseleave="OnModalMouseLeave">
<div class="config-option-help-popover-content">
<div class="config-option-help-popover-header">
<span class="config-option-help-popover-title">@Title</span>
</div>
<div class="config-option-help-popover-body">
@if (_loading)
{
<p class="text-muted small mb-0">Loading…</p>
}
else if (_error != null)
{
<p class="text-danger small mb-0">@_error</p>
}
else if (_helpHtml != null)
{
<div class="option-help">@((MarkupString)_helpHtml)</div>
}
</div>
</div>
</div>
}
@code {
[Parameter] public string? HelpKey { get; set; }
[Parameter] public string? AnchorId { get; set; }
[Parameter] public bool Visible { get; set; }
[Parameter] public string Title { get; set; } = "Option help";
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnMouseEnterModal { get; set; }
[Parameter] public EventCallback OnMouseLeaveModal { get; set; }
private string? _helpHtml;
private string? _error;
private bool _loading;
private string? _lastKey;
private string _positionStyle = "";
private readonly string _popoverId = "config-option-help-popover-" + Guid.NewGuid().ToString("N")[..8];
private DotNetObjectReference<ConfigOptionHelpModal>? _dotNetRef;
private bool _scrollListenerAdded;
private bool _mouseOverModal;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (Visible && !_scrollListenerAdded)
{
_scrollListenerAdded = true;
_dotNetRef = DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("addConfigOptionHelpScrollCloseListener", _dotNetRef);
}
else if (!Visible && _scrollListenerAdded)
{
_scrollListenerAdded = false;
await JSRuntime.InvokeVoidAsync("removeConfigOptionHelpScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
}
}
[JSInvokable]
public async Task OnScrollClose()
{
await PositionUnderAnchorAsync();
StateHasChanged();
if (!_mouseOverModal)
await OnClose.InvokeAsync();
}
public async ValueTask DisposeAsync()
{
if (_scrollListenerAdded)
{
_scrollListenerAdded = false;
try { await JSRuntime.InvokeVoidAsync("removeConfigOptionHelpScrollCloseListener"); } catch { /* best effort */ }
_dotNetRef?.Dispose();
_dotNetRef = null;
}
}
protected override async Task OnParametersSetAsync()
{
if (!Visible || string.IsNullOrEmpty(HelpKey))
{
_helpHtml = null;
_error = null;
_loading = false;
_positionStyle = "";
_mouseOverModal = false;
return;
}
if (HelpKey == _lastKey && _helpHtml != null)
{
await PositionUnderAnchorAsync();
return;
}
_lastKey = HelpKey;
_loading = true;
_error = null;
_helpHtml = null;
_positionStyle = "visibility: hidden;"; // avoid flash before position is set
StateHasChanged();
try
{
var http = HttpFactory.CreateClient(ServiceCollectionExtensions.DnsmasqApiClientName);
var response = await http.GetAsync($"option-help/{HelpKey}.html");
if (response.IsSuccessStatusCode)
_helpHtml = await response.Content.ReadAsStringAsync();
else
_error = "Help not available for this option.";
}
catch
{
_error = "Could not load help.";
}
finally
{
_loading = false;
await PositionUnderAnchorAsync();
StateHasChanged();
}
}
private async Task PositionUnderAnchorAsync()
{
if (string.IsNullOrEmpty(AnchorId)) return;
try
{
var rect = await JSRuntime.InvokeAsync<BoundingRect?>("getConfigOptionHelpAnchorRect", AnchorId);
if (rect is { } r)
{
var gap = 4;
var topPx = r.Bottom + gap;
var leftPx = r.Left;
_positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;";
}
}
catch
{
_positionStyle = "visibility: visible;";
}
}
private async Task OnModalMouseEnter()
{
_mouseOverModal = true;
await OnMouseEnterModal.InvokeAsync();
}
private async Task OnModalMouseLeave()
{
_mouseOverModal = false;
await OnMouseLeaveModal.InvokeAsync();
}
private sealed record BoundingRect(
[property: JsonPropertyName("top")] double Top,
[property: JsonPropertyName("left")] double Left,
[property: JsonPropertyName("bottom")] double Bottom,
[property: JsonPropertyName("width")] double Width,
[property: JsonPropertyName("height")] double Height);
}
@@ -0,0 +1,66 @@
/* Popover anchored under the label (position set in code from anchor rect) */
.config-option-help-popover {
position: fixed;
z-index: 1060;
max-width: min(420px, calc(100vw - 1rem));
max-height: min(70vh, 400px);
overflow: auto;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
background: var(--bs-body-bg, #fff);
}
.config-option-help-popover-content {
display: flex;
flex-direction: column;
min-height: 0;
}
.config-option-help-popover-header {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
flex-shrink: 0;
}
.config-option-help-popover-title {
font-weight: 600;
font-size: 0.9rem;
}
.config-option-help-popover-body {
padding: 0.75rem;
overflow: auto;
flex: 1;
min-height: 0;
}
/* Option help content: man-page fragments (dt/dd, b, p, a) */
.config-option-help-popover-body .option-help dt {
font-weight: 600;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.config-option-help-popover-body .option-help dt:first-child {
margin-top: 0;
}
.config-option-help-popover-body .option-help dd {
margin-left: 0;
margin-bottom: 0.5rem;
padding-left: 0.75rem;
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.1));
font-size: 0.8rem;
}
.config-option-help-popover-body .option-help dd p {
margin-bottom: 0.35rem;
}
.config-option-help-popover-body .option-help dd p:last-child {
margin-bottom: 0;
}
.config-option-help-popover-body .option-help b {
font-weight: 600;
}
.config-option-help-popover-body .option-help a {
color: var(--bs-link-color, #0d6efd);
text-decoration: underline;
}
@@ -1,6 +1,8 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Config
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@@ -9,36 +11,50 @@
{
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<span class="ec-field-label" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></span>
<span class="ec-field-value">@fragment(Descriptor)</span>
@if (Descriptor.GetSource()?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
<span id="@_anchorId" class="ec-field-label @(_optionHelpKey != null ? "ec-field-label-help" : null)" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Descriptor.OptionName:</strong></span>
<span class="ec-field-value">
@fragment(Descriptor)
@if (Descriptor.GetSource()?.IsReadOnly == true)
{
<span class="badge bg-secondary ec-badge-inline">readonly</span>
}
</span>
</li>
}
else if (Descriptor.IsMultiValue)
{
var items = Descriptor.GetItems();
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" />
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" />
}
else
{
var value = Descriptor.GetValue();
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" />
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" />
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8];
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
private string? _labelTooltip;
private string? _optionHelpKey;
protected override void OnParametersSet()
{
_customFragment = RenderFragmentRegistry.GetDisplayFragment(Descriptor.SectionId, Descriptor.OptionName);
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
_optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName);
}
private async Task RaiseOptionHelpRequested()
{
if (string.IsNullOrEmpty(_optionHelpKey)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = _optionHelpKey, OptionLabel = Descriptor.OptionName, AnchorId = _anchorId });
}
private async Task RaiseLabelMouseLeave() => await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = _anchorId, IsLabelMouseLeave = true });
}
@@ -5,7 +5,7 @@
@Value
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
<span class="badge bg-secondary ec-badge-inline">readonly</span>
}
</span>
@@ -1,8 +1,9 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<li class="ec-field">
<span class="ec-field-label" title="@LabelTooltip"><strong>@Label:</strong></span>
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@LabelTooltip" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit)
{
<span class="ec-field-value ec-field-value-multi">
@@ -34,10 +35,25 @@
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
[Parameter] public string? LabelTooltip { get; set; }
[Parameter] public string? OptionHelpKey { get; set; }
[Parameter] public string? OptionHelpAnchorId { get; set; }
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
private string _editLines = "";
private async Task RaiseOptionHelpRequested()
{
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
}
private async Task RaiseLabelMouseLeave()
{
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
}
protected override void OnParametersSet()
{
if (Items != null && Items.Count > 0)
@@ -1,8 +1,9 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<li class="ec-field" title="@(Source?.GetReadOnlyTooltip())">
<span class="ec-field-label" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><strong>@Label:</strong></span>
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Source?.IsReadOnly != true)
{
<span class="ec-field-value">
@@ -11,11 +12,13 @@
}
else
{
<span class="ec-field-value">@DisplayValue</span>
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
<span class="ec-field-value">
@DisplayValue
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ec-badge-inline">readonly</span>
}
</span>
}
</li>
@@ -25,9 +28,24 @@
[Parameter] public ConfigValueSource? Source { get; set; }
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
[Parameter] public string? LabelTooltip { get; set; }
[Parameter] public string? OptionHelpKey { get; set; }
[Parameter] public string? OptionHelpAnchorId { get; set; }
/// <summary>When Edit and not readonly: raised when the user changes the value. Not yet wired to save.</summary>
[Parameter] public EventCallback<string> DisplayValueChanged { get; set; }
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
private async Task RaiseOptionHelpRequested()
{
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
}
private async Task RaiseLabelMouseLeave()
{
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
}
private async Task OnValueInput(ChangeEventArgs e)
{
@@ -1,43 +1,118 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@implements IAsyncDisposable
@if (Status != null)
{
<div class="ec-sections small">
@{
var descriptors = EffectiveConfigFieldBuilder.BuildFieldDescriptors(Status);
var sections = new[]
{
(EffectiveConfigFieldBuilder.SectionHosts, "Hosts"),
(EffectiveConfigFieldBuilder.SectionResolver, "Resolver / DNS"),
(EffectiveConfigFieldBuilder.SectionDnsRecords, "DNS records"),
(EffectiveConfigFieldBuilder.SectionDhcp, "DHCP"),
(EffectiveConfigFieldBuilder.SectionTftpPxe, "TFTP / PXE"),
(EffectiveConfigFieldBuilder.SectionDnssec, "DNSSEC"),
(EffectiveConfigFieldBuilder.SectionCache, "Cache"),
(EffectiveConfigFieldBuilder.SectionProcess, "Process & networking")
};
}
@foreach (var (sectionId, title) in sections)
<div class="ec-container">
<ConfigOptionHelpModal HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Visible="@_showOptionHelp" Title="@_optionHelpTitle"
OnClose="CloseOptionHelp" OnMouseEnterModal="CancelCloseTimerAsync" OnMouseLeaveModal="ScheduleCloseAsync" />
@if (ShowSearchBox)
{
var sectionDescriptors = descriptors.Where(d => d.SectionId == sectionId).ToList();
if (sectionDescriptors.Count > 0)
{
<EffectiveConfigSectionPanel SectionId="@sectionId" Title="@title" Descriptors="@sectionDescriptors" IsOpen="@IsOpen(sectionId)" OnToggle="@OnSectionToggle" DisplayMode="@DisplayMode" />
}
<div class="ec-toolbar">
<input type="search" id="ec-search-input" class="form-control form-control-sm" placeholder="Search..." autocomplete="off"
@bind="_searchTerm" @bind:event="oninput" @bind:after="StateHasChanged" />
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CollapseAll" title="Collapse all sections" aria-label="Collapse all sections">
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
</button>
</div>
}
<div class="ec-sections small">
@{
var views = EffectiveConfigViews.GetViewsForContext(Context);
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views);
var hasSearch = !string.IsNullOrWhiteSpace(_searchTerm);
var term = _searchTerm?.Trim() ?? "";
}
@foreach (var view in views)
{
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
continue;
var sectionDescriptors = hasSearch
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
: contextDescriptors.ToList();
if (sectionDescriptors.Count == 0)
continue;
var isOpen = hasSearch ? true : IsOpen(view.SectionId);
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle" DisplayMode="@DisplayMode" OnOptionHelpRequested="@HandleOptionHelpRequested" />
}
</div>
</div>
}
@code {
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
[Parameter] public bool ShowSearchBox { get; set; } = true;
/// <summary>View = read-only; Edit = editable (future). Cascaded to field displays.</summary>
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
private string _searchTerm = "";
private HashSet<string> _openPanels = new();
private string? _optionHelpKey;
private string? _optionHelpAnchorId;
private string _optionHelpTitle = "Option help";
private bool _showOptionHelp;
private CancellationTokenSource? _closeCts;
private bool IsOpen(string id) => _openPanels.Contains(id);
private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args)
{
if (args.IsLabelMouseLeave)
{
if (args.AnchorId == _optionHelpAnchorId)
ScheduleClose();
return;
}
_optionHelpKey = args.HelpKey;
_optionHelpTitle = args.OptionLabel;
_optionHelpAnchorId = args.AnchorId;
_showOptionHelp = true;
CancelCloseTimer();
await InvokeAsync(StateHasChanged);
}
private void CancelCloseTimer()
{
_closeCts?.Cancel();
_closeCts = null;
}
private Task CancelCloseTimerAsync() { CancelCloseTimer(); return Task.CompletedTask; }
private void ScheduleClose()
{
_closeCts?.Cancel();
_closeCts = new CancellationTokenSource();
var token = _closeCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(300, token);
}
catch (OperationCanceledException) { return; }
await InvokeAsync(() =>
{
if (token.IsCancellationRequested) return;
_showOptionHelp = false;
_closeCts = null;
StateHasChanged();
});
}, token);
}
private Task ScheduleCloseAsync() { ScheduleClose(); return Task.CompletedTask; }
private void CloseOptionHelp()
{
CancelCloseTimer();
_showOptionHelp = false;
StateHasChanged();
}
private void OnSectionToggle(string id)
{
if (_openPanels.Contains(id))
@@ -45,4 +120,25 @@
else
_openPanels.Add(id);
}
private void CollapseAll()
{
_openPanels.Clear();
StateHasChanged();
}
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
{
if (string.IsNullOrEmpty(term)) return true;
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
}
public ValueTask DisposeAsync()
{
_closeCts?.Cancel();
_closeCts?.Dispose();
_closeCts = null;
return ValueTask.CompletedTask;
}
}
@@ -1,9 +1,35 @@
/* Effective config sections – mobile-first, touch-friendly.
Use ::deep so styles apply to EffectiveConfigSectionPanel (child component) markup. */
/* Wrapper: border and grouping around all sections. Margin-top separates from config files list above. */
.ec-container {
margin-top: 0.75rem;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.5rem;
padding: 1rem;
background-color: var(--bs-secondary-bg, #f8f9fa);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
min-width: 0;
overflow: visible;
}
.ec-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.ec-toolbar input[type="search"] {
flex: 1;
min-width: 0;
}
.ec-sections {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
::deep .ec-section {
@@ -14,7 +40,7 @@
border-bottom: none;
}
/* Touch-friendly header: min 44px tap target (WCAG 2.5.5), breathing room from edges */
/* Section header: clear separation from background, touch-friendly (min 44px), distinct so search jumps are obvious */
::deep .ec-section-header {
display: flex;
align-items: center;
@@ -24,20 +50,22 @@
padding: 0.625rem 1rem;
margin: 0;
border: none;
border-left: 3px solid var(--bs-border-color, #dee2e6);
border-radius: 0.25rem;
background: transparent;
background-color: var(--bs-body-bg, #fff);
color: inherit;
text-align: left;
cursor: pointer;
font-size: inherit;
line-height: 1.3;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.15s ease, color 0.15s ease;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
::deep .ec-section-header:hover {
color: var(--bs-primary, #0d6efd);
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
border-left-color: var(--bs-primary, #0d6efd);
}
::deep .ec-section-header:focus-visible {
@@ -45,15 +73,17 @@
outline-offset: 2px;
}
/* Open state: header reads as "active" and clearly separates from body */
/* Open state: header reads as "active", left border accent */
::deep .ec-section-open .ec-section-header {
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.05));
background-color: var(--bs-body-bg, #fff);
color: var(--bs-emphasis-color, inherit);
font-weight: 600;
border-left-color: var(--bs-primary, #0d6efd);
box-shadow: 0 1px 0 var(--bs-border-color, #dee2e6);
}
::deep .ec-section-open .ec-section-header:hover {
background-color: var(--bs-tertiary-bg, rgba(0, 0, 0, 0.08));
background-color: var(--bs-secondary-bg, rgba(0, 0, 0, 0.04));
color: var(--bs-primary, #0d6efd);
}
@@ -79,6 +109,8 @@
padding-left: 1rem;
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
margin-left: 0;
min-width: 0;
overflow: visible;
overflow-wrap: break-word;
word-break: break-word;
}
@@ -112,6 +144,17 @@
min-width: 0;
}
/* Clickable label: open full option help on click (hover tooltip unchanged) */
::deep .ec-field-label-help {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
::deep .ec-field-label-help:hover {
color: var(--bs-primary, #0d6efd);
}
::deep .ec-field-value {
flex: 1 1 auto;
min-width: 0;
@@ -119,21 +162,26 @@
word-break: break-word;
}
/* Multi-value: full width below label, indented ordered list */
/* Multi-value: full width below label, indented ordered list. min-width: 0 so flex doesn't prevent wrapping. */
::deep .ec-field-value-multi {
flex-basis: 100%;
min-width: 0;
padding-left: 1.25rem;
margin-top: 0.15rem;
overflow-wrap: break-word;
word-break: break-word;
}
::deep .ec-field-value-list {
margin: 0;
padding-left: 1.25rem;
list-style-type: decimal;
min-width: 0;
}
::deep .ec-field-value-list li {
margin-bottom: 0.2rem;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
}
@@ -142,14 +190,31 @@
margin-bottom: 0;
}
/* Long paths/URLs wrap; allow break anywhere so nothing is clipped */
::deep .ec-field-value-item {
display: inline;
overflow-wrap: break-word;
word-break: break-word;
}
::deep .ec-field-value-sep {
margin-right: 0.25rem;
}
/* Badge: vertically centered, inline with value so when it wraps it stays part of the value flow */
::deep .ec-field .badge,
::deep .ec-field-value-item .badge {
display: inline-flex;
align-items: center;
line-height: 1;
}
/* Inline badge (inside value): gap from text; when it wraps to own line the margin gives a subtle indent */
::deep .ec-badge-inline {
margin-left: 0.35rem;
white-space: nowrap;
}
@media (max-width: 767.98px) {
::deep .ec-section-header {
padding-left: 0.75rem;
@@ -1,5 +1,6 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<CascadingValue Value="DisplayMode">
<div class="ec-section @(IsOpen ? "ec-section-open" : "")">
@@ -12,7 +13,7 @@
<ul class="list-unstyled mb-0">
@foreach (var f in Descriptors)
{
<EffectiveConfigFieldDisplay Descriptor="@f" />
<EffectiveConfigFieldDisplay Descriptor="@f" OnOptionHelpRequested="@OnOptionHelpRequested" />
}
</ul>
</div>
@@ -26,6 +27,8 @@
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<string> OnToggle { get; set; }
/// <summary>Raised when user requests option help (click or mouse leave from label). Passed through from Section.</summary>
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable (future).</summary>
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
@@ -2,6 +2,8 @@
@using System.Reflection
@inject IOptions<ApplicationOptions> AppOptions
@inject ISettingsModalService SettingsModalService
@inject IUpdateCheckService UpdateCheckService
@implements IDisposable
<div class="top-row ps-3 navbar navbar-dark">
<div class="top-row-inner">
@@ -36,10 +38,33 @@
</NavLink>
</div>
</nav>
<div class="nav-footer px-3 py-2" aria-label="Application version">
<a class="nav-footer-link" href="@ReleaseUrl" target="_blank" rel="noopener noreferrer" title="View release on GitHub">
v@(BaseVersion(Version))
</a>
<div class="nav-footer px-3 py-2" aria-label="Links and version">
<a class="nav-footer-link" href="https://github.com/alexhopeoconnor/dnsmasq-webui" target="_blank" rel="noopener noreferrer" title="dnsmasq-webui project on GitHub">Project</a>
<span class="nav-footer-sep" aria-hidden="true">·</span>
<a class="nav-footer-link" href="https://thekelleys.org.uk/dnsmasq/doc.html" target="_blank" rel="noopener noreferrer" title="Official dnsmasq documentation">Docs</a>
<span class="nav-footer-sep" aria-hidden="true">·</span>
<a class="nav-footer-link" href="@ReleaseUrl" target="_blank" rel="noopener noreferrer" title="View release on GitHub">v@(BaseVersion(Version))</a>
<span class="nav-footer-version-actions" aria-hidden="true">[
@if (UpdateCheckService.NewerVersionAvailable)
{
<a class="nav-footer-link nav-footer-new-release" href="@UpdateCheckService.NewerVersionUrl" target="_blank" rel="noopener noreferrer" title="New version @(UpdateCheckService.NewerVersionTag ?? "") is available" aria-label="New version @(UpdateCheckService.NewerVersionTag ?? "") is available">
<i class="bi bi-download" aria-hidden="true"></i>
</a>
}
else
{
<button type="button" class="nav-footer-link nav-footer-check-btn" @onclick="ForceCheckAsync" disabled="@UpdateCheckService.CheckInProgress" title="@GetCheckButtonTooltip()" aria-label="@GetCheckButtonTooltip()">
@if (UpdateCheckService.CheckInProgress)
{
<i class="bi bi-arrow-repeat nav-footer-spin" aria-hidden="true"></i>
}
else
{
<i class="bi bi-arrow-repeat" aria-hidden="true"></i>
}
</button>
}
]</span>
</div>
</div>
@@ -47,6 +72,7 @@
private const string ReleasesBaseUrl = "https://github.com/alexhopeoconnor/dnsmasq-webui/releases";
private static readonly string Version = typeof(NavMenu).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "?";
/// <summary>Base version without build metadata (e.g. 0.0.4+abc123 -> 0.0.4) for GitHub tag URLs.</summary>
private static string BaseVersion(string v)
{
@@ -59,5 +85,35 @@
: ReleasesBaseUrl;
private string? AppTitle => AppOptions?.Value?.EffectiveTitle;
protected override void OnInitialized()
{
UpdateCheckService.ResultChanged += OnUpdateCheckResultChanged;
}
public void Dispose()
{
UpdateCheckService.ResultChanged -= OnUpdateCheckResultChanged;
}
private void OnUpdateCheckResultChanged(object? sender, EventArgs e)
{
_ = InvokeAsync(StateHasChanged);
}
private string GetCheckButtonTooltip()
{
if (UpdateCheckService.CheckInProgress)
return "Checking…";
var last = UpdateCheckService.LastCheckTime;
if (last.HasValue)
return $"No new update available. Checked at {last.Value.ToLocalTime():t}. Check again.";
return "Check for updates";
}
private async Task ForceCheckAsync()
{
await UpdateCheckService.CheckNowAsync();
}
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
}
@@ -135,6 +135,7 @@
margin-top: auto;
flex-shrink: 0;
font-size: 0.8125rem;
text-align: right;
}
.nav-footer-link {
@@ -146,6 +147,58 @@
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-footer-sep {
color: rgba(255, 255, 255, 0.45);
margin: 0 0.35rem;
user-select: none;
}
.nav-footer-version-actions {
margin-left: 0.15rem;
color: rgba(255, 255, 255, 0.65);
}
.nav-footer-version-actions .nav-footer-link,
.nav-footer-version-actions .nav-footer-check-btn {
color: inherit;
}
.nav-footer-new-release {
display: inline-flex;
align-items: center;
}
.nav-footer-new-release:hover {
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-footer-check-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
color: inherit;
}
.nav-footer-check-btn:disabled {
cursor: default;
opacity: 0.8;
}
.nav-footer-check-btn:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-footer-spin {
animation: nav-footer-spin 0.8s linear infinite;
}
@keyframes nav-footer-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.nav-scrollable {
display: none;
flex-direction: column;
@@ -322,6 +322,11 @@ else
</div>
}
</section>
<section class="page-section">
<h2 class="page-section-title">Config affecting DHCP</h2>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" />
</section>
}
@code {
@@ -56,7 +56,7 @@ else if (_status != null)
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
}
</ul>
<EffectiveConfigSection Status="_status" />
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.All" ShowSearchBox="true" />
@if (_status.ReloadCommandConfigured)
{
<div class="d-flex align-items-center gap-2 flex-wrap mt-3">
+34 -27
View File
@@ -15,37 +15,44 @@
Loading...
</p>
}
else if (string.IsNullOrEmpty(_status.ManagedHostsFilePath))
{
<p class="text-muted">Hosts editing is unavailable: managed hosts path is not configured. Set <strong>Dnsmasq:MainConfigPath</strong> (and optionally <strong>Dnsmasq:ManagedHostsFileName</strong>) so the app can create and edit the managed hosts file.</p>
@if (_status.AddnHostsPaths?.Count > 0)
{
<p class="text-muted small">Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()</p>
}
}
else
{
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
<section class="page-section mb-4">
<h2 class="page-section-title">Config affecting hosts</h2>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Hosts" ShowSearchBox="true" />
</section>
@if (string.IsNullOrEmpty(_status.ManagedHostsFilePath))
{
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
}
@if (_error != null)
{
<div class="alert alert-danger">@_error</div>
}
@if (_message != null)
{
<div class="alert alert-success">@_message</div>
}
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
@if (_readOnlyHosts?.Count > 0)
{
@foreach (var ro in _readOnlyHosts)
<p class="text-muted">Hosts editing is unavailable: managed hosts path is not configured. Set <strong>Dnsmasq:MainConfigPath</strong> (and optionally <strong>Dnsmasq:ManagedHostsFileName</strong>) so the app can create and edit the managed hosts file.</p>
@if (_status.AddnHostsPaths?.Count > 0)
{
<HostsFileSection Path="@ro.Path" Entries="@ro.Entries" IsEditable="false" IsFirst="false" @key="@ro.Path" />
<p class="text-muted small">Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()</p>
}
}
else
{
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
{
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
}
@if (_error != null)
{
<div class="alert alert-danger">@_error</div>
}
@if (_message != null)
{
<div class="alert alert-success">@_message</div>
}
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
@if (_readOnlyHosts?.Count > 0)
{
@foreach (var ro in _readOnlyHosts)
{
<HostsFileSection Path="@ro.Path" Entries="@ro.Entries" IsEditable="false" IsFirst="false" @key="@ro.Path" />
}
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<Version>0.0.4</Version>
<Version>0.0.5</Version>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
<DisableBuildCompression>true</DisableBuildCompression>
@@ -12,6 +12,9 @@ public static class ServiceCollectionExtensions
/// <summary>Name of the HttpClient used for same-host API calls (status, config, reload, hosts, etc.).</summary>
public const string DnsmasqApiClientName = "DnsmasqWebUI.Api";
/// <summary>Name of the HttpClient used for GitHub API (e.g. latest release check).</summary>
public const string GitHubClientName = "DnsmasqWebUI.GitHub";
/// <summary>
/// Registers the named HttpClient for same-host API calls and all typed API clients
/// (IStatusClient, IConfigSetClient, IReloadClient, IHostsClient, IDhcpHostsClient, ILeasesClient).
@@ -32,6 +35,13 @@ public static class ServiceCollectionExtensions
services.AddHttpClient<ILeasesClient, LeasesClient>(DnsmasqApiClientName);
services.AddHttpClient<ILoggingClient, LoggingClient>(DnsmasqApiClientName);
services.AddHttpClient(GitHubClientName, client =>
{
client.BaseAddress = new Uri("https://api.github.com/", UriKind.Absolute);
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "dnsmasq-webui");
client.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/vnd.github.v3+json");
});
return services;
}
@@ -21,6 +21,11 @@ public static class DnsmasqConfKeys
public const string DhcpLeasefile = "dhcp-leasefile";
public const string DhcpLease = "dhcp-lease";
// --- DHCP include files/dirs (multi-value paths; lines still come from conf only, we just show paths) ---
public const string DhcpHostsfile = "dhcp-hostsfile";
public const string DhcpOptsfile = "dhcp-optsfile";
public const string DhcpHostsdir = "dhcp-hostsdir";
// --- Multi-value (ARG_DUP) ---
public const string Server = "server";
public const string Local = "local";
@@ -104,6 +109,11 @@ public static class DnsmasqConfKeys
public const string TftpNoBlocksize = "tftp-no-blocksize";
public const string Dnssec = "dnssec";
public const string DnssecCheckUnsigned = "dnssec-check-unsigned";
public const string ProxyDnssec = "proxy-dnssec";
// --- Process / daemon behaviour (flags) ---
public const string KeepInForeground = "keep-in-foreground";
public const string NoDaemon = "no-daemon";
// --- Single-value (last wins) ---
public const string CacheSize = "cache-size";
@@ -135,6 +145,9 @@ public static class DnsmasqConfKeys
public const string FastDnsRetry = "fast-dns-retry";
public const string DhcpScript = "dhcp-script";
// --- Niche / platform (Linux conntrack mark for UBus/query filtering) ---
public const string Conntrack = "conntrack";
/// <summary>Keys collected for effective config "server/local" multi-value (order preserved).</summary>
public static readonly string[] ServerLocalKeys = { Server, Local };
}
@@ -11,6 +11,10 @@ public static class DnsmasqOptionTooltips
/// <summary>Display label used in EffectiveConfigFieldBuilder for server/local multi-value.</summary>
public const string ServerLocalLabel = "server / local";
/// <summary>Option name to option-help file key. "server / local" → "server"; others unchanged.</summary>
public static string GetOptionHelpKey(string optionName) =>
string.Equals(optionName, ServerLocalLabel, StringComparison.Ordinal) ? "server" : optionName;
private static readonly FrozenDictionary<string, string> Tooltips = new Dictionary<string, string>(StringComparer.Ordinal)
{
// --- Hosts ---
@@ -91,6 +95,9 @@ public static class DnsmasqOptionTooltips
[DnsmasqConfKeys.DhcpMac] = "Set tag when client MAC matches pattern. Can repeat.",
[DnsmasqConfKeys.DhcpNameMatch] = "Set tag when client name matches. Can repeat.",
[DnsmasqConfKeys.DhcpIgnoreNames] = "Ignore client names when tag set. Can repeat.",
[DnsmasqConfKeys.DhcpHostsfile] = "File(s) containing dhcp-host-style lines. Can repeat.",
[DnsmasqConfKeys.DhcpOptsfile] = "File containing dhcp-option-style lines.",
[DnsmasqConfKeys.DhcpHostsdir] = "Directory of files read like dhcp-hostsfile (watched for changes). Can repeat.",
[DnsmasqConfKeys.DhcpBoot] = "Boot file and server for PXE. Can repeat.",
[DnsmasqConfKeys.DhcpIgnore] = "Ignore DHCP clients (e.g. by tag). Can repeat.",
[DnsmasqConfKeys.DhcpVendorclass] = "Match by DHCP vendor class. Can repeat.",
@@ -139,6 +146,9 @@ public static class DnsmasqOptionTooltips
[DnsmasqConfKeys.EnableUbus] = "Enable UBus interface (optional service name).",
[DnsmasqConfKeys.EnableRa] = "Enable router advertisements for DHCPv6 subnets.",
[DnsmasqConfKeys.LogDhcp] = "Log DHCP transactions (leases, options).",
[DnsmasqConfKeys.KeepInForeground] = "Do not daemonise; stay in foreground (e.g. under systemd).",
[DnsmasqConfKeys.NoDaemon] = "Debug mode: no fork, no pid file, log to stderr.",
[DnsmasqConfKeys.Conntrack] = "Linux connection-tracking mark for UBus/query filtering (platform-dependent).",
}.ToFrozenDictionary();
/// <summary>Returns a short tooltip for the option, or null if none is defined.</summary>
@@ -27,14 +27,15 @@ public static class EffectiveConfigFieldBuilder
};
}
public const string SectionHosts = "hosts";
public const string SectionResolver = "resolver";
public const string SectionDnsRecords = "dns-records";
public const string SectionDhcp = "dhcp";
public const string SectionTftpPxe = "tftp-pxe";
public const string SectionDnssec = "dnssec";
public const string SectionCache = "cache";
public const string SectionProcess = "process";
/// <summary>Section IDs for use by registry/views. Prefer EffectiveConfigSections for section list and option→section mapping.</summary>
public static string SectionHosts => EffectiveConfigSections.SectionHosts;
public static string SectionResolver => EffectiveConfigSections.SectionResolver;
public static string SectionDnsRecords => EffectiveConfigSections.SectionDnsRecords;
public static string SectionDhcp => EffectiveConfigSections.SectionDhcp;
public static string SectionTftpPxe => EffectiveConfigSections.SectionTftpPxe;
public static string SectionDnssec => EffectiveConfigSections.SectionDnssec;
public static string SectionCache => EffectiveConfigSections.SectionCache;
public static string SectionProcess => EffectiveConfigSections.SectionProcess;
public static IReadOnlyList<EffectiveConfigFieldDescriptor> BuildFieldDescriptors(DnsmasqServiceStatus? status)
{
@@ -43,142 +44,149 @@ public static class EffectiveConfigFieldBuilder
var list = new List<EffectiveConfigFieldDescriptor>();
// --- Hosts ---
list.Add(Single(SectionHosts, DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null));
list.Add(Multi(SectionHosts, DnsmasqConfKeys.AddnHosts, status,
s => ToValueWithSourceList(s?.AddnHostsPaths, Sources(s)?.AddnHostsPaths)));
list.Add(Single(SectionHosts, DnsmasqConfKeys.Hostsdir, status, s => Config(s)?.HostsdirPath, s => Sources(s)?.HostsdirPath, null));
list.Add(Single(SectionHosts, DnsmasqConfKeys.ReadEthers, status, s => Config(s)?.ReadEthers, s => Sources(s)?.ReadEthers, null));
// Hosts
list.Add(Single(DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null));
list.Add(Multi(DnsmasqConfKeys.AddnHosts, status, s => ToValueWithSourceList(s?.AddnHostsPaths, Sources(s)?.AddnHostsPaths)));
list.Add(Single(DnsmasqConfKeys.Hostsdir, status, s => Config(s)?.HostsdirPath, s => Sources(s)?.HostsdirPath, null));
list.Add(Single(DnsmasqConfKeys.ReadEthers, status, s => Config(s)?.ReadEthers, s => Sources(s)?.ReadEthers, null));
// --- Resolver / DNS (most-used first: server/local, rev-server, address, resolv-file) ---
list.Add(Multi(SectionResolver, "server / local", status, Items(ec => ec?.ServerLocalValues, src => src?.ServerLocalValues)));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.RevServer, status, Items(ec => ec?.RevServerValues, src => src?.RevServerValues)));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.Address, status, Items(ec => ec?.AddressValues, src => src?.AddressValues)));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.ResolvFile, status, Items(ec => ec?.ResolvFiles, src => src?.ResolvFiles)));
list.Add(Single(SectionResolver, DnsmasqConfKeys.NoResolv, status, s => Config(s)?.NoResolv, s => Sources(s)?.NoResolv, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.DomainNeeded, status, s => Config(s)?.DomainNeeded, s => Sources(s)?.DomainNeeded, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.Port, status, s => Config(s)?.Port, s => Sources(s)?.Port, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.LogQueries, status, s => Config(s)?.LogQueries, s => Sources(s)?.LogQueries, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.StrictOrder, status, s => Config(s)?.StrictOrder, s => Sources(s)?.StrictOrder, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.AllServers, status, s => Config(s)?.AllServers, s => Sources(s)?.AllServers, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.AuthTtl, status, s => Config(s)?.AuthTtl, s => Sources(s)?.AuthTtl, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.EdnsPacketMax, status, s => Config(s)?.EdnsPacketMax, s => Sources(s)?.EdnsPacketMax, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.QueryPort, status, s => Config(s)?.QueryPort, s => Sources(s)?.QueryPort, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.PortLimit, status, s => Config(s)?.PortLimit, s => Sources(s)?.PortLimit, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.MinPort, status, s => Config(s)?.MinPort, s => Sources(s)?.MinPort, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.MaxPort, status, s => Config(s)?.MaxPort, s => Sources(s)?.MaxPort, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.DnsLoopDetect, status, s => Config(s)?.DnsLoopDetect, s => Sources(s)?.DnsLoopDetect, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.StopDnsRebind, status, s => Config(s)?.StopDnsRebind, s => Sources(s)?.StopDnsRebind, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.RebindLocalhostOk, status, s => Config(s)?.RebindLocalhostOk, s => Sources(s)?.RebindLocalhostOk, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.ClearOnReload, status, s => Config(s)?.ClearOnReload, s => Sources(s)?.ClearOnReload, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.ExpandHosts, status, s => Config(s)?.ExpandHosts, s => Sources(s)?.ExpandHosts, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.BogusPriv, status, s => Config(s)?.BogusPriv, s => Sources(s)?.BogusPriv, null));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.RebindDomainOk, status, Items(ec => ec?.RebindDomainOkValues, src => src?.RebindDomainOkValues)));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.BogusNxdomain, status, Items(ec => ec?.BogusNxdomainValues, src => src?.BogusNxdomainValues)));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.IgnoreAddress, status, Items(ec => ec?.IgnoreAddressValues, src => src?.IgnoreAddressValues)));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.Alias, status, Items(ec => ec?.AliasValues, src => src?.AliasValues)));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.FilterRr, status, Items(ec => ec?.FilterRrValues, src => src?.FilterRrValues)));
list.Add(Single(SectionResolver, DnsmasqConfKeys.Filterwin2k, status, s => Config(s)?.Filterwin2k, s => Sources(s)?.Filterwin2k, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.FilterA, status, s => Config(s)?.FilterA, s => Sources(s)?.FilterA, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.FilterAaaa, status, s => Config(s)?.FilterAaaa, s => Sources(s)?.FilterAaaa, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.LocaliseQueries, status, s => Config(s)?.LocaliseQueries, s => Sources(s)?.LocaliseQueries, null));
list.Add(Single(SectionResolver, DnsmasqConfKeys.FastDnsRetry, status, s => Config(s)?.FastDnsRetry, s => Sources(s)?.FastDnsRetry, null));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.Ipset, status, Items(ec => ec?.IpsetValues, src => src?.IpsetValues)));
list.Add(Multi(SectionResolver, DnsmasqConfKeys.Nftset, status, Items(ec => ec?.NftsetValues, src => src?.NftsetValues)));
// Resolver / DNS
list.Add(Multi(DnsmasqOptionTooltips.ServerLocalLabel, status, Items(ec => ec?.ServerLocalValues, src => src?.ServerLocalValues)));
list.Add(Multi(DnsmasqConfKeys.RevServer, status, Items(ec => ec?.RevServerValues, src => src?.RevServerValues)));
list.Add(Multi(DnsmasqConfKeys.Address, status, Items(ec => ec?.AddressValues, src => src?.AddressValues)));
list.Add(Multi(DnsmasqConfKeys.ResolvFile, status, Items(ec => ec?.ResolvFiles, src => src?.ResolvFiles)));
list.Add(Single(DnsmasqConfKeys.NoResolv, status, s => Config(s)?.NoResolv, s => Sources(s)?.NoResolv, null));
list.Add(Single(DnsmasqConfKeys.DomainNeeded, status, s => Config(s)?.DomainNeeded, s => Sources(s)?.DomainNeeded, null));
list.Add(Single(DnsmasqConfKeys.Port, status, s => Config(s)?.Port, s => Sources(s)?.Port, null));
list.Add(Single(DnsmasqConfKeys.LogQueries, status, s => Config(s)?.LogQueries, s => Sources(s)?.LogQueries, null));
list.Add(Single(DnsmasqConfKeys.StrictOrder, status, s => Config(s)?.StrictOrder, s => Sources(s)?.StrictOrder, null));
list.Add(Single(DnsmasqConfKeys.AllServers, status, s => Config(s)?.AllServers, s => Sources(s)?.AllServers, null));
list.Add(Single(DnsmasqConfKeys.AuthTtl, status, s => Config(s)?.AuthTtl, s => Sources(s)?.AuthTtl, null));
list.Add(Single(DnsmasqConfKeys.EdnsPacketMax, status, s => Config(s)?.EdnsPacketMax, s => Sources(s)?.EdnsPacketMax, null));
list.Add(Single(DnsmasqConfKeys.QueryPort, status, s => Config(s)?.QueryPort, s => Sources(s)?.QueryPort, null));
list.Add(Single(DnsmasqConfKeys.PortLimit, status, s => Config(s)?.PortLimit, s => Sources(s)?.PortLimit, null));
list.Add(Single(DnsmasqConfKeys.MinPort, status, s => Config(s)?.MinPort, s => Sources(s)?.MinPort, null));
list.Add(Single(DnsmasqConfKeys.MaxPort, status, s => Config(s)?.MaxPort, s => Sources(s)?.MaxPort, null));
list.Add(Single(DnsmasqConfKeys.DnsLoopDetect, status, s => Config(s)?.DnsLoopDetect, s => Sources(s)?.DnsLoopDetect, null));
list.Add(Single(DnsmasqConfKeys.StopDnsRebind, status, s => Config(s)?.StopDnsRebind, s => Sources(s)?.StopDnsRebind, null));
list.Add(Single(DnsmasqConfKeys.RebindLocalhostOk, status, s => Config(s)?.RebindLocalhostOk, s => Sources(s)?.RebindLocalhostOk, null));
list.Add(Single(DnsmasqConfKeys.ClearOnReload, status, s => Config(s)?.ClearOnReload, s => Sources(s)?.ClearOnReload, null));
list.Add(Single(DnsmasqConfKeys.ExpandHosts, status, s => Config(s)?.ExpandHosts, s => Sources(s)?.ExpandHosts, null));
list.Add(Single(DnsmasqConfKeys.BogusPriv, status, s => Config(s)?.BogusPriv, s => Sources(s)?.BogusPriv, null));
list.Add(Multi(DnsmasqConfKeys.RebindDomainOk, status, Items(ec => ec?.RebindDomainOkValues, src => src?.RebindDomainOkValues)));
list.Add(Multi(DnsmasqConfKeys.BogusNxdomain, status, Items(ec => ec?.BogusNxdomainValues, src => src?.BogusNxdomainValues)));
list.Add(Multi(DnsmasqConfKeys.IgnoreAddress, status, Items(ec => ec?.IgnoreAddressValues, src => src?.IgnoreAddressValues)));
list.Add(Multi(DnsmasqConfKeys.Alias, status, Items(ec => ec?.AliasValues, src => src?.AliasValues)));
list.Add(Multi(DnsmasqConfKeys.FilterRr, status, Items(ec => ec?.FilterRrValues, src => src?.FilterRrValues)));
list.Add(Single(DnsmasqConfKeys.Filterwin2k, status, s => Config(s)?.Filterwin2k, s => Sources(s)?.Filterwin2k, null));
list.Add(Single(DnsmasqConfKeys.FilterA, status, s => Config(s)?.FilterA, s => Sources(s)?.FilterA, null));
list.Add(Single(DnsmasqConfKeys.FilterAaaa, status, s => Config(s)?.FilterAaaa, s => Sources(s)?.FilterAaaa, null));
list.Add(Single(DnsmasqConfKeys.LocaliseQueries, status, s => Config(s)?.LocaliseQueries, s => Sources(s)?.LocaliseQueries, null));
list.Add(Single(DnsmasqConfKeys.FastDnsRetry, status, s => Config(s)?.FastDnsRetry, s => Sources(s)?.FastDnsRetry, null));
list.Add(Multi(DnsmasqConfKeys.Ipset, status, Items(ec => ec?.IpsetValues, src => src?.IpsetValues)));
list.Add(Multi(DnsmasqConfKeys.Nftset, status, Items(ec => ec?.NftsetValues, src => src?.NftsetValues)));
// --- DNS records (authoritative / local) ---
list.Add(Multi(SectionDnsRecords, DnsmasqConfKeys.Domain, status, Items(ec => ec?.DomainValues, src => src?.DomainValues)));
list.Add(Multi(SectionDnsRecords, DnsmasqConfKeys.Cname, status, Items(ec => ec?.CnameValues, src => src?.CnameValues)));
list.Add(Multi(SectionDnsRecords, DnsmasqConfKeys.MxHost, status, Items(ec => ec?.MxHostValues, src => src?.MxHostValues)));
list.Add(Multi(SectionDnsRecords, DnsmasqConfKeys.Srv, status, Items(ec => ec?.SrvValues, src => src?.SrvValues)));
list.Add(Multi(SectionDnsRecords, DnsmasqConfKeys.PtrRecord, status, Items(ec => ec?.PtrRecordValues, src => src?.PtrRecordValues)));
list.Add(Multi(SectionDnsRecords, DnsmasqConfKeys.TxtRecord, status, Items(ec => ec?.TxtRecordValues, src => src?.TxtRecordValues)));
list.Add(Multi(SectionDnsRecords, DnsmasqConfKeys.NaptrRecord, status, Items(ec => ec?.NaptrRecordValues, src => src?.NaptrRecordValues)));
list.Add(Multi(SectionDnsRecords, DnsmasqConfKeys.HostRecord, status, Items(ec => ec?.HostRecordValues, src => src?.HostRecordValues)));
list.Add(Multi(SectionDnsRecords, DnsmasqConfKeys.DynamicHost, status, Items(ec => ec?.DynamicHostValues, src => src?.DynamicHostValues)));
list.Add(Multi(SectionDnsRecords, DnsmasqConfKeys.InterfaceName, status, Items(ec => ec?.InterfaceNameValues, src => src?.InterfaceNameValues)));
list.Add(Single(SectionDnsRecords, DnsmasqConfKeys.MxTarget, status, s => Config(s)?.MxTarget, s => Sources(s)?.MxTarget, null));
list.Add(Single(SectionDnsRecords, DnsmasqConfKeys.Localmx, status, s => Config(s)?.Localmx, s => Sources(s)?.Localmx, null));
list.Add(Single(SectionDnsRecords, DnsmasqConfKeys.Selfmx, status, s => Config(s)?.Selfmx, s => Sources(s)?.Selfmx, null));
// DNS records
list.Add(Multi(DnsmasqConfKeys.Domain, status, Items(ec => ec?.DomainValues, src => src?.DomainValues)));
list.Add(Multi(DnsmasqConfKeys.Cname, status, Items(ec => ec?.CnameValues, src => src?.CnameValues)));
list.Add(Multi(DnsmasqConfKeys.MxHost, status, Items(ec => ec?.MxHostValues, src => src?.MxHostValues)));
list.Add(Multi(DnsmasqConfKeys.Srv, status, Items(ec => ec?.SrvValues, src => src?.SrvValues)));
list.Add(Multi(DnsmasqConfKeys.PtrRecord, status, Items(ec => ec?.PtrRecordValues, src => src?.PtrRecordValues)));
list.Add(Multi(DnsmasqConfKeys.TxtRecord, status, Items(ec => ec?.TxtRecordValues, src => src?.TxtRecordValues)));
list.Add(Multi(DnsmasqConfKeys.NaptrRecord, status, Items(ec => ec?.NaptrRecordValues, src => src?.NaptrRecordValues)));
list.Add(Multi(DnsmasqConfKeys.HostRecord, status, Items(ec => ec?.HostRecordValues, src => src?.HostRecordValues)));
list.Add(Multi(DnsmasqConfKeys.DynamicHost, status, Items(ec => ec?.DynamicHostValues, src => src?.DynamicHostValues)));
list.Add(Multi(DnsmasqConfKeys.InterfaceName, status, Items(ec => ec?.InterfaceNameValues, src => src?.InterfaceNameValues)));
list.Add(Single(DnsmasqConfKeys.MxTarget, status, s => Config(s)?.MxTarget, s => Sources(s)?.MxTarget, null));
list.Add(Single(DnsmasqConfKeys.Localmx, status, s => Config(s)?.Localmx, s => Sources(s)?.Localmx, null));
list.Add(Single(DnsmasqConfKeys.Selfmx, status, s => Config(s)?.Selfmx, s => Sources(s)?.Selfmx, null));
// --- DHCP ---
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null));
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpRapidCommit, status, s => Config(s)?.DhcpRapidCommit, s => Sources(s)?.DhcpRapidCommit, null));
list.Add(Single(SectionDhcp, DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, null));
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpScript, status, s => Config(s)?.DhcpScriptPath, s => Sources(s)?.DhcpScriptPath, null));
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpLeasefile, status, s => Config(s)?.DhcpLeaseFilePath, s => Sources(s)?.DhcpLeaseFilePath, null));
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, status, s => Config(s)?.DhcpLeaseMax, s => Sources(s)?.DhcpLeaseMax, null));
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpTtl, status, s => Config(s)?.DhcpTtl, s => Sources(s)?.DhcpTtl, null));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpRange, status, Items(ec => ec?.DhcpRanges, src => src?.DhcpRanges)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpHost, status, Items(ec => ec?.DhcpHostLines, src => src?.DhcpHostLines)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpOption, status, Items(ec => ec?.DhcpOptionLines, src => src?.DhcpOptionLines)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpOptionForce, status, Items(ec => ec?.DhcpOptionForceLines, src => src?.DhcpOptionForceLines)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpMatch, status, Items(ec => ec?.DhcpMatchValues, src => src?.DhcpMatchValues)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpMac, status, Items(ec => ec?.DhcpMacValues, src => src?.DhcpMacValues)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpNameMatch, status, Items(ec => ec?.DhcpNameMatchValues, src => src?.DhcpNameMatchValues)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpIgnoreNames, status, Items(ec => ec?.DhcpIgnoreNamesValues, src => src?.DhcpIgnoreNamesValues)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpBoot, status, Items(ec => ec?.DhcpBootValues, src => src?.DhcpBootValues)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpIgnore, status, Items(ec => ec?.DhcpIgnoreValues, src => src?.DhcpIgnoreValues)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpVendorclass, status, Items(ec => ec?.DhcpVendorclassValues, src => src?.DhcpVendorclassValues)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.DhcpUserclass, status, Items(ec => ec?.DhcpUserclassValues, src => src?.DhcpUserclassValues)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.RaParam, status, Items(ec => ec?.RaParamValues, src => src?.RaParamValues)));
list.Add(Multi(SectionDhcp, DnsmasqConfKeys.Slaac, status, Items(ec => ec?.SlaacValues, src => src?.SlaacValues)));
// DHCP
list.Add(Single(DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null));
list.Add(Single(DnsmasqConfKeys.DhcpRapidCommit, status, s => Config(s)?.DhcpRapidCommit, s => Sources(s)?.DhcpRapidCommit, null));
list.Add(Single(DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, null));
list.Add(Single(DnsmasqConfKeys.DhcpScript, status, s => Config(s)?.DhcpScriptPath, s => Sources(s)?.DhcpScriptPath, null));
list.Add(Single(DnsmasqConfKeys.DhcpLeasefile, status, s => Config(s)?.DhcpLeaseFilePath, s => Sources(s)?.DhcpLeaseFilePath, null));
list.Add(Single(DnsmasqConfKeys.DhcpLeaseMax, status, s => Config(s)?.DhcpLeaseMax, s => Sources(s)?.DhcpLeaseMax, null));
list.Add(Single(DnsmasqConfKeys.DhcpTtl, status, s => Config(s)?.DhcpTtl, s => Sources(s)?.DhcpTtl, null));
list.Add(Multi(DnsmasqConfKeys.DhcpRange, status, Items(ec => ec?.DhcpRanges, src => src?.DhcpRanges)));
list.Add(Multi(DnsmasqConfKeys.DhcpHost, status, Items(ec => ec?.DhcpHostLines, src => src?.DhcpHostLines)));
list.Add(Multi(DnsmasqConfKeys.DhcpOption, status, Items(ec => ec?.DhcpOptionLines, src => src?.DhcpOptionLines)));
list.Add(Multi(DnsmasqConfKeys.DhcpOptionForce, status, Items(ec => ec?.DhcpOptionForceLines, src => src?.DhcpOptionForceLines)));
list.Add(Multi(DnsmasqConfKeys.DhcpMatch, status, Items(ec => ec?.DhcpMatchValues, src => src?.DhcpMatchValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpMac, status, Items(ec => ec?.DhcpMacValues, src => src?.DhcpMacValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpNameMatch, status, Items(ec => ec?.DhcpNameMatchValues, src => src?.DhcpNameMatchValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpIgnoreNames, status, Items(ec => ec?.DhcpIgnoreNamesValues, src => src?.DhcpIgnoreNamesValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpHostsfile, status, Items(ec => ec?.DhcpHostsfilePaths, src => src?.DhcpHostsfilePaths)));
list.Add(Multi(DnsmasqConfKeys.DhcpOptsfile, status, Items(ec => ec?.DhcpOptsfilePaths, src => src?.DhcpOptsfilePaths)));
list.Add(Multi(DnsmasqConfKeys.DhcpHostsdir, status, Items(ec => ec?.DhcpHostsdirPaths, src => src?.DhcpHostsdirPaths)));
list.Add(Multi(DnsmasqConfKeys.DhcpBoot, status, Items(ec => ec?.DhcpBootValues, src => src?.DhcpBootValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpIgnore, status, Items(ec => ec?.DhcpIgnoreValues, src => src?.DhcpIgnoreValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpVendorclass, status, Items(ec => ec?.DhcpVendorclassValues, src => src?.DhcpVendorclassValues)));
list.Add(Multi(DnsmasqConfKeys.DhcpUserclass, status, Items(ec => ec?.DhcpUserclassValues, src => src?.DhcpUserclassValues)));
list.Add(Multi(DnsmasqConfKeys.RaParam, status, Items(ec => ec?.RaParamValues, src => src?.RaParamValues)));
list.Add(Multi(DnsmasqConfKeys.Slaac, status, Items(ec => ec?.SlaacValues, src => src?.SlaacValues)));
// --- TFTP / PXE ---
list.Add(Single(SectionTftpPxe, DnsmasqConfKeys.EnableTftp, status, s => Config(s)?.EnableTftp, s => Sources(s)?.EnableTftp, null));
list.Add(Single(SectionTftpPxe, DnsmasqConfKeys.TftpSecure, status, s => Config(s)?.TftpSecure, s => Sources(s)?.TftpSecure, null));
list.Add(Single(SectionTftpPxe, DnsmasqConfKeys.TftpNoFail, status, s => Config(s)?.TftpNoFail, s => Sources(s)?.TftpNoFail, null));
list.Add(Single(SectionTftpPxe, DnsmasqConfKeys.TftpNoBlocksize, status, s => Config(s)?.TftpNoBlocksize, s => Sources(s)?.TftpNoBlocksize, null));
list.Add(Single(SectionTftpPxe, DnsmasqConfKeys.TftpRoot, status, s => Config(s)?.TftpRootPath, s => Sources(s)?.TftpRootPath, null));
list.Add(Single(SectionTftpPxe, DnsmasqConfKeys.PxePrompt, status, s => Config(s)?.PxePrompt, s => Sources(s)?.PxePrompt, null));
list.Add(Multi(SectionTftpPxe, DnsmasqConfKeys.PxeService, status, Items(ec => ec?.PxeServiceValues, src => src?.PxeServiceValues)));
// TFTP / PXE
list.Add(Single(DnsmasqConfKeys.EnableTftp, status, s => Config(s)?.EnableTftp, s => Sources(s)?.EnableTftp, null));
list.Add(Single(DnsmasqConfKeys.TftpSecure, status, s => Config(s)?.TftpSecure, s => Sources(s)?.TftpSecure, null));
list.Add(Single(DnsmasqConfKeys.TftpNoFail, status, s => Config(s)?.TftpNoFail, s => Sources(s)?.TftpNoFail, null));
list.Add(Single(DnsmasqConfKeys.TftpNoBlocksize, status, s => Config(s)?.TftpNoBlocksize, s => Sources(s)?.TftpNoBlocksize, null));
list.Add(Single(DnsmasqConfKeys.TftpRoot, status, s => Config(s)?.TftpRootPath, s => Sources(s)?.TftpRootPath, null));
list.Add(Single(DnsmasqConfKeys.PxePrompt, status, s => Config(s)?.PxePrompt, s => Sources(s)?.PxePrompt, null));
list.Add(Multi(DnsmasqConfKeys.PxeService, status, Items(ec => ec?.PxeServiceValues, src => src?.PxeServiceValues)));
// --- DNSSEC ---
list.Add(Single(SectionDnssec, DnsmasqConfKeys.Dnssec, status, s => Config(s)?.Dnssec, s => Sources(s)?.Dnssec, null));
list.Add(Single(SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, status, s => Config(s)?.DnssecCheckUnsigned, s => Sources(s)?.DnssecCheckUnsigned, null));
list.Add(Multi(SectionDnssec, DnsmasqConfKeys.TrustAnchor, status, Items(ec => ec?.TrustAnchorValues, src => src?.TrustAnchorValues)));
// DNSSEC
list.Add(Single(DnsmasqConfKeys.Dnssec, status, s => Config(s)?.Dnssec, s => Sources(s)?.Dnssec, null));
list.Add(Single(DnsmasqConfKeys.DnssecCheckUnsigned, status, s => Config(s)?.DnssecCheckUnsigned, s => Sources(s)?.DnssecCheckUnsigned, null));
list.Add(Single(DnsmasqConfKeys.ProxyDnssec, status, s => Config(s)?.ProxyDnssec, s => Sources(s)?.ProxyDnssec, null));
list.Add(Multi(DnsmasqConfKeys.TrustAnchor, status, Items(ec => ec?.TrustAnchorValues, src => src?.TrustAnchorValues)));
// --- Cache ---
list.Add(Multi(SectionCache, DnsmasqConfKeys.CacheRr, status, Items(ec => ec?.CacheRrValues, src => src?.CacheRrValues)));
list.Add(Single(SectionCache, DnsmasqConfKeys.CacheSize, status, s => Config(s)?.CacheSize, s => Sources(s)?.CacheSize, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.LocalTtl, status, s => Config(s)?.LocalTtl, s => Sources(s)?.LocalTtl, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.NoNegcache, status, s => Config(s)?.NoNegcache, s => Sources(s)?.NoNegcache, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.NegTtl, status, s => Config(s)?.NegTtl, s => Sources(s)?.NegTtl, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.MaxTtl, status, s => Config(s)?.MaxTtl, s => Sources(s)?.MaxTtl, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.MaxCacheTtl, status, s => Config(s)?.MaxCacheTtl, s => Sources(s)?.MaxCacheTtl, null));
list.Add(Single(SectionCache, DnsmasqConfKeys.MinCacheTtl, status, s => Config(s)?.MinCacheTtl, s => Sources(s)?.MinCacheTtl, null));
// Cache
list.Add(Multi(DnsmasqConfKeys.CacheRr, status, Items(ec => ec?.CacheRrValues, src => src?.CacheRrValues)));
list.Add(Single(DnsmasqConfKeys.CacheSize, status, s => Config(s)?.CacheSize, s => Sources(s)?.CacheSize, null));
list.Add(Single(DnsmasqConfKeys.LocalTtl, status, s => Config(s)?.LocalTtl, s => Sources(s)?.LocalTtl, null));
list.Add(Single(DnsmasqConfKeys.NoNegcache, status, s => Config(s)?.NoNegcache, s => Sources(s)?.NoNegcache, null));
list.Add(Single(DnsmasqConfKeys.NegTtl, status, s => Config(s)?.NegTtl, s => Sources(s)?.NegTtl, null));
list.Add(Single(DnsmasqConfKeys.MaxTtl, status, s => Config(s)?.MaxTtl, s => Sources(s)?.MaxTtl, null));
list.Add(Single(DnsmasqConfKeys.MaxCacheTtl, status, s => Config(s)?.MaxCacheTtl, s => Sources(s)?.MaxCacheTtl, null));
list.Add(Single(DnsmasqConfKeys.MinCacheTtl, status, s => Config(s)?.MinCacheTtl, s => Sources(s)?.MinCacheTtl, null));
// --- Process & networking ---
list.Add(Single(SectionProcess, DnsmasqConfKeys.NoPoll, status, s => Config(s)?.NoPoll, s => Sources(s)?.NoPoll, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.BindInterfaces, status, s => Config(s)?.BindInterfaces, s => Sources(s)?.BindInterfaces, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.BindDynamic, status, s => Config(s)?.BindDynamic, s => Sources(s)?.BindDynamic, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.LogDebug, status, s => Config(s)?.LogDebug, s => Sources(s)?.LogDebug, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.LogAsync, status, s => Config(s)?.LogAsync, s => Sources(s)?.LogAsync, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.LocalService, status, s => Config(s)?.LocalService, s => Sources(s)?.LocalService, null));
list.Add(Multi(SectionProcess, DnsmasqConfKeys.Interface, status, Items(ec => ec?.Interfaces, src => src?.Interfaces)));
list.Add(Multi(SectionProcess, DnsmasqConfKeys.ListenAddress, status, Items(ec => ec?.ListenAddresses, src => src?.ListenAddresses)));
list.Add(Multi(SectionProcess, DnsmasqConfKeys.ExceptInterface, status, Items(ec => ec?.ExceptInterfaces, src => src?.ExceptInterfaces)));
list.Add(Multi(SectionProcess, DnsmasqConfKeys.AuthServer, status, Items(ec => ec?.AuthServerValues, src => src?.AuthServerValues)));
list.Add(Multi(SectionProcess, DnsmasqConfKeys.NoDhcpInterface, status, Items(ec => ec?.NoDhcpInterfaceValues, src => src?.NoDhcpInterfaceValues)));
list.Add(Multi(SectionProcess, DnsmasqConfKeys.NoDhcpv4Interface, status, Items(ec => ec?.NoDhcpv4InterfaceValues, src => src?.NoDhcpv4InterfaceValues)));
list.Add(Multi(SectionProcess, DnsmasqConfKeys.NoDhcpv6Interface, status, Items(ec => ec?.NoDhcpv6InterfaceValues, src => src?.NoDhcpv6InterfaceValues)));
list.Add(Single(SectionProcess, DnsmasqConfKeys.PidFile, status, s => Config(s)?.PidFilePath, s => Sources(s)?.PidFilePath, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.User, status, s => Config(s)?.User, s => Sources(s)?.User, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.Group, status, s => Config(s)?.Group, s => Sources(s)?.Group, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.LogFacility, status, s => Config(s)?.LogFacility, s => Sources(s)?.LogFacility, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.EnableDbus, status, s => Config(s)?.EnableDbus, s => Sources(s)?.EnableDbus, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.EnableUbus, status, s => Config(s)?.EnableUbus, s => Sources(s)?.EnableUbus, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.EnableRa, status, s => Config(s)?.EnableRa, s => Sources(s)?.EnableRa, null));
list.Add(Single(SectionProcess, DnsmasqConfKeys.LogDhcp, status, s => Config(s)?.LogDhcp, s => Sources(s)?.LogDhcp, null));
// Process & networking
list.Add(Single(DnsmasqConfKeys.NoPoll, status, s => Config(s)?.NoPoll, s => Sources(s)?.NoPoll, null));
list.Add(Single(DnsmasqConfKeys.BindInterfaces, status, s => Config(s)?.BindInterfaces, s => Sources(s)?.BindInterfaces, null));
list.Add(Single(DnsmasqConfKeys.BindDynamic, status, s => Config(s)?.BindDynamic, s => Sources(s)?.BindDynamic, null));
list.Add(Single(DnsmasqConfKeys.LogDebug, status, s => Config(s)?.LogDebug, s => Sources(s)?.LogDebug, null));
list.Add(Single(DnsmasqConfKeys.LogAsync, status, s => Config(s)?.LogAsync, s => Sources(s)?.LogAsync, null));
list.Add(Single(DnsmasqConfKeys.LocalService, status, s => Config(s)?.LocalService, s => Sources(s)?.LocalService, null));
list.Add(Multi(DnsmasqConfKeys.Interface, status, Items(ec => ec?.Interfaces, src => src?.Interfaces)));
list.Add(Multi(DnsmasqConfKeys.ListenAddress, status, Items(ec => ec?.ListenAddresses, src => src?.ListenAddresses)));
list.Add(Multi(DnsmasqConfKeys.ExceptInterface, status, Items(ec => ec?.ExceptInterfaces, src => src?.ExceptInterfaces)));
list.Add(Multi(DnsmasqConfKeys.AuthServer, status, Items(ec => ec?.AuthServerValues, src => src?.AuthServerValues)));
list.Add(Multi(DnsmasqConfKeys.NoDhcpInterface, status, Items(ec => ec?.NoDhcpInterfaceValues, src => src?.NoDhcpInterfaceValues)));
list.Add(Multi(DnsmasqConfKeys.NoDhcpv4Interface, status, Items(ec => ec?.NoDhcpv4InterfaceValues, src => src?.NoDhcpv4InterfaceValues)));
list.Add(Multi(DnsmasqConfKeys.NoDhcpv6Interface, status, Items(ec => ec?.NoDhcpv6InterfaceValues, src => src?.NoDhcpv6InterfaceValues)));
list.Add(Single(DnsmasqConfKeys.PidFile, status, s => Config(s)?.PidFilePath, s => Sources(s)?.PidFilePath, null));
list.Add(Single(DnsmasqConfKeys.User, status, s => Config(s)?.User, s => Sources(s)?.User, null));
list.Add(Single(DnsmasqConfKeys.Group, status, s => Config(s)?.Group, s => Sources(s)?.Group, null));
list.Add(Single(DnsmasqConfKeys.LogFacility, status, s => Config(s)?.LogFacility, s => Sources(s)?.LogFacility, null));
list.Add(Single(DnsmasqConfKeys.EnableDbus, status, s => Config(s)?.EnableDbus, s => Sources(s)?.EnableDbus, null));
list.Add(Single(DnsmasqConfKeys.EnableUbus, status, s => Config(s)?.EnableUbus, s => Sources(s)?.EnableUbus, null));
list.Add(Single(DnsmasqConfKeys.EnableRa, status, s => Config(s)?.EnableRa, s => Sources(s)?.EnableRa, null));
list.Add(Single(DnsmasqConfKeys.LogDhcp, status, s => Config(s)?.LogDhcp, s => Sources(s)?.LogDhcp, null));
list.Add(Single(DnsmasqConfKeys.KeepInForeground, status, s => Config(s)?.KeepInForeground, s => Sources(s)?.KeepInForeground, null));
list.Add(Single(DnsmasqConfKeys.NoDaemon, status, s => Config(s)?.NoDaemon, s => Sources(s)?.NoDaemon, null));
list.Add(Single(DnsmasqConfKeys.Conntrack, status, s => Config(s)?.Conntrack, s => Sources(s)?.Conntrack, null));
return list;
}
private static EffectiveConfigFieldDescriptor Single(string sectionId, string optionName, DnsmasqServiceStatus? status,
private static EffectiveConfigFieldDescriptor Single(string optionName, DnsmasqServiceStatus? status,
Func<DnsmasqServiceStatus?, object?>? getValue,
Func<DnsmasqServiceStatus?, ConfigValueSource?>? getSource,
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
{
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
return new EffectiveConfigFieldDescriptor(sectionId, optionName, false, status, getValue, getSource, getItems);
}
@@ -190,9 +198,10 @@ public static class EffectiveConfigFieldBuilder
: paths.Select(p => new ValueWithSource(p, null)).ToList();
}
private static EffectiveConfigFieldDescriptor Multi(string sectionId, string optionName, DnsmasqServiceStatus? status,
private static EffectiveConfigFieldDescriptor Multi(string optionName, DnsmasqServiceStatus? status,
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
{
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
return new EffectiveConfigFieldDescriptor(sectionId, optionName, true, status, null, null, getItems);
}
}
@@ -0,0 +1,194 @@
using System.Collections.Frozen;
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// <summary>
/// Single source of truth: which effective-config sections exist, their display order and titles,
/// and which option (conf key) belongs to which section. Used by the field builder (section lookup)
/// and by the UI (section order, context filtering).
/// </summary>
public static class EffectiveConfigSections
{
// --- Section IDs (used by registry, views, etc.) ---
public const string SectionHosts = "hosts";
public const string SectionResolver = "resolver";
public const string SectionDnsRecords = "dns-records";
public const string SectionDhcp = "dhcp";
public const string SectionTftpPxe = "tftp-pxe";
public const string SectionDnssec = "dnssec";
public const string SectionCache = "cache";
public const string SectionProcess = "process";
/// <summary>Section definition: id, display title, option names in display order.</summary>
public sealed record SectionDef(string SectionId, string Title, string[] OptionNames);
/// <summary>All sections in display order with their option names. Defines option → section mapping.</summary>
public static readonly IReadOnlyList<SectionDef> Sections =
[
new SectionDef(SectionHosts, "Hosts", [
DnsmasqConfKeys.NoHosts,
DnsmasqConfKeys.AddnHosts,
DnsmasqConfKeys.Hostsdir,
DnsmasqConfKeys.ReadEthers,
]),
new SectionDef(SectionResolver, "Resolver / DNS", [
DnsmasqOptionTooltips.ServerLocalLabel,
DnsmasqConfKeys.RevServer,
DnsmasqConfKeys.Address,
DnsmasqConfKeys.ResolvFile,
DnsmasqConfKeys.NoResolv,
DnsmasqConfKeys.DomainNeeded,
DnsmasqConfKeys.Port,
DnsmasqConfKeys.LogQueries,
DnsmasqConfKeys.StrictOrder,
DnsmasqConfKeys.AllServers,
DnsmasqConfKeys.AuthTtl,
DnsmasqConfKeys.EdnsPacketMax,
DnsmasqConfKeys.QueryPort,
DnsmasqConfKeys.PortLimit,
DnsmasqConfKeys.MinPort,
DnsmasqConfKeys.MaxPort,
DnsmasqConfKeys.DnsLoopDetect,
DnsmasqConfKeys.StopDnsRebind,
DnsmasqConfKeys.RebindLocalhostOk,
DnsmasqConfKeys.ClearOnReload,
DnsmasqConfKeys.ExpandHosts,
DnsmasqConfKeys.BogusPriv,
DnsmasqConfKeys.RebindDomainOk,
DnsmasqConfKeys.BogusNxdomain,
DnsmasqConfKeys.IgnoreAddress,
DnsmasqConfKeys.Alias,
DnsmasqConfKeys.FilterRr,
DnsmasqConfKeys.Filterwin2k,
DnsmasqConfKeys.FilterA,
DnsmasqConfKeys.FilterAaaa,
DnsmasqConfKeys.LocaliseQueries,
DnsmasqConfKeys.FastDnsRetry,
DnsmasqConfKeys.Ipset,
DnsmasqConfKeys.Nftset,
]),
new SectionDef(SectionDnsRecords, "DNS records", [
DnsmasqConfKeys.Domain,
DnsmasqConfKeys.Cname,
DnsmasqConfKeys.MxHost,
DnsmasqConfKeys.Srv,
DnsmasqConfKeys.PtrRecord,
DnsmasqConfKeys.TxtRecord,
DnsmasqConfKeys.NaptrRecord,
DnsmasqConfKeys.HostRecord,
DnsmasqConfKeys.DynamicHost,
DnsmasqConfKeys.InterfaceName,
DnsmasqConfKeys.MxTarget,
DnsmasqConfKeys.Localmx,
DnsmasqConfKeys.Selfmx,
]),
new SectionDef(SectionDhcp, "DHCP", [
DnsmasqConfKeys.DhcpAuthoritative,
DnsmasqConfKeys.DhcpRapidCommit,
DnsmasqConfKeys.LeasefileRo,
DnsmasqConfKeys.DhcpScript,
DnsmasqConfKeys.DhcpLeasefile,
DnsmasqConfKeys.DhcpLeaseMax,
DnsmasqConfKeys.DhcpTtl,
DnsmasqConfKeys.DhcpRange,
DnsmasqConfKeys.DhcpHost,
DnsmasqConfKeys.DhcpOption,
DnsmasqConfKeys.DhcpOptionForce,
DnsmasqConfKeys.DhcpMatch,
DnsmasqConfKeys.DhcpMac,
DnsmasqConfKeys.DhcpNameMatch,
DnsmasqConfKeys.DhcpIgnoreNames,
DnsmasqConfKeys.DhcpHostsfile,
DnsmasqConfKeys.DhcpOptsfile,
DnsmasqConfKeys.DhcpHostsdir,
DnsmasqConfKeys.DhcpBoot,
DnsmasqConfKeys.DhcpIgnore,
DnsmasqConfKeys.DhcpVendorclass,
DnsmasqConfKeys.DhcpUserclass,
DnsmasqConfKeys.RaParam,
DnsmasqConfKeys.Slaac,
]),
new SectionDef(SectionTftpPxe, "TFTP / PXE", [
DnsmasqConfKeys.EnableTftp,
DnsmasqConfKeys.TftpSecure,
DnsmasqConfKeys.TftpNoFail,
DnsmasqConfKeys.TftpNoBlocksize,
DnsmasqConfKeys.TftpRoot,
DnsmasqConfKeys.PxePrompt,
DnsmasqConfKeys.PxeService,
]),
new SectionDef(SectionDnssec, "DNSSEC", [
DnsmasqConfKeys.Dnssec,
DnsmasqConfKeys.DnssecCheckUnsigned,
DnsmasqConfKeys.ProxyDnssec,
DnsmasqConfKeys.TrustAnchor,
]),
new SectionDef(SectionCache, "Cache", [
DnsmasqConfKeys.CacheRr,
DnsmasqConfKeys.CacheSize,
DnsmasqConfKeys.LocalTtl,
DnsmasqConfKeys.NoNegcache,
DnsmasqConfKeys.NegTtl,
DnsmasqConfKeys.MaxTtl,
DnsmasqConfKeys.MaxCacheTtl,
DnsmasqConfKeys.MinCacheTtl,
]),
new SectionDef(SectionProcess, "Process & networking", [
DnsmasqConfKeys.NoPoll,
DnsmasqConfKeys.BindInterfaces,
DnsmasqConfKeys.BindDynamic,
DnsmasqConfKeys.LogDebug,
DnsmasqConfKeys.LogAsync,
DnsmasqConfKeys.LocalService,
DnsmasqConfKeys.Interface,
DnsmasqConfKeys.ListenAddress,
DnsmasqConfKeys.ExceptInterface,
DnsmasqConfKeys.AuthServer,
DnsmasqConfKeys.NoDhcpInterface,
DnsmasqConfKeys.NoDhcpv4Interface,
DnsmasqConfKeys.NoDhcpv6Interface,
DnsmasqConfKeys.PidFile,
DnsmasqConfKeys.User,
DnsmasqConfKeys.Group,
DnsmasqConfKeys.LogFacility,
DnsmasqConfKeys.EnableDbus,
DnsmasqConfKeys.EnableUbus,
DnsmasqConfKeys.EnableRa,
DnsmasqConfKeys.LogDhcp,
DnsmasqConfKeys.KeepInForeground,
DnsmasqConfKeys.NoDaemon,
DnsmasqConfKeys.Conntrack,
]),
];
private static FrozenDictionary<string, string>? _optionToSection;
/// <summary>Maps option name (conf key or display label) to section id. Used by Single/Multi in the builder.</summary>
public static string GetSectionId(string optionName)
{
_optionToSection ??= BuildOptionToSection();
return _optionToSection.TryGetValue(optionName, out var sectionId) ? sectionId : SectionResolver;
}
/// <summary>Section id and title in display order. Use for rendering section headers.</summary>
public static IReadOnlyList<(string SectionId, string Title)> GetSectionsInOrder() =>
Sections.Select(s => (s.SectionId, s.Title)).ToList();
/// <summary>Option names in the given section (display order). For context/views that restrict by section.</summary>
public static IReadOnlyList<string> GetOptionsInSection(string sectionId)
{
var def = Sections.FirstOrDefault(s => string.Equals(s.SectionId, sectionId, StringComparison.OrdinalIgnoreCase));
return def?.OptionNames ?? Array.Empty<string>();
}
private static FrozenDictionary<string, string> BuildOptionToSection()
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var section in Sections)
{
foreach (var option in section.OptionNames)
dict[option] = section.SectionId;
}
return dict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,76 @@
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// <summary>
/// Defines which sections and (optionally) which fields are visible per EffectiveConfigContext.
/// Single place to add new contexts or change what shows on Hosts/DHCP/etc.
/// </summary>
public static class EffectiveConfigViews
{
/// <summary>
/// Ordered list of sections and optional field allow-lists for the given context.
/// </summary>
public static IReadOnlyList<EffectiveConfigSectionView> GetViewsForContext(EffectiveConfigContext context)
{
return context switch
{
EffectiveConfigContext.All => GetAllSections(),
EffectiveConfigContext.Hosts => GetHostsView(),
EffectiveConfigContext.Dhcp => GetDhcpView(),
_ => [],
};
}
private static IReadOnlyList<EffectiveConfigSectionView> GetAllSections()
{
return EffectiveConfigSections.Sections
.Select(s => new EffectiveConfigSectionView(s.SectionId, s.Title, null))
.ToList();
}
private static IReadOnlyList<EffectiveConfigSectionView> GetHostsView() =>
[
new(EffectiveConfigSections.SectionHosts, "Hosts", [
DnsmasqConfKeys.NoHosts,
DnsmasqConfKeys.AddnHosts,
DnsmasqConfKeys.Hostsdir,
DnsmasqConfKeys.ReadEthers,
]),
];
private static IReadOnlyList<EffectiveConfigSectionView> GetDhcpView() =>
[
new(EffectiveConfigSections.SectionDhcp, "DHCP", null),
new(EffectiveConfigSections.SectionTftpPxe, "TFTP / PXE", null),
];
/// <summary>
/// For each section in the view, returns the context-visible descriptors for that section.
/// Does not apply search; component applies search to each section's list and hides sections with no matches.
/// </summary>
public static IReadOnlyDictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>> GetDescriptorsBySection(
DnsmasqServiceStatus? status,
IReadOnlyList<EffectiveConfigSectionView> views)
{
if (status == null || views.Count == 0)
return new Dictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>>();
var allDescriptors = EffectiveConfigFieldBuilder.BuildFieldDescriptors(status);
var result = new Dictionary<string, List<EffectiveConfigFieldDescriptor>>(StringComparer.OrdinalIgnoreCase);
foreach (var view in views)
{
var list = allDescriptors
.Where(d => string.Equals(d.SectionId, view.SectionId, StringComparison.OrdinalIgnoreCase))
.Where(d => view.AllowedOptionNames == null
|| view.AllowedOptionNames.Contains(d.OptionName, StringComparer.OrdinalIgnoreCase))
.ToList();
if (list.Count > 0)
result[view.SectionId] = list;
}
return result.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<EffectiveConfigFieldDescriptor>)kv.Value, StringComparer.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,29 @@
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
/// <summary>
/// Service that checks GitHub for a newer release. Runs on a configurable interval (hosted service)
/// and can be triggered manually. UI subscribes to <see cref="ResultChanged"/> to refresh when state updates.
/// </summary>
public interface IUpdateCheckService
{
/// <summary>Raised when a check completes (background or manual). Subscribe and call StateHasChanged from the UI.</summary>
event EventHandler? ResultChanged;
/// <summary>True if the latest GitHub release is newer than the current app version.</summary>
bool NewerVersionAvailable { get; }
/// <summary>Tag of the newer release (e.g. v0.0.5), or null if none.</summary>
string? NewerVersionTag { get; }
/// <summary>URL of the newer release page, or null if none.</summary>
string? NewerVersionUrl { get; }
/// <summary>When the last check completed (success or failure), or null if never run.</summary>
DateTime? LastCheckTime { get; }
/// <summary>True while a check is in progress.</summary>
bool CheckInProgress { get; }
/// <summary>Run a check now (e.g. user clicked "check for updates").</summary>
Task CheckNowAsync();
}
@@ -265,6 +265,9 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var dhcpMacValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpMac);
var dhcpNameMatchValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpNameMatch);
var dhcpIgnoreNamesValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpIgnoreNames);
var dhcpHostsfilePaths = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpHostsfile);
var dhcpOptsfilePaths = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpOptsfile);
var dhcpHostsdirPaths = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpHostsdir);
var expandHosts = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ExpandHosts);
var bogusPriv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.BogusPriv);
@@ -299,6 +302,9 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var selfmx = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Selfmx);
var enableRa = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.EnableRa);
var logDhcp = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogDhcp);
var keepInForeground = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.KeepInForeground);
var noDaemon = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoDaemon);
var proxyDnssec = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ProxyDnssec);
var dhcpLeaseFilePath = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths, pathToLines);
@@ -360,6 +366,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var dhcpScriptPath = string.IsNullOrWhiteSpace(dhcpScriptVal) ? null : DnsmasqConfIncludeParser.ResolvePath(dhcpScriptVal?.Trim(), dhcpScriptDir);
var (mxTargetVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MxTarget);
var mxTarget = string.IsNullOrWhiteSpace(mxTargetVal) ? null : mxTargetVal.Trim();
var (conntrackVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Conntrack);
var conntrack = string.IsNullOrWhiteSpace(conntrackVal) ? null : conntrackVal.Trim();
return new EffectiveDnsmasqConfig(
noHosts, addnHosts, hostsdirPath,
@@ -368,13 +376,15 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
rebindDomainOk, bogusNxdomain, ignoreAddress, alias, filterRr, cacheRr, authServer, noDhcpInterface, noDhcpv4Interface, noDhcpv6Interface,
domainValues, cnameValues, mxHostValues, srvValues, ptrRecordValues, txtRecordValues, naptrRecordValues, hostRecordValues, dynamicHostValues, interfaceNameValues,
dhcpOptionForceLines, ipsetValues, nftsetValues, dhcpMacValues, dhcpNameMatchValues, dhcpIgnoreNamesValues,
dhcpHostsfilePaths, dhcpOptsfilePaths, dhcpHostsdirPaths,
expandHosts, bogusPriv, strictOrder, allServers, noResolv, domainNeeded, noPoll, bindInterfaces, bindDynamic, noNegcache,
dnsLoopDetect, stopDnsRebind, rebindLocalhostOk, clearOnReload, filterwin2k, filterA, filterAaaa, localiseQueries, logDebug, dhcpAuthoritative, leasefileRo,
enableTftp, tftpSecure, tftpNoFail, tftpNoBlocksize, dnssec, dnssecCheckUnsigned, readEthers, dhcpRapidCommit, localmx, selfmx, enableRa, logDhcp,
keepInForeground, noDaemon, proxyDnssec,
dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, logQueries,
authTtl, ednsPacketMax, queryPort, portLimit, minPort, maxPort, logAsync, localService, dhcpLeaseMax,
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl, tftpRootPath, pxePrompt, enableDbus, enableUbus, fastDnsRetry,
dhcpScriptPath, mxTarget
dhcpScriptPath, mxTarget, conntrack
);
}
@@ -428,6 +438,9 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var dhcpMacWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpMac, managedFilePath);
var dhcpNameMatchWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpNameMatch, managedFilePath);
var dhcpIgnoreNamesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpIgnoreNames, managedFilePath);
var dhcpHostsfileWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHostsfile, managedFilePath);
var dhcpOptsfileWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOptsfile, managedFilePath);
var dhcpHostsdirWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHostsdir, managedFilePath);
var (_, expandHostsSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ExpandHosts, managedFilePath);
var (_, bogusPrivSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.BogusPriv, managedFilePath);
@@ -462,6 +475,9 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var (_, selfmxSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Selfmx, managedFilePath);
var (_, enableRaSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.EnableRa, managedFilePath);
var (_, logDhcpSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogDhcp, managedFilePath);
var (_, keepInForegroundSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.KeepInForeground, managedFilePath);
var (_, noDaemonSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoDaemon, managedFilePath);
var (_, proxyDnssecSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ProxyDnssec, managedFilePath);
var (_, dhcpLeaseFilePathSource) = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFilesWithSource(paths, pathToLines, managedFilePath);
var (_, cacheSizeSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.CacheSize, managedFilePath);
@@ -493,6 +509,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var (_, fastDnsRetrySource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.FastDnsRetry, managedFilePath);
var (_, dhcpScriptPathSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpScript, managedFilePath);
var (_, mxTargetSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MxTarget, managedFilePath);
var (_, conntrackSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Conntrack, managedFilePath);
return new EffectiveConfigSources(
noHostsSource, addnHostsWithSource.Select(t => new PathWithSource(t.Path, t.Source)).ToList(), hostsdirPathSource,
@@ -541,6 +558,9 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
dhcpMacWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpNameMatchWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpIgnoreNamesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpHostsfileWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpOptsfileWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
dhcpHostsdirWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
expandHostsSource,
bogusPrivSource,
strictOrderSource,
@@ -574,6 +594,9 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
selfmxSource,
enableRaSource,
logDhcpSource,
keepInForegroundSource,
noDaemonSource,
proxyDnssecSource,
dhcpLeaseFilePathSource,
cacheSizeSource,
portSource,
@@ -603,7 +626,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
enableUbusSource,
fastDnsRetrySource,
dhcpScriptPathSource,
mxTargetSource
mxTargetSource,
conntrackSource
);
}
@@ -656,16 +680,18 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
HostRecordValues: Array.Empty<string>(), DynamicHostValues: Array.Empty<string>(), InterfaceNameValues: Array.Empty<string>(),
DhcpOptionForceLines: Array.Empty<string>(), IpsetValues: Array.Empty<string>(), NftsetValues: Array.Empty<string>(),
DhcpMacValues: Array.Empty<string>(), DhcpNameMatchValues: Array.Empty<string>(), DhcpIgnoreNamesValues: Array.Empty<string>(),
DhcpHostsfilePaths: Array.Empty<string>(), DhcpOptsfilePaths: Array.Empty<string>(), DhcpHostsdirPaths: Array.Empty<string>(),
ExpandHosts: false, BogusPriv: false, StrictOrder: false, AllServers: false, NoResolv: false, DomainNeeded: false, NoPoll: false,
BindInterfaces: false, BindDynamic: false, NoNegcache: false, DnsLoopDetect: false, StopDnsRebind: false, RebindLocalhostOk: false, ClearOnReload: false,
Filterwin2k: false, FilterA: false, FilterAaaa: false, LocaliseQueries: false, LogDebug: false, DhcpAuthoritative: false, LeasefileRo: false,
EnableTftp: false, TftpSecure: false, TftpNoFail: false, TftpNoBlocksize: false, Dnssec: false, DnssecCheckUnsigned: false,
ReadEthers: false, DhcpRapidCommit: false, Localmx: false, Selfmx: false, EnableRa: false, LogDhcp: false,
KeepInForeground: false, NoDaemon: false, ProxyDnssec: false,
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
LogFacility: null, LogQueries: null, AuthTtl: null, EdnsPacketMax: null, QueryPort: null, PortLimit: null, MinPort: null, MaxPort: null, LogAsync: null, LocalService: null,
DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null,
TftpRootPath: null, PxePrompt: null, EnableDbus: null, EnableUbus: null, FastDnsRetry: null,
DhcpScriptPath: null, MxTarget: null
DhcpScriptPath: null, MxTarget: null, Conntrack: null
);
private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() =>
@@ -689,16 +715,18 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
HostRecordValues: Array.Empty<ValueWithSource>(), DynamicHostValues: Array.Empty<ValueWithSource>(), InterfaceNameValues: Array.Empty<ValueWithSource>(),
DhcpOptionForceLines: Array.Empty<ValueWithSource>(), IpsetValues: Array.Empty<ValueWithSource>(), NftsetValues: Array.Empty<ValueWithSource>(),
DhcpMacValues: Array.Empty<ValueWithSource>(), DhcpNameMatchValues: Array.Empty<ValueWithSource>(), DhcpIgnoreNamesValues: Array.Empty<ValueWithSource>(),
DhcpHostsfilePaths: Array.Empty<ValueWithSource>(), DhcpOptsfilePaths: Array.Empty<ValueWithSource>(), DhcpHostsdirPaths: Array.Empty<ValueWithSource>(),
ExpandHosts: null, BogusPriv: null, StrictOrder: null, AllServers: null, NoResolv: null, DomainNeeded: null, NoPoll: null,
BindInterfaces: null, BindDynamic: null, NoNegcache: null, DnsLoopDetect: null, StopDnsRebind: null, RebindLocalhostOk: null, ClearOnReload: null,
Filterwin2k: null, FilterA: null, FilterAaaa: null, LocaliseQueries: null, LogDebug: null, DhcpAuthoritative: null, LeasefileRo: null,
EnableTftp: null, TftpSecure: null, TftpNoFail: null, TftpNoBlocksize: null, Dnssec: null, DnssecCheckUnsigned: null,
ReadEthers: null, DhcpRapidCommit: null, Localmx: null, Selfmx: null, EnableRa: null, LogDhcp: null,
KeepInForeground: null, NoDaemon: null, ProxyDnssec: null,
DhcpLeaseFilePath: null, CacheSize: null, Port: null, LocalTtl: null, PidFilePath: null, User: null, Group: null,
LogFacility: null, LogQueries: null, AuthTtl: null, EdnsPacketMax: null, QueryPort: null, PortLimit: null, MinPort: null, MaxPort: null, LogAsync: null, LocalService: null,
DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: null,
TftpRootPath: null, PxePrompt: null, EnableDbus: null, EnableUbus: null, FastDnsRetry: null,
DhcpScriptPath: null, MxTarget: null
DhcpScriptPath: null, MxTarget: null, Conntrack: null
);
private static int? TryParseInt(string? value)
@@ -55,6 +55,9 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.EnableRa);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDhcp);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.KeepInForeground);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDaemon);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.ProxyDnssec);
}
private void RegisterFlag(string sectionId, string optionName)
@@ -0,0 +1,46 @@
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services;
/// <summary>
/// Runs an update check shortly after startup, then periodically at the configured interval.
/// Disabled when <see cref="UpdateCheckOptions.IntervalMinutes"/> is 0.
/// </summary>
public sealed class UpdateCheckHostedService : IApplicationHostedService
{
private const int StartupDelaySeconds = 30;
private readonly IUpdateCheckService _updateCheckService;
private readonly IOptions<UpdateCheckOptions> _options;
public UpdateCheckHostedService(IUpdateCheckService updateCheckService, IOptions<UpdateCheckOptions> options)
{
_updateCheckService = updateCheckService;
_options = options;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_ = RunLoopAsync(cancellationToken);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
var intervalMinutes = _options.Value.IntervalMinutes;
if (intervalMinutes <= 0) return;
await Task.Delay(TimeSpan.FromSeconds(StartupDelaySeconds), cancellationToken);
await _updateCheckService.CheckNowAsync();
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), cancellationToken);
await _updateCheckService.CheckNowAsync();
}
}
}
@@ -0,0 +1,120 @@
using System.Reflection;
using System.Text.Json;
using DnsmasqWebUI.Extensions;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services;
public sealed class UpdateCheckService : IUpdateCheckService
{
private const string ReleasesBaseUrl = "https://github.com/alexhopeoconnor/dnsmasq-webui/releases";
private static readonly string CurrentVersion = typeof(UpdateCheckService).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "?";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IOptions<UpdateCheckOptions> _options;
private readonly object _lock = new();
private string? _newerVersionTag;
private string? _newerVersionUrl;
private DateTime? _lastCheckTime;
private bool _checkInProgress;
public UpdateCheckService(IHttpClientFactory httpClientFactory, IOptions<UpdateCheckOptions> options)
{
_httpClientFactory = httpClientFactory;
_options = options;
}
public event EventHandler? ResultChanged;
public bool NewerVersionAvailable => _newerVersionTag != null;
public string? NewerVersionTag => _newerVersionTag;
public string? NewerVersionUrl => _newerVersionUrl;
public DateTime? LastCheckTime => _lastCheckTime;
public bool CheckInProgress => _checkInProgress;
public async Task CheckNowAsync()
{
lock (_lock)
{
if (_checkInProgress) return;
_checkInProgress = true;
}
try
{
var client = _httpClientFactory.CreateClient(ServiceCollectionExtensions.GitHubClientName);
var response = await client.GetAsync("repos/alexhopeoconnor/dnsmasq-webui/releases/latest");
string? newTag = null;
string? newUrl = null;
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var tagName = root.TryGetProperty("tag_name", out var tagProp) ? tagProp.GetString() : null;
var htmlUrl = root.TryGetProperty("html_url", out var urlProp) ? urlProp.GetString() : null;
if (!string.IsNullOrEmpty(tagName) && !string.IsNullOrEmpty(htmlUrl))
{
var latest = tagName.TrimStart('v');
var current = BaseVersion(CurrentVersion);
if (!string.IsNullOrEmpty(current) && current != "?" && IsNewerVersion(latest, current))
{
newTag = tagName.StartsWith('v') ? tagName : "v" + tagName;
newUrl = htmlUrl;
}
}
}
lock (_lock)
{
_newerVersionTag = newTag;
_newerVersionUrl = newUrl;
_lastCheckTime = DateTime.UtcNow;
_checkInProgress = false;
}
ResultChanged?.Invoke(this, EventArgs.Empty);
}
catch
{
lock (_lock)
{
_lastCheckTime = DateTime.UtcNow;
_checkInProgress = false;
}
ResultChanged?.Invoke(this, EventArgs.Empty);
}
}
private static string BaseVersion(string v)
{
if (string.IsNullOrEmpty(v) || v == "?") return v;
var i = v.IndexOf('+');
return i >= 0 ? v[..i] : v;
}
private static bool IsNewerVersion(string latest, string current)
{
var l = ParseVersionParts(latest);
var c = ParseVersionParts(current);
for (var i = 0; i < Math.Max(l.Length, c.Length); i++)
{
var a = i < l.Length ? l[i] : 0;
var b = i < c.Length ? c[i] : 0;
if (a > b) return true;
if (a < b) return false;
}
return false;
}
private static int[] ParseVersionParts(string v)
{
return v.Split('.', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var n) ? n : 0)
.ToArray();
}
}
@@ -0,0 +1,13 @@
namespace DnsmasqWebUI.Models.Config;
/// <summary>
/// Options for the periodic update check (GitHub latest release). Bound from "UpdateCheck" config section.
/// </summary>
public class UpdateCheckOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "UpdateCheck";
/// <summary>Interval in minutes between checks. Default 1440 (24 hours). Set to 0 to disable periodic checks.</summary>
public int IntervalMinutes { get; set; } = 1440;
}
@@ -0,0 +1,21 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// Event args when a child requests to show or dismiss the config option help popover.
/// Parent (EffectiveConfigSection) shows ConfigOptionHelpModal at the anchor; when
/// <see cref="IsLabelMouseLeave"/> is true, parent schedules close if mouse does not enter the modal.
/// </summary>
public sealed class ConfigOptionHelpRequestEventArgs
{
/// <summary>Option-help file key (e.g. "server", "no-hosts").</summary>
public string HelpKey { get; init; } = "";
/// <summary>Display label for the modal title (e.g. "server / local").</summary>
public string OptionLabel { get; init; } = "";
/// <summary>Id of the label element that triggered the request; used to position the modal and match leave events.</summary>
public string AnchorId { get; init; } = "";
/// <summary>When true, the label lost mouse focus; parent should schedule close unless mouse enters the modal.</summary>
public bool IsLabelMouseLeave { get; init; }
}
@@ -0,0 +1,16 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// Where the effective config component is shown. Drives which sections and (optionally) which fields are visible.
/// </summary>
public enum EffectiveConfigContext
{
/// <summary>Full config: all sections, all fields (e.g. Dnsmasq page).</summary>
All,
/// <summary>Hosts page: only hosts-related section and fields.</summary>
Hosts,
/// <summary>DHCP page: DHCP and TFTP/PXE sections.</summary>
Dhcp,
}
@@ -0,0 +1,14 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// One section in a context view: section id, display title, and optional allow-list of option names.
/// When AllowedOptionNames is null, all fields in that section are shown; otherwise only listed options.
/// </summary>
/// <param name="SectionId">Matches EffectiveConfigSections.SectionXxx.</param>
/// <param name="Title">Display title (e.g. "Hosts", "Resolver / DNS").</param>
/// <param name="AllowedOptionNames">Null = all fields; otherwise only descriptors whose OptionName is in this list (case-insensitive).</param>
public record EffectiveConfigSectionView(
string SectionId,
string Title,
IReadOnlyList<string>? AllowedOptionNames
);
@@ -61,6 +61,9 @@ public record EffectiveConfigSources(
IReadOnlyList<ValueWithSource> DhcpMacValues,
IReadOnlyList<ValueWithSource> DhcpNameMatchValues,
IReadOnlyList<ValueWithSource> DhcpIgnoreNamesValues,
IReadOnlyList<ValueWithSource> DhcpHostsfilePaths,
IReadOnlyList<ValueWithSource> DhcpOptsfilePaths,
IReadOnlyList<ValueWithSource> DhcpHostsdirPaths,
// --- Flags (set if any occurrence; source = first file that set it, so we know if readonly) ---
ConfigValueSource? ExpandHosts,
@@ -96,6 +99,9 @@ public record EffectiveConfigSources(
ConfigValueSource? Selfmx,
ConfigValueSource? EnableRa,
ConfigValueSource? LogDhcp,
ConfigValueSource? KeepInForeground,
ConfigValueSource? NoDaemon,
ConfigValueSource? ProxyDnssec,
// --- Single-value (last occurrence wins; source = file that set the last value) ---
ConfigValueSource? DhcpLeaseFilePath,
@@ -127,5 +133,6 @@ public record EffectiveConfigSources(
ConfigValueSource? EnableUbus,
ConfigValueSource? FastDnsRetry,
ConfigValueSource? DhcpScriptPath,
ConfigValueSource? MxTarget
ConfigValueSource? MxTarget,
ConfigValueSource? Conntrack
);
@@ -65,6 +65,9 @@ public record EffectiveDnsmasqConfig(
IReadOnlyList<string> DhcpMacValues,
IReadOnlyList<string> DhcpNameMatchValues,
IReadOnlyList<string> DhcpIgnoreNamesValues,
IReadOnlyList<string> DhcpHostsfilePaths,
IReadOnlyList<string> DhcpOptsfilePaths,
IReadOnlyList<string> DhcpHostsdirPaths,
// --- Boolean flags (set if any file contains the option) ---
bool ExpandHosts,
@@ -100,6 +103,9 @@ public record EffectiveDnsmasqConfig(
bool Selfmx,
bool EnableRa,
bool LogDhcp,
bool KeepInForeground,
bool NoDaemon,
bool ProxyDnssec,
// --- Single-value options (last occurrence wins; null = not set, dnsmasq default) ---
string? DhcpLeaseFilePath,
@@ -131,7 +137,8 @@ public record EffectiveDnsmasqConfig(
string? EnableUbus,
string? FastDnsRetry,
string? DhcpScriptPath,
string? MxTarget
string? MxTarget,
string? Conntrack
)
{
/// <summary>
+3
View File
@@ -26,6 +26,8 @@ builder.Services.AddOptions<AppLogsOptions>()
.Bind(builder.Configuration.GetSection(AppLogsOptions.SectionName));
builder.Services.AddOptions<RuntimeOverridesOptions>()
.Bind(builder.Configuration.GetSection(RuntimeOverridesOptions.SectionName));
builder.Services.AddOptions<UpdateCheckOptions>()
.Bind(builder.Configuration.GetSection(UpdateCheckOptions.SectionName));
// ---- Dnsmasq options (required paths validated at startup) ----
builder.Services.AddOptions<DnsmasqOptions>()
@@ -36,6 +38,7 @@ builder.Services.AddSingleton<IValidateOptions<DnsmasqOptions>, DnsmasqOptionsVa
// ---- Application services ----
builder.Services.AddApplicationServices();
builder.Services.AddScoped<ISettingsModalService, SettingsModalService>();
builder.Services.AddSingleton<IUpdateCheckService, UpdateCheckService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddDnsmasqApiHttpClients();
+3
View File
@@ -2,6 +2,9 @@
"Application": {
"ApplicationTitle": "Local DNS"
},
"UpdateCheck": {
"IntervalMinutes": 1440
},
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -0,0 +1,26 @@
/**
* Positioning and scroll-close for config option help popover.
* Called from ConfigOptionHelpModal.razor via IJSRuntime.InvokeAsync.
*/
window.getConfigOptionHelpAnchorRect = function (anchorId) {
var el = document.getElementById(anchorId);
if (!el) return null;
var r = el.getBoundingClientRect();
return { top: r.top, left: r.left, bottom: r.bottom, width: r.width, height: r.height };
};
var _configOptionHelpScrollHandler = null;
window.addConfigOptionHelpScrollCloseListener = function (dotNetRef) {
if (_configOptionHelpScrollHandler) return;
_configOptionHelpScrollHandler = function () {
dotNetRef.invokeMethodAsync('OnScrollClose').catch(function () {});
};
window.addEventListener('scroll', _configOptionHelpScrollHandler, true);
};
window.removeConfigOptionHelpScrollCloseListener = function () {
if (!_configOptionHelpScrollHandler) return;
window.removeEventListener('scroll', _configOptionHelpScrollHandler, true);
_configOptionHelpScrollHandler = null;
};
@@ -0,0 +1,5 @@
<dt><b>-H, --addn-hosts=&lt;file&gt;</b></dt>
<dd>Additional hosts file. Read the specified file as well as /etc/hosts. If <b>--no-hosts</b> is given, read
only the specified file. This option may be repeated for more than one
additional hosts file. If a directory is given, then read all the files contained in that directory
in alphabetical order.</dd>
@@ -0,0 +1,27 @@
<dt><b>-A, --address=/&lt;domain&gt;[/&lt;domain&gt;...]/[&lt;ipaddr&gt;]</b></dt>
<dd>Specify an IP address to return for any host in the given domains.
A (or AAAA) queries in the domains are never forwarded and always replied to
with the specified IP address which may be IPv4 or IPv6. To give
multiple addresses or both IPv4 and IPv6 addresses for a domain, use repeated <b>--address</b> flags.
Note that /etc/hosts and DHCP leases override this for individual
names. A common use of this is to redirect the entire doubleclick.net
domain to some friendly local web server to avoid banner ads. The
domain specification works in the same way as for <b>--server</b>, with
the additional facility that <b>/#/</b> matches any domain. Thus
<b>--address=/#/1.2.3.4</b> will always return <b>1.2.3.4</b> for any
query not answered from <b>/etc/hosts</b> or DHCP and not sent to an
upstream nameserver by a more specific <b>--server</b> directive. As for
<b>--server</b>, one or more domains with no address returns a
no-such-domain answer, so <b>--address=/example.com/</b> is equivalent to
<b>--server=/example.com/</b> and returns NXDOMAIN for example.com and
all its subdomains. An address specified as '#' translates to the NULL
address of 0.0.0.0 and its IPv6 equivalent of :: so
<b>--address=/example.com/#</b> will return NULL addresses for example.com and
its subdomains. This is partly syntactic sugar for <b>--address=/example.com/0.0.0.0</b>
and <b>--address=/example.com/::</b> but is also more efficient than including both
as separate configuration lines. Note that NULL addresses normally work in the same way as localhost, so beware that clients looking up these names are likely to end up talking to themselves.
<p>
Note that the behaviour for queries which don't match the specified address literal changed in version 2.86.
Previous versions, configured with (eg) --address=/example.com/1.2.3.4 and then queried for a RR type other than
A would return a NoData answer. From 2.86, the query is sent upstream. To restore the pre-2.86 behaviour,
use the configuration --address=/example.com/1.2.3.4 --local=/example.com/</dd>
@@ -0,0 +1,13 @@
<dt><b>-V, --alias=[&lt;old-ip&gt;]|[&lt;start-ip&gt;-&lt;end-ip&gt;],&lt;new-ip&gt;[,&lt;mask&gt;]</b></dt>
<dd>Modify IPv4 addresses returned from upstream nameservers; old-ip is
replaced by new-ip. If the optional mask is given then any address
which matches the masked old-ip will be re-written. So, for instance
<b>--alias=1.2.3.0,6.7.8.0,255.255.255.0 </b>
will map 1.2.3.56 to 6.7.8.56 and 1.2.3.67 to 6.7.8.67. This is what
Cisco PIX routers call "DNS doctoring". If the old IP is given as
range, then only addresses in the range, rather than a whole subnet,
are re-written. So
<b>--alias=192.168.0.10-192.168.0.40,10.0.0.0,255.255.255.0</b>
maps 192.168.0.10-&gt;192.168.0.40 to 10.0.0.10-&gt;10.0.0.40</dd>
@@ -0,0 +1,5 @@
<dt><b>--all-servers</b></dt>
<dd>By default, when dnsmasq has more than one upstream server available,
it will send queries to just one server. Setting this flag forces
dnsmasq to send all queries to all available servers. The reply from
the server which answers first will be returned to the original requester.</dd>
@@ -0,0 +1,17 @@
<dt><b>--auth-server=&lt;domain&gt;,[&lt;interface&gt;|&lt;ip-address&gt;...]</b></dt>
<dd>Enable DNS authoritative mode for queries arriving at an interface or address. Note that the interface or address
need not be mentioned in
<b>--interface</b>
or
<b>--listen-address</b>
configuration, indeed
<b>--auth-server</b>
will override these and provide a different DNS service on the
specified interface. The &lt;domain&gt; is the "glue record". It should
resolve in the global DNS to an A and/or AAAA record which points to
the address dnsmasq is listening on. When an interface is specified,
it may be qualified with "/4" or "/6" to specify only the IPv4 or IPv6
addresses associated with the interface. Since any defined authoritative zones are also available as part of the normal recusive DNS service supplied by dnsmasq, it can make sense to have an --auth-server declaration with no interfaces or address, but simply specifying the primary external nameserver.</dd>
@@ -0,0 +1,2 @@
<dt><b>--auth-ttl=&lt;time&gt;</b></dt>
<dd>Set the TTL value returned in answers from the authoritative server.</dd>
@@ -0,0 +1,11 @@
<dt><b>--bind-dynamic</b></dt>
<dd>Enable a network mode which is a hybrid between
<b>--bind-interfaces</b>
and the default. Dnsmasq binds the address of individual interfaces,
allowing multiple dnsmasq instances, but if new interfaces or
addresses appear, it automatically listens on those (subject to any
access-control configuration). This makes dynamically created
interfaces work in the same way as the default. Implementing this
option requires non-standard networking APIs and it is only available
under Linux. On other platforms it falls-back to <b>--bind-interfaces</b> mode.</dd>
@@ -0,0 +1,10 @@
<dt><b>-z, --bind-interfaces</b></dt>
<dd>On systems which support it, dnsmasq binds the wildcard address,
even when it is listening on only some interfaces. It then discards
requests that it shouldn't reply to. This has the advantage of
working even when interfaces come and go and change address. This
option forces dnsmasq to really bind only the interfaces it is
listening on. About the only time when this is useful is when
running another nameserver (or another instance of dnsmasq) on the
same machine. Setting this option also enables multiple instances of
dnsmasq which provide DHCP service to run in the same machine.</dd>
@@ -0,0 +1,8 @@
<dt><b>-B, --bogus-nxdomain=&lt;ipaddr&gt;[/prefix]</b></dt>
<dd>Transform replies which contain the specified address or subnet into "No such
domain" replies. IPv4 and IPv6 are supported. This is intended to counteract a devious move made by
Verisign in September 2003 when they started returning the address of
an advertising web page in response to queries for unregistered names,
instead of the correct NXDOMAIN response. This option tells dnsmasq to
fake the correct response when it sees this behaviour. As at Sept 2003
the IP address being returned by Verisign is 64.94.110.11</dd>
@@ -0,0 +1,9 @@
<dt><b>-b, --bogus-priv</b></dt>
<dd>Bogus private reverse lookups. All reverse lookups for private IP ranges (ie 192.168.x.x, etc)
which are not found in /etc/hosts or the DHCP leases file are answered
with "no such domain" rather than being forwarded upstream. The
set of prefixes affected is the list given in RFC6303, for IPv4 and IPv6.
Enabling this also subtly alters DNSSEC validation for reverse lookups in the
private ranges such that a non-secure DS record is accepted as proof that
the range is not signed. This works around behaviour by the public DNS services
which seem not to return validated proof-of-non-existence for DS records in these domains.</dd>
@@ -0,0 +1,6 @@
<dt><b>--cache-rr=&lt;rrtype&gt;[,&lt;rrtype&gt;...]</b></dt>
<dd>By default, dnsmasq caches A, AAAA, CNAME and SRV DNS record types.
This option adds other record types to the cache. The RR-type can be given
as a name such as TXT or MX or a decimal number. A single --cache-rr option
can take a comma-separated list of RR-types and more than one --cache-rr option
is allowed. Use --cache-rr=ANY to enable caching for all RR-types.</dd>
@@ -0,0 +1,2 @@
<dt><b>-c, --cache-size=&lt;cachesize&gt;</b></dt>
<dd>Set the size of dnsmasq's cache. The default is 150 names. Setting the cache size to zero disables caching. Note: huge cache size impacts performance.</dd>
@@ -0,0 +1,5 @@
<dt><b>--clear-on-reload</b></dt>
<dd>Whenever /etc/resolv.conf is re-read or the upstream servers are set
via DBus, clear the DNS cache.
This is useful when new nameservers may have different
data than that held in cache.</dd>
@@ -0,0 +1,12 @@
<dt><b>--cname=&lt;cname&gt;,[&lt;cname&gt;,]&lt;target&gt;[,&lt;TTL&gt;]</b></dt>
<dd>Return a CNAME record which indicates that &lt;cname&gt; is really
&lt;target&gt;. There is a significant limitation on the target; it must be a
DNS record which is known to dnsmasq and NOT a DNS record which comes from
an upstream server. The cname must be unique, but it
is permissible to have more than one cname pointing to the same target. Indeed
it's possible to declare multiple cnames to a target in a single line, like so:
<b>--cname=cname1,cname2,target</b>
<p>
If the time-to-live is given, it overrides the default, which is zero
or the value of <b>--local-ttl</b>. The value is a positive integer and gives
the time-to-live in seconds.</dd>
@@ -0,0 +1,9 @@
<dt><b>--conntrack</b></dt>
<dd>Read the Linux connection track mark associated with incoming DNS
queries and set the same mark value on upstream traffic used to answer
those queries. This allows traffic generated by dnsmasq to be
associated with the queries which cause it, useful for bandwidth
accounting and firewalling. Dnsmasq must have conntrack support
compiled in and the kernel must have conntrack support
included and configured. This option cannot be combined with
<b>--query-port.</b></dd>
@@ -0,0 +1,10 @@
<dt><b>-K, --dhcp-authoritative</b></dt>
<dd>Should be set when dnsmasq is definitely the only DHCP server on a network.
For DHCPv4, it changes the behaviour from strict RFC compliance so that DHCP requests on
unknown leases from unknown hosts are not ignored. This allows new hosts
to get a lease without a tedious timeout under all circumstances. It also
allows dnsmasq to rebuild its lease database without each client needing to
reacquire a lease, if the database is lost. For DHCPv6 it controls the same
behaviour as DHCPv4 with missing leases (except for the RFC uncompliance - the
DHCPv6 RFCs allow this behaviour if configured). It also sets the
priority in replies to 255 (the maximum) instead of 0 (the minimum).</dd>
@@ -0,0 +1,14 @@
<dt><b>-M, --dhcp-boot=[tag:&lt;tag&gt;,]&lt;filename&gt;,[&lt;servername&gt;[,&lt;server address&gt;|&lt;tftp_servername&gt;]]</b></dt>
<dd>(IPv4 only) Set BOOTP options to be returned by the DHCP server. Server name and
address are optional: if not provided, the name is left empty, and the
address set to the address of the machine running dnsmasq. If dnsmasq
is providing a TFTP service (see
<b>--enable-tftp</b>
) then only the filename is required here to enable network booting.
If the optional tag(s) are given,
they must match for this configuration to be sent.
Instead of an IP address, the TFTP server address can be given as a domain
name which is looked up in /etc/hosts. This name can be associated in
/etc/hosts with multiple IP addresses, which are used round-robin.
This facility can be used to load balance the tftp load among a set of servers.</dd>
@@ -0,0 +1,144 @@
<dt><b>-G, --dhcp-host=[&lt;hwaddr&gt;][,id:&lt;client_id&gt;|*][,set:&lt;tag&gt;][,tag:&lt;tag&gt;][,&lt;ipaddr&gt;][,&lt;hostname&gt;][,&lt;lease_time&gt;][,ignore]</b></dt>
<dd>Specify per host parameters for the DHCP server. This allows a machine
with a particular hardware address to be always allocated the same
hostname, IP address and lease time. A hostname specified like this
overrides any supplied by the DHCP client on the machine. It is also
allowable to omit the hardware address and include the hostname, in
which case the IP address and lease times will apply to any machine
claiming that name. For example
<b>--dhcp-host=00:20:e0:3b:13:af,wap,infinite </b>
tells dnsmasq to give
the machine with hardware address 00:20:e0:3b:13:af the name wap, and
an infinite DHCP lease.
<b>--dhcp-host=lap,192.168.0.199 </b>
tells
dnsmasq to always allocate the machine lap the IP address
192.168.0.199.
<p>
Addresses allocated like this are not constrained to be
in the range given by the <b>--dhcp-range</b> option, but they must be in
the same subnet as some valid dhcp-range. For
subnets which don't need a pool of dynamically allocated addresses,
use the "static" keyword in the <b>--dhcp-range</b> declaration.
<p>
It is allowed to use client identifiers (called client
DUID in IPv6-land) rather than
hardware addresses to identify hosts by prefixing with 'id:'. Thus:
<b>--dhcp-host=id:01:02:03:04,..... </b>
refers to the host with client identifier 01:02:03:04. It is also
allowed to specify the client ID as text, like this:
<b>--dhcp-host=id:clientidastext,..... </b>
<p>
A single
<b>--dhcp-host</b>
may contain an IPv4 address or one or more IPv6 addresses, or both. IPv6 addresses must be bracketed by square brackets thus:
<b>--dhcp-host=laptop,[1234::56]</b>
IPv6 addresses may contain only the host-identifier part:
<b>--dhcp-host=laptop,[::56]</b>
in which case they act as wildcards in constructed DHCP ranges, with
the appropriate network part inserted. For IPv6, an address may include a prefix length:
<b>--dhcp-host=laptop,[1234:50/126]</b>
which (in this case) specifies four addresses, 1234::50 to 1234::53. This (an the ability
to specify multiple addresses) is useful
when a host presents either a consistent name or hardware-ID, but varying DUIDs, since it allows
dnsmasq to honour the static address allocation but assign a different address for each DUID. This
typically occurs when chain netbooting, as each stage of the chain gets in turn allocates an address.
<p>
Note that in IPv6 DHCP, the hardware address may not be
available, though it normally is for direct-connected clients, or
clients using DHCP relays which support RFC 6939.
<p>
<p>
For DHCPv4, the special option <b>id:*</b> means "ignore any client-id
and use MAC addresses only." This is useful when a client presents a client-id sometimes
but not others.
<p>
If a name appears in /etc/hosts, the associated address can be
allocated to a DHCP lease, but only if a
<b>--dhcp-host</b>
option specifying the name also exists. Only one hostname can be
given in a
<b>--dhcp-host</b>
option, but aliases are possible by using CNAMEs. (See
<b>--cname</b>
). Note that /etc/hosts is NOT used when the DNS server side of dnsmasq
is disabled by setting the DNS server port to zero.
<p>
More than one
<b>--dhcp-host</b>
can be associated (by name, hardware address or UID) with a host. Which one is used
(and therefore which address is allocated by DHCP and appears in the DNS) depends
on the subnet on which the host last obtained a DHCP lease:
the
<b>--dhcp-host</b>
with an address within the subnet is used. If more than one address is within the subnet,
the result is undefined. A corollary to this is that the name associated with a host using
<b>--dhcp-host</b>
does not appear in the DNS until the host obtains a DHCP lease.
<p>
<p>
The special keyword <b>ignore</b> tells dnsmasq never to offer a DHCP lease
to a machine. The machine
can be specified by hardware address, client ID or hostname. For
example: <b>--dhcp-host=00:20:e0:3b:13:af,ignore</b>.
This can be useful when there is another DHCP server on the network which should
be used by some machines.
<p>
The <b>set:&lt;tag&gt;</b> construct sets the tag
whenever this <b>--dhcp-host</b> directive is in use. This can be used to
selectively send DHCP options just for this host. More than one tag
can be set in a <b>--dhcp-host</b> directive (but not in other places where
<b>set:&lt;tag&gt;</b> is allowed). When a host matches any
<b>--dhcp-host</b> directive (or one implied by /etc/ethers) then the special
tag <b>known</b> is set. This allows dnsmasq to be configured to
ignore requests from unknown machines using
<b>--dhcp-ignore=tag:!known</b>.
If the host matches only a <b>--dhcp-host</b> directive which cannot
be used because it specifies an address on a different subnet, the tag <b>known-othernet</b> is set.
<p>
The <b>tag:&lt;tag&gt;</b> construct filters which dhcp-host directives are used. More than
one tag can be provided. In this case, the request must match all tags. Tagged
directives are used in preference to untagged ones. Note that one of &lt;hwaddr&gt;,
&lt;client_id&gt; or &lt;hostname&gt; still must be specified (can be a wildcard).
<p>
Ethernet addresses (but not client-ids) may have
wildcard bytes, so for example
<b>--dhcp-host=00:20:e0:3b:13:*,ignore </b>
will cause dnsmasq to ignore the given range of hardware addresses. Note that
the "*" will need to be escaped or quoted on a command line, but not
in the configuration file.
<p>
Hardware addresses normally match any
network (ARP) type, but it is possible to restrict them to a single
ARP type by preceding them with the ARP-type (in HEX) and "-". so
<b>--dhcp-host=06-00:20:e0:3b:13:af,1.2.3.4 </b>
will only match a
Token-Ring hardware address, since the ARP-address type for token ring
is 6.
<p>
As a special case, in DHCPv4, it is possible to include more than one
hardware address. eg:
<b>--dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2</b>.
This allows an IP address to be associated with
multiple hardware addresses, and gives dnsmasq permission to abandon a
DHCP lease to one of the hardware addresses when another one asks for
a lease. Beware that this is a dangerous thing to do: it will only
work reliably if only one of the hardware addresses is active at any
time and there is no way for dnsmasq to enforce this. It is, for instance,
useful to allocate a stable IP address to a laptop which
has both wired and wireless interfaces.</dd>
@@ -0,0 +1,8 @@
<dt><b>--dhcp-hostsdir=&lt;path&gt;</b></dt>
<dd>This is equivalent to <b>--dhcp-hostsfile</b>, except for the following. The path MUST be a
directory, and not an individual file. Changed or new files within
the directory are read automatically, without the need to send SIGHUP.
If a file is deleted or changed after it has been read by dnsmasq, then the
host record it contained will remain until dnsmasq receives a SIGHUP, or
is restarted; ie host records are only added dynamically. The order in which the
files in a directory are read is not defined.</dd>
@@ -0,0 +1,7 @@
<dt><b>--dhcp-hostsfile=&lt;path&gt;</b></dt>
<dd>Read DHCP host information from the specified file. If a directory
is given, then read all the files contained in that directory in alphabetical order. The file contains
information about one host per line. The format of a line is the same
as text to the right of '=' in <b>--dhcp-host</b>. The advantage of storing DHCP host information
in this file is that it can be changed without re-starting dnsmasq:
the file will be re-read when dnsmasq receives SIGHUP.</dd>
@@ -0,0 +1,7 @@
<dt><b>--dhcp-ignore-names[=tag:&lt;tag&gt;[,tag:&lt;tag&gt;]]</b></dt>
<dd>When all the given tags appear in the tag set, ignore any hostname
provided by the host. Note that, unlike <b>--dhcp-ignore</b>, it is permissible
to supply no tags, in which case DHCP-client supplied hostnames
are always ignored, and DHCP hosts are added to the DNS using only
<b>--dhcp-host</b> configuration in dnsmasq and the contents of /etc/hosts and
/etc/ethers.</dd>
@@ -0,0 +1,3 @@
<dt><b>-J, --dhcp-ignore=tag:&lt;tag&gt;[,tag:&lt;tag&gt;]</b></dt>
<dd>When all the given tags appear in the tag set, ignore the host and do
not allocate it a DHCP lease.</dd>
@@ -0,0 +1,5 @@
<dt><b>-X, --dhcp-lease-max=&lt;number&gt;</b></dt>
<dd>Limits dnsmasq to the specified maximum number of DHCP leases. The
default is 1000. This limit is to prevent DoS attacks from hosts which
create thousands of leases and use lots of memory in the dnsmasq
process.</dd>
@@ -0,0 +1,2 @@
<dt><b>-l, --dhcp-leasefile=&lt;path&gt;</b></dt>
<dd>Use the specified file to store DHCP lease information.</dd>
@@ -0,0 +1,6 @@
<dt><b>-4, --dhcp-mac=set:&lt;tag&gt;,&lt;MAC address&gt;</b></dt>
<dd>Map from a MAC address to a tag. The MAC address may include
wildcards. For example
<b>--dhcp-mac=set:3com,01:34:23:*:*:*</b>
will set the tag "3com" for any host whose MAC address matches the pattern.</dd>
@@ -0,0 +1,22 @@
<dt><b>--dhcp-match=set:&lt;tag&gt;,&lt;option number&gt;|option:&lt;option name&gt;|vi-encap:&lt;enterprise&gt;[,&lt;value&gt;]</b></dt>
<dd>Without <b>&lt;value&gt;</b>, set <b>&lt;tag&gt;</b> if the client sends a DHCP
<b>option</b> of the given number or name. With <b>&lt;value&gt;</b>, set <b>&lt;tag&gt;</b> only if
the client sends the <b>option</b> matching or containing <b>&lt;value&gt;</b>.
<p>
The value may be of the form
"01:ff:*:02" in which case the value must match (apart from wildcards)
but the option sent may have unmatched data past the end of the
value. The value may also be of the same form as in
<b>--dhcp-option</b>
in which case the option sent is treated as an array, and one element
must match.
<b>--dhcp-match=set:efi-ia32,option:client-arch,6</b>
sets the tag <b>efi-ia32</b> if the number <b>6</b> appears in the list of
architectures sent by the client in option 93. (See RFC 4578 for
details.) If the value is a string, substring matching is used.
<p>
The special form with vi-encap:&lt;enterprise number&gt; matches against
vendor-identifying vendor classes for the specified enterprise. Please
see RFC 3925 for more details of these rare and interesting beasts.</dd>
@@ -0,0 +1,2 @@
<dt><b>--dhcp-name-match=set:&lt;tag&gt;,&lt;name&gt;[*]</b></dt>
<dd>Set a <b>&lt;tag&gt;</b> if the given <b>&lt;name&gt;</b> is supplied by a DHCP client. There may be a single trailing wildcard *, with a glob meaning. Combined with <b>dhcp-ignore</b> or <b>dhcp-ignore-names</b> this gives the ability to ignore certain clients by name, or disallow certain hostnames from being claimed by a client.</dd>
@@ -0,0 +1,7 @@
<dt><b>--dhcp-option-force=[tag:&lt;tag&gt;,[tag:&lt;tag&gt;,]][encap:&lt;opt&gt;,][vi-encap:&lt;enterprise&gt;,][vendor:[&lt;vendor-class&gt;],]&lt;opt&gt;,[&lt;value&gt;[,&lt;value&gt;]]</b></dt>
<dd>This works in exactly the same way as
<b>--dhcp-option</b>
except that the option will always be sent, even if the client does
not ask for it in the parameter request list. This is sometimes
needed, for example when sending options to PXELinux.</dd>
@@ -0,0 +1,99 @@
<dt><b>-O, --dhcp-option=[tag:&lt;tag&gt;,[tag:&lt;tag&gt;,]][encap:&lt;opt&gt;,][vi-encap:&lt;enterprise&gt;,][vendor:[&lt;vendor-class&gt;],][&lt;opt&gt;|option:&lt;opt-name&gt;|option6:&lt;opt&gt;|option6:&lt;opt-name&gt;],[&lt;value&gt;[,&lt;value&gt;]]</b></dt>
<dd>Send various extra options to DHCP clients. By default,
dnsmasq sends some standard options to DHCP clients, the netmask and
broadcast address are set to the same as the host running dnsmasq, and
the DNS server and default route are set to the address of the machine
running dnsmasq. (Equivalent rules apply for IPv6.) If the domain name option has been set, that is sent.
This configuration allows these defaults to be overridden,
or other options specified. The option to be sent may be given as a
decimal number or as <b>option:&lt;option-name&gt;</b>.
<p>
Option numbers are
specified in RFC2132 and subsequent RFCs. The set of option-names
known by dnsmasq can be discovered by running <b>dnsmasq --help dhcp</b>.
For example, to set the default route option to 192.168.4.4, use
<b>--dhcp-option=3,192.168.4.4</b> or
<b>--dhcp-option=option:router,192.168.4.4</b>
and to set the time-server address to 192.168.0.4, use
<b>--dhcp-option=42,192.168.0.4</b> or
<b>--dhcp-option=option:ntp-server,192.168.0.4</b>.
The special address 0.0.0.0 means "the address of the system running dnsmasq".
<p>
An option without data is valid, and includes just the option without data.
(There is only one option with a zero length data field currently defined for DHCPv4, 80:rapid commit, so this feature is not very useful in practice). Options for which dnsmasq normally
provides default values can be omitted by defining the option with no data. These are
netmask, broadcast, router, DNS server, domainname and hostname. Thus, for DHCPv4
<b>--dhcp-option = option:router</b>
will result in no router option being sent, rather than the default of the host on which dnsmasq is running. For DHCPv6, the same is true of the options DNS server and refresh time.
<p>
<p>
Data types allowed are comma separated
dotted-quad IPv4 addresses, []-wrapped IPv6 addresses, a decimal number, colon-separated hex digits
and a text string. If the optional tags are given then
this option is only sent when all the tags are matched.
<p>
Special processing is done on a text argument for option 119, to
conform with RFC 3397. Text or dotted-quad IP addresses as arguments
to option 120 are handled as per RFC 3361. Dotted-quad IP addresses
which are followed by a slash and then a netmask size are encoded as
described in RFC 3442.
<p>
IPv6 options are specified using the <b>option6:</b>
keyword, followed by the option number or option name. The IPv6 option
name space is disjoint from the IPv4 option name space. IPv6 addresses
in options must be bracketed with square brackets, eg.
<b> --dhcp-option=option6:ntp-server,[1234::56]</b>.
For IPv6, [::] means "the global address of
the machine running dnsmasq", whilst [fd00::] is replaced with the
ULA, if it exists, and [fe80::] with the link-local address.
<p>
Be careful: data-type suitability for the option number sent is not checked.
It is quite possible to persuade dnsmasq to generate illegal DHCP packets with
injudicious use of this flag.
When the value is a decimal number, dnsmasq must determine how
large the data item is. It does this by examining the option number and/or the
value, but can be overridden by appending a single letter flag as follows:
b = one byte, s = two bytes, i = four bytes. This is mainly useful with
encapsulated vendor class options (see below) where dnsmasq cannot
determine data size from the option number. Option data which
consists solely of periods and digits will be interpreted by dnsmasq
as an IP address, and inserted into an option as such. To force a
literal string, use quotes. For instance when using option 66 to send
a literal IP address as TFTP server name, it is necessary to do
<b>--dhcp-option=66,"1.2.3.4"</b>.
<p>
Encapsulated Vendor-class options may also be specified (IPv4 only) using
<b>--dhcp-option</b>: for instance
<b>--dhcp-option=vendor:PXEClient,1,0.0.0.0 </b>
sends the encapsulated vendor
class-specific option "mftp-address=0.0.0.0" to any client whose
vendor-class matches "PXEClient". The vendor-class matching is
substring based (see <b>--dhcp-vendorclass</b> for details). If a
vendor-class option (number 60) is sent by dnsmasq, then that is used
for selecting encapsulated options in preference to any sent by the
client. It is
possible to omit the vendorclass completely;
<b>--dhcp-option=vendor:,1,0.0.0.0</b>
in which case the encapsulated option is always sent.
<p>
Options may be encapsulated (IPv4 only) within other options: for instance
<b>--dhcp-option=encap:175, 190, iscsi-client0</b>
will send option 175, within which is the option 190. If multiple
options are given which are encapsulated with the same option number
then they will be correctly combined into one encapsulated option.
encap: and vendor: may not both be set in the same <b>--dhcp-option</b>.
<p>
The final variant on encapsulated options is "Vendor-Identifying
Vendor Options" as specified by RFC3925. These are denoted like this:
<b>--dhcp-option=vi-encap:2, 10, text</b>
The number in the vi-encap: section is the IANA enterprise number
used to identify this option. This form of encapsulation is supported
in IPv6.
<p>
The address 0.0.0.0 is not treated specially in
encapsulated options.</dd>
@@ -0,0 +1,11 @@
<dt><b>--dhcp-optsfile=&lt;path&gt;</b></dt>
<dd>Read DHCP option information from the specified file. If a directory
is given, then read all the files contained in that directory in alphabetical order. The advantage of
using this option is the same as for <b>--dhcp-hostsfile</b>: the
<b>--dhcp-optsfile</b> will be re-read when dnsmasq receives SIGHUP. Note that
it is possible to encode the information in a
<b>--dhcp-boot</b>
flag as DHCP options, using the options names bootfile-name,
server-ip-address and tftp-server. This allows these to be included
in a <b>--dhcp-optsfile</b>.</dd>
@@ -0,0 +1,2 @@
<dt><b>-F, --dhcp-range=[tag:&lt;tag&gt;[,tag:&lt;tag&gt;],][set:&lt;tag&gt;,]&lt;start-addr&gt;[,&lt;end-addr&gt;|&lt;mode&gt;[,&lt;netmask&gt;[,&lt;broadcast&gt;]]][,&lt;lease time&gt;]</b></dt>
<dd></dd>
@@ -0,0 +1,6 @@
<dt><b>--dhcp-rapid-commit</b></dt>
<dd>Enable DHCPv4 Rapid Commit Option specified in RFC 4039. When enabled, dnsmasq
will respond to a DHCPDISCOVER message including a Rapid Commit
option with a DHCPACK including a Rapid Commit option and fully committed
address and configuration information. Should only be enabled if either the
server is the only server for the subnet, or multiple servers are present and they each commit a binding for all clients.</dd>
@@ -0,0 +1,146 @@
<dt><b>-6 --dhcp-script=&lt;path&gt;</b></dt>
<dd>Whenever a new DHCP lease is created, or an old one destroyed, or a
TFTP file transfer completes, the
executable specified by this option is run. &lt;path&gt;
must be an absolute pathname, no PATH search occurs.
The arguments to the process
are "add", "old" or "del", the MAC
address of the host (or DUID for IPv6) , the IP address, and the hostname,
if known. "add" means a lease has been created, "del" means it has
been destroyed, "old" is a notification of an existing lease when
dnsmasq starts or a change to MAC address or hostname of an existing
lease (also, lease length or expiry and client-id, if <b>--leasefile-ro</b> is set
and lease expiry if <b>--script-on-renewal</b> is set).
If the MAC address is from a network type other than ethernet,
it will have the network type prepended, eg "06-01:23:45:67:89:ab" for
token ring. The process is run as root (assuming that dnsmasq was originally run as
root) even if dnsmasq is configured to change UID to an unprivileged user.
<p>
The environment is inherited from the invoker of dnsmasq, with some or
all of the following variables added
<p>
For both IPv4 and IPv6:
<p>
DNSMASQ_DOMAIN if the fully-qualified domain name of the host is
known, this is set to the domain part. (Note that the hostname passed
to the script as an argument is never fully-qualified.)
<p>
If the client provides a hostname, DNSMASQ_SUPPLIED_HOSTNAME
<p>
If the client provides user-classes, DNSMASQ_USER_CLASS0..DNSMASQ_USER_CLASSn
<p>
If dnsmasq was compiled with HAVE_BROKEN_RTC, then
the length of the lease (in seconds) is stored in
DNSMASQ_LEASE_LENGTH, otherwise the time of lease expiry is stored in
DNSMASQ_LEASE_EXPIRES. The number of seconds until lease expiry is
always stored in DNSMASQ_TIME_REMAINING.
<p>
DNSMASQ_DATA_MISSING is set to "1" during "old" events for existing
leases generated at startup to indicate that data not stored in the
persistent lease database will not be present. This comprises everything
other than IP address, hostname, MAC address, DUID, IAID and lease length
or expiry time.
<p>
If a lease used to have a hostname, which is
removed, an "old" event is generated with the new state of the lease,
ie no name, and the former name is provided in the environment
variable DNSMASQ_OLD_HOSTNAME.
<p>
DNSMASQ_INTERFACE stores the name of
the interface on which the request arrived; this is not set for "old"
actions when dnsmasq restarts.
<p>
DNSMASQ_RELAY_ADDRESS is set if the client
used a DHCP relay to contact dnsmasq and the IP address of the relay
is known.
<p>
DNSMASQ_TAGS contains all the tags set during the
DHCP transaction, separated by spaces.
<p>
DNSMASQ_LOG_DHCP is set if
<b>--log-dhcp</b>
is in effect.
<p>
DNSMASQ_REQUESTED_OPTIONS a string containing the decimal values in the Parameter Request List option, comma separated, if the parameter request list option is provided by the client.
<p>
DNSMASQ_MUD_URL the Manufacturer Usage Description URL if provided by the client. (See RFC8520 for details.)
<p>
<p>
For IPv4 only:
<p>
DNSMASQ_CLIENT_ID if the host provided a client-id.
<p>
DNSMASQ_CIRCUIT_ID, DNSMASQ_SUBSCRIBER_ID, DNSMASQ_REMOTE_ID if a
DHCP relay-agent added any of these options.
<br/> 
If the client provides vendor-class, DNSMASQ_VENDOR_CLASS.
<p>
For IPv6 only:
<p>
If the client provides vendor-class, DNSMASQ_VENDOR_CLASS_ID,
containing the IANA enterprise id for the class, and
DNSMASQ_VENDOR_CLASS0..DNSMASQ_VENDOR_CLASSn for the data.
<p>
DNSMASQ_SERVER_DUID containing the DUID of the server: this is the same for
every call to the script.
<p>
DNSMASQ_IAID containing the IAID for the lease. If the lease is a
temporary allocation, this is prefixed to 'T'.
<p>
DNSMASQ_MAC containing the MAC address of the client, if known.
<p>
Note that the supplied hostname, vendorclass and userclass data is
only supplied for
"add" actions or "old" actions when a host resumes an existing lease,
since these data are not held in dnsmasq's lease
database.
<p>
<p>
<p>
All file descriptors are
closed except stdin, which is open to /dev/null, and stdout and stderr which capture output for logging by dnsmasq.
(In debug mode, stdio, stdout and stderr file are left as those inherited from the invoker of dnsmasq).
<p>
The script is not invoked concurrently: at most one instance
of the script is ever running (dnsmasq waits for an instance of script to exit
before running the next). Changes to the lease database are which
require the script to be invoked are queued awaiting exit of a running instance.
If this queueing allows multiple state changes occur to a single
lease before the script can be run then
earlier states are discarded and the current state of that lease is
reflected when the script finally runs.
<p>
At dnsmasq startup, the script will be invoked for
all existing leases as they are read from the lease file. Expired
leases will be called with "del" and others with "old". When dnsmasq
receives a HUP signal, the script will be invoked for existing leases
with an "old" event.
<p>
<p>
There are five further actions which may appear as the first argument
to the script, "init", "arp-add", "arp-del", "relay-snoop" and "tftp".
More may be added in the future, so
scripts should be written to ignore unknown actions. "init" is
described below in
<b>--leasefile-ro</b>
<p>
The "tftp" action is invoked when a TFTP file transfer completes: the
arguments are the file size in bytes, the address to which the file
was sent, and the complete pathname of the file.
<p>
The "relay-snoop" action is invoked when dnsmasq is configured as a DHCP
relay for DHCPv6 and it relays a prefx delegation to a client. The arguments
are the name of the interface where the client is connected, its (link-local)
address on that interface and the delegated prefix. This information is
sufficient to install routes to the delegated prefix of a router. See
<b>--dhcp-relay</b>
for more details on configuring DHCP relay.
<p>
The "arp-add" and "arp-del" actions are only called if enabled with
<b>--script-arp</b>
They are are supplied with a MAC address and IP address as arguments. "arp-add" indicates
the arrival of a new entry in the ARP or neighbour table, and "arp-del" indicates the deletion of same.
<p></dd>
@@ -0,0 +1,2 @@
<dt><b>--dhcp-ttl=&lt;time&gt;</b></dt>
<dd>As for <b>--local-ttl</b>, but affects only replies with information from DHCP leases. If both are given, <b>--dhcp-ttl</b> applies for DHCP information, and <b>--local-ttl</b> for others. Setting this to zero eliminates the effect of <b>--local-ttl</b> for DHCP.</dd>
@@ -0,0 +1,8 @@
<dt><b>-j, --dhcp-userclass=set:&lt;tag&gt;,&lt;user-class&gt;</b></dt>
<dd>Map from a user-class string to a tag (with substring
matching, like vendor classes). Most DHCP clients provide a
"user class" which is configurable. This option
maps user classes to tags, so that DHCP options may be selectively delivered
to different classes of hosts. It is possible, for instance to use
this to set a different printer server for hosts in the class
"accounts" than for hosts in the class "engineering".</dd>
@@ -0,0 +1,19 @@
<dt><b>-U, --dhcp-vendorclass=set:&lt;tag&gt;,[enterprise:&lt;IANA-enterprise number&gt;,]&lt;vendor-class&gt;</b></dt>
<dd>Map from a vendor-class string to a tag. Most DHCP clients provide a
"vendor class" which represents, in some sense, the type of host. This option
maps vendor classes to tags, so that DHCP options may be selectively delivered
to different classes of hosts. For example
<b>--dhcp-vendorclass=set:printers,Hewlett-Packard JetDirect</b>
will allow options to be set only for HP printers like so:
<b>--dhcp-option=tag:printers,3,192.168.4.4 </b>
The vendor-class string is
substring matched against the vendor-class supplied by the client, to
allow fuzzy matching. The set: prefix is optional but allowed for
consistency.
<p>
Note that in IPv6 only, vendorclasses are namespaced with an
IANA-allocated enterprise number. This is given with enterprise:
keyword and specifies that only vendorclasses matching the specified
number should be searched.</dd>
@@ -0,0 +1,9 @@
<dt><b>--dns-loop-detect</b></dt>
<dd>Enable code to detect DNS forwarding loops; ie the situation where a query sent to one
of the upstream server eventually returns as a new query to the dnsmasq instance. The
process works by generating TXT queries of the form &lt;hex&gt;.test and sending them to
each upstream server. The hex is a UID which encodes the instance of dnsmasq sending the query
and the upstream server to which it was sent. If the query returns to the server which sent it, then
the upstream server through which it was sent is disabled and this event is logged. Each time the
set of upstream servers changes, the test is re-run on all of them, including ones which
were previously disabled.</dd>
@@ -0,0 +1,15 @@
<dt><b>--dnssec-check-unsigned[=no]</b></dt>
<dd>As a default, dnsmasq checks that unsigned DNS replies are
legitimate: this entails possible extra queries even for the majority of DNS
zones which are not, at the moment, signed. If
<b>--dnssec-check-unsigned=no</b>
appears in the configuration, then such replies they are assumed to be valid and passed on (without the
"authentic data" bit set, of course). This does not protect against an
attacker forging unsigned replies for signed DNS zones, but it is
fast.
<p>
Versions of dnsmasq prior to 2.80 defaulted to not checking unsigned replies, and used
<b>--dnssec-check-unsigned</b>
to switch this on. Such configurations will continue to work as before, but those which used the default of no checking will need to be altered to explicitly select no checking. The new default is because switching off checking for unsigned replies is inherently dangerous. Not only does it open the possibility of forged replies, but it allows everything to appear to be working even when the upstream namesevers do not support DNSSEC, and in this case no DNSSEC validation at all is occurring.</dd>
@@ -0,0 +1,16 @@
<dt><b>--dnssec</b></dt>
<dd>Validate DNS replies and cache DNSSEC data. When forwarding DNS queries, dnsmasq requests the
DNSSEC records needed to validate the replies. The replies are validated and the result returned as
the Authenticated Data bit in the DNS packet. In addition the DNSSEC records are stored in the cache, making
validation by clients more efficient. Note that validation by clients is the most secure DNSSEC mode, but for
clients unable to do validation, use of the AD bit set by dnsmasq is useful, provided that the network between
the dnsmasq server and the client is trusted. Dnsmasq must be compiled with HAVE_DNSSEC enabled, and DNSSEC
trust anchors provided, see
<b>--trust-anchor.</b>
Because the DNSSEC validation process uses the cache, it is not
permitted to reduce the cache size below the default when DNSSEC is
enabled. The nameservers upstream of dnsmasq must be DNSSEC-capable,
ie capable of returning DNSSEC records with data. If they are not,
then dnsmasq will not be able to determine the trusted status of
answers and this means that DNS service will be entirely broken.</dd>
@@ -0,0 +1,4 @@
<dt><b>-D, --domain-needed</b></dt>
<dd>Tells dnsmasq to never forward A or AAAA queries for plain names, without dots
or domain parts, to upstream nameservers. If the name is not known
from /etc/hosts or DHCP then a "not found" answer is returned.</dd>
@@ -0,0 +1,43 @@
<dt><b>-s, --domain=&lt;domain&gt;[[,&lt;address range&gt;[,local]]|&lt;interface&gt;]</b></dt>
<dd>Specifies DNS domains for the DHCP server. Domains may be be given
unconditionally (without the IP range) or for limited IP ranges. This has two effects;
firstly it causes the DHCP server to return the domain to any hosts
which request it, and secondly it sets the domain which it is legal
for DHCP-configured hosts to claim. The intention is to constrain
hostnames so that an untrusted host on the LAN cannot advertise
its name via DHCP as e.g. "microsoft.com" and capture traffic not
meant for it. If no domain suffix is specified, then any DHCP
hostname with a domain part (ie with a period) will be disallowed
and logged. If suffix is specified, then hostnames with a domain
part are allowed, provided the domain part matches the suffix. In
addition, when a suffix is set then hostnames without a domain
part have the suffix added as an optional domain part. Eg on my network I can set
<b>--domain=thekelleys.org.uk</b>
and have a machine whose DHCP hostname is "laptop". The IP address for that machine is available from
<b>dnsmasq</b>
both as "laptop" and "laptop.thekelleys.org.uk". If the domain is
given as "#" then the domain is read from the first "search" directive
in /etc/resolv.conf (or equivalent).
<p>
The address range can be of the form
&lt;ip address&gt;,&lt;ip address&gt; or &lt;ip address&gt;/&lt;netmask&gt; or just a single
&lt;ip address&gt;. See
<b>--dhcp-fqdn</b>
which can change the behaviour of dnsmasq with domains.
<p>
If the address range is given as ip-address/network-size, then a
additional flag "local" may be supplied which has the effect of adding
<b>--local</b> declarations for forward and reverse DNS queries. Eg.
<b>--domain=thekelleys.org.uk,192.168.0.0/24,local</b>
is identical to
<b>--domain=thekelleys.org.uk,192.168.0.0/24</b>
<b>--local=/thekelleys.org.uk/ --local=/0.168.192.in-addr.arpa/</b>
<p>
The address range can also be given as a network interface name, in which case
all of the subnets currently assigned to the interface are used in matching the
address. This allows hosts on different physical subnets to be given different
domains in a way which updates automatically as the interface addresses change.</dd>
@@ -0,0 +1,6 @@
<dt><b>--dynamic-host=&lt;name&gt;,[IPv4-address],[IPv6-address],&lt;interface&gt;</b></dt>
<dd>Add A, AAAA and PTR records to the DNS in the same subnet as the specified interface. The address is derived from the network part of each address associated with the interface, and the host part from the specified address. For example
<b>--dynamic-host=example.com,0.0.0.8,eth0</b>
will, when eth0 has the address 192.168.78.x and netmask 255.255.255.0 give the
name example.com an A record for 192.168.78.8. The same principle applies to IPv6 addresses. Note that if an interface has more than one address, more than one A or AAAA record will be created. The TTL of the records is always zero, and any changes to interface addresses will be immediately reflected in them.</dd>
@@ -0,0 +1,4 @@
<dt><b>-P, --edns-packet-max=&lt;size&gt;</b></dt>
<dd>Specify the largest EDNS.0 UDP packet which is supported by the DNS
forwarder. Defaults to 1232, which is the recommended size following the
DNS flag day in 2020. Only increase if you know what you are doing.</dd>
@@ -0,0 +1,7 @@
<dt><b>-1, --enable-dbus[=&lt;service-name&gt;]</b></dt>
<dd>Allow dnsmasq configuration to be updated via DBus method calls. The
configuration which can be changed is upstream DNS servers (and
corresponding domains) and cache clear. Requires that dnsmasq has
been built with DBus support. If the service name is given, dnsmasq
provides service at that name, rather than the default which is
<b>uk.org.thekelleys.dnsmasq</b></dd>
@@ -0,0 +1,18 @@
<dt><b>--enable-ra</b></dt>
<dd>Enable dnsmasq's IPv6 Router Advertisement feature. DHCPv6 doesn't
handle complete network configuration in the same way as DHCPv4. Router
discovery and (possibly) prefix discovery for autonomous address
creation are handled by a different protocol. When DHCP is in use,
only a subset of this is needed, and dnsmasq can handle it, using
existing DHCP configuration to provide most data. When RA is enabled,
dnsmasq will advertise a prefix for each <b>--dhcp-range</b>, with default
router as the relevant link-local address on
the machine running dnsmasq. By default, the "managed address" bits are set, and
the "use SLAAC" bit is reset. This can be changed for individual
subnets with the mode keywords described in
<b>--dhcp-range.</b>
RFC6106 DNS parameters are included in the advertisements. By default,
the relevant link-local address of the machine running dnsmasq is sent
as recursive DNS server. If provided, the DHCPv6 options dns-server and
domain-search are used for the DNS server (RDNSS) and the domain search list (DNSSL).</dd>
@@ -0,0 +1,6 @@
<dt><b>--enable-tftp[=&lt;interface&gt;[,&lt;interface&gt;]]</b></dt>
<dd>Enable the TFTP server function. This is deliberately limited to that
needed to net-boot a client. Only reading is allowed; the tsize and
blksize extensions are supported (tsize is only supported in octet
mode). Without an argument, the TFTP service is provided to the same set of interfaces as DHCP service.
If the list of interfaces is provided, that defines which interfaces receive TFTP service.</dd>
@@ -0,0 +1,10 @@
<dt><b>--enable-ubus[=&lt;service-name&gt;]</b></dt>
<dd>Enable dnsmasq UBus interface. It sends notifications via UBus on
DHCPACK and DHCPRELEASE events. Furthermore it offers metrics
and allows configuration of Linux connection track mark based filtering.
When DNS query filtering based on Linux connection track marks is enabled
UBus notifications are generated for each resolved or filtered DNS query.
Requires that dnsmasq has been built with UBus support. If the service
name is given, dnsmasq provides service at that namespace, rather than
the default which is
<b>dnsmasq</b></dd>
@@ -0,0 +1,15 @@
<dt><b>-I, --except-interface=&lt;interface name&gt;</b></dt>
<dd>Do not listen on the specified interface. Note that the order of
<b>--listen-address</b>
<b>--interface</b>
and
<b>--except-interface</b>
options does not matter and that
<b>--except-interface</b>
options always override the others. The comments about interface labels for
<b>--listen-address</b>
apply here.</dd>
@@ -0,0 +1,4 @@
<dt><b>-E, --expand-hosts</b></dt>
<dd>Add the domain to simple names (without a period) in /etc/hosts
in the same way as for DHCP-derived names. Note that this does not
apply to domain names in cnames, PTR records, TXT records etc.</dd>
@@ -0,0 +1,10 @@
<dt><b>--fast-dns-retry=[&lt;initial retry delay in ms&gt;[,&lt;time to continue retries in ms&gt;]]</b></dt>
<dd>Under normal circumstances, dnsmasq relies on DNS clients to do retries; it
does not generate timeouts itself. Setting this option
instructs dnsmasq to generate its own retries starting after a delay
which defaults to 1000ms. If the second parameter is given this controls
how long the retries will continue for
otherwise this defaults to 10000ms. Retries are repeated with exponential
backoff. Using this option increases memory usage and
network bandwidth. If not otherwise configured, this option is activated
with the default parameters when <b>--dnssec</b> is set.</dd>
@@ -0,0 +1,2 @@
<dt><b>--filter-A</b></dt>
<dd>Remove A records from answers. No IPv4 addresses will be returned.</dd>
@@ -0,0 +1,2 @@
<dt><b>--filter-AAAA</b></dt>
<dd>Remove AAAA records from answers. No IPv6 addresses will be returned.</dd>
@@ -0,0 +1,6 @@
<dt><b>--filter-rr=&lt;rrtype&gt;[,&lt;rrtype&gt;...]</b></dt>
<dd>Remove records of the specified type(s) from answers. The otherwise-nonsensical --filter-rr=ANY has
a special meaning: it filters replies to queries for type ANY. Everything other than A, AAAA, MX and CNAME
records are removed. Since ANY queries with forged source addresses can be used in DNS amplification attacks
(replies to ANY queries can be large) this defangs such attacks, whilst still supporting the
one remaining possible use of ANY queries. See RFC 8482 para 4.3 for details.</dd>
@@ -0,0 +1,6 @@
<dt><b>-f, --filterwin2k</b></dt>
<dd>Later versions of windows make periodic DNS requests which don't get sensible answers from
the public DNS and can cause problems by triggering dial-on-demand links. This flag turns on an option
to filter such requests. The requests blocked are for records of type ANY
where the requested name has underscores, to catch LDAP requests, and for
<b>all</b> records of types SOA and SRV.</dd>
@@ -0,0 +1,4 @@
<dt><b>-g, --group=&lt;groupname&gt; </b></dt>
<dd>Specify the group which dnsmasq will run
as. The default is "dip", if available, to facilitate access to
/etc/ppp/resolv.conf which is not normally world readable.</dd>
@@ -0,0 +1,25 @@
<dt><b>--host-record=&lt;name&gt;[,&lt;name&gt;....],[&lt;IPv4-address&gt;],[&lt;IPv6-address&gt;][,&lt;TTL&gt;]</b></dt>
<dd>Add A, AAAA and PTR records to the DNS. This adds one or more names to
the DNS with associated IPv4 (A) and IPv6 (AAAA) records. A name may
appear in more than one
<b>--host-record</b>
and therefore be assigned more than one address. Only the first
address creates a PTR record linking the address to the name. This is
the same rule as is used reading hosts-files.
<b>--host-record</b>
options are considered to be read before host-files, so a name
appearing there inhibits PTR-record creation if it appears in
hosts-file also. Unlike hosts-files, names are not expanded, even when
<b>--expand-hosts</b>
is in effect. Short and long names may appear in the same
<b>--host-record,</b>
eg.
<b>--host-record=laptop,laptop.thekelleys.org,192.168.0.1,1234::100</b>
<p>
If the time-to-live is given, it overrides the default, which is zero
or the value of <b>--local-ttl</b>. The value is a positive integer and gives
the time-to-live in seconds.</dd>
@@ -0,0 +1,4 @@
<dt><b>--hostsdir=&lt;path&gt;</b></dt>
<dd>Read all the hosts files contained in the directory. New or changed files
are read automatically and modified and deleted files have removed records
automatically deleted.</dd>
@@ -0,0 +1,5 @@
<dt><b>--ignore-address=&lt;ipaddr&gt;[/prefix]</b></dt>
<dd>Ignore replies to A or AAAA queries which include the specified address or subnet.
No error is generated, dnsmasq simply continues to listen for another reply.
This is useful to defeat blocking strategies which rely on quickly supplying a
forged answer to a DNS request for certain domain, before the correct answer can arrive.</dd>
@@ -0,0 +1,13 @@
<dt><b>--interface-name=&lt;name&gt;,&lt;interface&gt;[/4|/6]</b></dt>
<dd>Return DNS records associating the name with the address(es) of
the given interface. This flag specifies an A or AAAA record for the given
name in the same way as an /etc/hosts line, except that the address is
not constant, but taken from the given interface. The interface may be
followed by "/4" or "/6" to specify that only IPv4 or IPv6 addresses
of the interface should be used. If the interface is
down, not configured or non-existent, an empty record is returned. The
matching PTR record is also created, mapping the interface address to
the name. More than one name may be associated with an interface
address by repeating the flag; in that case the first instance is used
for the reverse address-to-name mapping. Note that a name used in
<b>--interface-name</b> may not appear in /etc/hosts.</dd>
@@ -0,0 +1,36 @@
<dt><b>-i, --interface=&lt;interface name&gt;</b></dt>
<dd>Listen only on the specified interface(s). Dnsmasq automatically adds
the loopback (local) interface to the list of interfaces to use when
the
<b>--interface</b>
option is used. If no
<b>--interface</b>
or
<b>--listen-address</b>
options are given dnsmasq listens on all available interfaces except any
given in
<b>--except-interface</b>
options. On Linux, when
<b>--bind-interfaces</b>
or
<b>--bind-dynamic</b>
are in effect, IP alias interface labels (eg "eth1:0") are checked, rather than
interface names. In the degenerate case when an interface has one address, this amounts to the same thing but when an interface has multiple addresses it
allows control over which of those addresses are accepted.
The same effect is achievable in default mode by using
<b>--listen-address.</b>
A simple wildcard, consisting of a trailing '*',
can be used in
<b>--interface </b>
and
<b>--except-interface</b>
options.</dd>
@@ -0,0 +1,11 @@
<dt><b>--ipset=/&lt;domain&gt;[/&lt;domain&gt;...]/&lt;ipset&gt;[,&lt;ipset&gt;...]</b></dt>
<dd>Places the resolved IP addresses of queries for one or more domains in
the specified Netfilter IP set. If multiple setnames are given, then the
addresses are placed in each of them, subject to the limitations of an
IP set (IPv4 addresses cannot be stored in an IPv6 IP set and vice
versa). Domains and subdomains are matched in the same way as
<b>--address</b>.
These IP sets must already exist. See
<b>ipset</b>(8)
for more details.</dd>

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