mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7c2560d07 | ||
|
|
439192058e | ||
|
|
7965fc669b | ||
|
|
dfee96650b | ||
|
|
cefc488712 | ||
|
|
5ac2006b80 | ||
|
|
1bdd176c18 | ||
|
|
b70aa02017 | ||
|
|
d06d6ef6c9 | ||
|
|
1dffdcf2ed |
@@ -29,22 +29,22 @@ jobs:
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${{ github.workspace }}:/src" -w /src \
|
||||
mcr.microsoft.com/dotnet/sdk:9.0 \
|
||||
./scripts/publish-self-contained.sh ${{ matrix.rid }}
|
||||
mcr.microsoft.com/dotnet/sdk:10.0 \
|
||||
./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Publish for ${{ matrix.rid }} (musl on host)
|
||||
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
|
||||
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }}
|
||||
run: ./scripts/publish-self-contained.sh ${{ matrix.rid }} ${{ github.ref_name }}
|
||||
|
||||
- name: Zip publish output
|
||||
run: |
|
||||
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net9.0/${{ matrix.rid }}/publish"
|
||||
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net10.0/${{ matrix.rid }}/publish"
|
||||
(cd "$PUBLISH_DIR" && zip -r "../../../../../../../dnsmasq-webui-${{ matrix.rid }}.zip" .)
|
||||
|
||||
- name: Upload artifact
|
||||
|
||||
+2
-1
@@ -43,6 +43,7 @@ testdata-mount/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# User-specific
|
||||
# User-specific / runtime
|
||||
appsettings.*.local.json
|
||||
appsettings.Overrides.json
|
||||
*.local
|
||||
|
||||
+2
-2
@@ -3,12 +3,12 @@
|
||||
# For dnsmasq on the host, use the self-contained publish (scripts/publish-self-contained.sh)
|
||||
# or run the app on the host; see DnsmasqOptions XML doc for permissions and ReloadCommand scope.
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"]
|
||||
RUN dotnet restore "src/DnsmasqWebUI/DnsmasqWebUI.csproj"
|
||||
|
||||
@@ -39,7 +39,7 @@ The recommended way to get a prebuilt binary is the **install script**. It picks
|
||||
curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/master/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
**From a git clone** (repo is detected from `git remote origin`):
|
||||
**From a git clone:**
|
||||
|
||||
```bash
|
||||
./scripts/install.sh
|
||||
@@ -102,8 +102,6 @@ sudo ./scripts/install.sh --uninstall --purge --system # Also remove /opt/dnsm
|
||||
./scripts/install.sh --version v1.0.0
|
||||
```
|
||||
|
||||
**Installing from a fork:** Pass the repo explicitly: `./scripts/install.sh --repo owner/dnsmasq-webui` or set `GITHUB_REPO=owner/dnsmasq-webui`. The default repo is `alexhopeoconnor/dnsmasq-webui`.
|
||||
|
||||
**If the binary fails to run** (e.g. `TypeLoadException` or glibc errors): use the install script with `--build-from-source` so the app is built for your machine. This requires the .NET SDK and a **git clone** (it does not work when installing via `curl ... | sh`). Clone the repo, then run `./scripts/install.sh --build-from-source`. The script checks that .NET is installed, detects your OS/arch, and builds (on Ubuntu it may use a distro-specific RID for a better match). Other distros use the portable RID and may still hit runtime issues; building from source on the target machine is the most reliable.
|
||||
|
||||
**Configuration:** Set at least `Dnsmasq__MainConfigPath` to your main dnsmasq config. See [Configuration](#configuration) for all options.
|
||||
@@ -238,7 +236,6 @@ All scripts live under `scripts/` and are intended for Linux (WSL may work but i
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--repo OWNER/REPO` | GitHub owner/repo (or set `GITHUB_REPO`). When run from a clone, repo is detected from `git remote origin`. Default repo: `alexhopeoconnor/dnsmasq-webui`. |
|
||||
| `--list` | List available releases (tag, name, published_at) and exit. |
|
||||
| `--version TAG` | Install from release TAG (e.g. v1.0.0). Default: latest. |
|
||||
| `--update` | Reinstall latest into the default user directory (~/.local/share/dnsmasq-webui). |
|
||||
@@ -291,18 +288,14 @@ Invalid combinations (script errors with a clear message): `--purge` without `--
|
||||
Script lists release tags and dates; list-only mode cannot be combined with install/uninstall so you do not accidentally change the system.
|
||||
**Command:** `./scripts/install.sh --list`
|
||||
|
||||
10. **Install from a fork or a specific GitHub repo** instead of the default.
|
||||
You pass the repo; script uses it for the API. Without a clone or `--repo`/GITHUB_REPO it errors so you fix the command instead of getting a confusing API failure.
|
||||
**Command:** `./scripts/install.sh --repo owner/dnsmasq-webui` or `GITHUB_REPO=owner/dnsmasq-webui ./scripts/install.sh`
|
||||
|
||||
11. **Install a specific release tag** (e.g. an older or pinned version) instead of latest.
|
||||
10. **Install a specific release tag** (e.g. an older or pinned version) instead of latest.
|
||||
Script fetches that tag's release and the asset for your RID. If no asset matches your OS/arch it errors and prints the asset names so you can pick another tag or report a missing build.
|
||||
**Command:** `./scripts/install.sh --version v1.0.0`
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Install latest (from clone: repo auto-detected; from curl: uses alexhopeoconnor/dnsmasq-webui by default, or use --repo)
|
||||
# Install latest
|
||||
./scripts/install.sh
|
||||
|
||||
# Update to latest release
|
||||
@@ -329,8 +322,8 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
# Switch to another release (overwrites that dir)
|
||||
./scripts/install.sh --dir ~/.local/share/dnsmasq-webui --version v0.9.0
|
||||
|
||||
# List releases (from fork)
|
||||
./scripts/install.sh --repo alexhopeoconnor/dnsmasq-webui --list
|
||||
# List releases
|
||||
./scripts/install.sh --list
|
||||
```
|
||||
|
||||
**How the script works and avoids mistakes**
|
||||
@@ -351,7 +344,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
`--uninstall` removes only systemd units and symlinks unless you add `--purge`. With `--purge`, you must say *which* install to remove: default user dir (no extra flags), `--dir DIR`, or `--system` for /opt. That avoids accidentally deleting the wrong directory.
|
||||
|
||||
6. **Repo and release**
|
||||
When not in a git clone, the script needs a repo (e.g. `REPO_DEFAULT` in the one-liner or `--repo` / `GITHUB_REPO`). It errors with a clear message instead of hitting the GitHub API with an empty repo. If no asset matches your OS/arch (RID), it prints the available asset names so you can pick a different release or report a missing build.
|
||||
If no asset matches your OS/arch (RID), the script prints the available asset names so you can pick a different release or report a missing build.
|
||||
|
||||
7. **Dependencies**
|
||||
The script requires `curl`, `jq`, and `unzip`. It checks for `jq` before calling the GitHub API and errors with an install hint. `--service` requires `systemctl` (systemd) and errors on non-systemd systems instead of writing a unit that won’t be used.
|
||||
@@ -392,7 +385,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
./scripts/publish-self-contained.sh --no-clean ubuntu.24.04-x64
|
||||
```
|
||||
|
||||
**Output:** `src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/`
|
||||
**Output:** `src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ services:
|
||||
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
|
||||
Dnsmasq__StatusShowCommand: "/app/dnsmasq-status.sh"
|
||||
Dnsmasq__LogsCommand: "tail -n 100 /data/dnsmasq.log 2>/dev/null || echo '(no log file yet)'"
|
||||
# Persist runtime log level across container restarts
|
||||
RuntimeOverrides__FilePath: "/data/appsettings.Overrides.json"
|
||||
volumes:
|
||||
- ${TESTDATA_MOUNT:-./testdata-mount}:/data
|
||||
networks:
|
||||
|
||||
Executable
+253
@@ -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"
|
||||
+52
-81
@@ -1,12 +1,10 @@
|
||||
#!/usr/bin/env sh
|
||||
# Download and install dnsmasq-webui from a GitHub release. Picks the binary
|
||||
# matching the current OS/arch (RID). Run from a clone or via:
|
||||
# curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/main/scripts/install.sh | sh
|
||||
# When run from a clone, repo is detected from git remote origin.
|
||||
# curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/master/scripts/install.sh | sh
|
||||
set -e
|
||||
|
||||
GITHUB_REPO="${GITHUB_REPO:-}"
|
||||
REPO_DEFAULT="${REPO_DEFAULT:-alexhopeoconnor/dnsmasq-webui}"
|
||||
GITHUB_REPO="alexhopeoconnor/dnsmasq-webui"
|
||||
|
||||
RELEASE_TAG=""
|
||||
LIST=false
|
||||
@@ -37,10 +35,6 @@ usage() {
|
||||
echo "Download and install dnsmasq-webui from a GitHub release for this machine's OS/arch."
|
||||
echo "Supports install, update, and switch-release (re-run with same --dir and different --version)."
|
||||
echo ""
|
||||
echo "Repo (when not in a clone):"
|
||||
echo " --repo OWNER/REPO GitHub owner/repo. Or set GITHUB_REPO. Or set REPO_DEFAULT in the script."
|
||||
echo " When run inside a git clone with origin, repo is detected automatically."
|
||||
echo ""
|
||||
echo "Release:"
|
||||
echo " --list List available releases (tag, name, published_at) and exit."
|
||||
echo " --version TAG Install from release TAG (e.g. v1.0.0). Default: latest."
|
||||
@@ -72,7 +66,7 @@ usage() {
|
||||
echo " sudo $0 --uninstall --purge --system # also remove /opt/dnsmasq-webui"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Install latest (clone: auto repo; curl: use REPO_DEFAULT or --repo)"
|
||||
echo " $0 # Install latest"
|
||||
echo " $0 --update # Reinstall latest to default dir (upgrade)"
|
||||
echo " $0 --version v1.0.0 # Install v1.0.0 to default dir"
|
||||
echo " $0 --dir /opt/dnsmasq-webui # Install to custom dir"
|
||||
@@ -90,11 +84,6 @@ usage() {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Trim whitespace and carriage return (e.g. from script downloaded on Windows or with CRLF).
|
||||
trim_repo() {
|
||||
echo "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
# Build CONFIG_VARS_FILE from env (DNSMASQ_WEBUI_*) then --set lines. Call before do_install when installing.
|
||||
# Result: key=value per line (env first, then --set so --set overrides). Safe for values with = in them.
|
||||
collect_config() {
|
||||
@@ -145,30 +134,6 @@ write_env_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect owner/repo from git remote, REPO_DEFAULT, or require --repo/GITHUB_REPO.
|
||||
detect_repo() {
|
||||
if [ -n "$GITHUB_REPO" ]; then
|
||||
GITHUB_REPO="$(trim_repo "$GITHUB_REPO")"
|
||||
return
|
||||
fi
|
||||
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
origin="$(git remote get-url origin 2>/dev/null)" || true
|
||||
if [ -n "$origin" ]; then
|
||||
GITHUB_REPO="$(echo "$origin" | sed -E 's|^https://github\.com/||; s|^git@github\.com:||; s|\.git$||; s|/$||')"
|
||||
GITHUB_REPO="$(trim_repo "$GITHUB_REPO")"
|
||||
if [ -n "$GITHUB_REPO" ]; then
|
||||
return
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "$REPO_DEFAULT" ]; then
|
||||
GITHUB_REPO="$(trim_repo "$REPO_DEFAULT")"
|
||||
return
|
||||
fi
|
||||
echo "Error: GitHub repo not set. Use --repo owner/repo, set GITHUB_REPO, or run from a clone. For the one-liner, set REPO_DEFAULT in the script." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Require jq for parsing GitHub API JSON.
|
||||
check_jq() {
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
@@ -178,6 +143,14 @@ check_jq() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Safely run jq on release JSON (avoids echo mangling). $1 = JSON, $2+ = jq args.
|
||||
jq_release() {
|
||||
local json
|
||||
json="$1"
|
||||
shift
|
||||
printf '%s' "$json" | jq -r "$@"
|
||||
}
|
||||
|
||||
# Detect RID (same logic as publish-self-contained.sh).
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
@@ -234,40 +207,45 @@ fetch_release() {
|
||||
api_url="https://api.github.com/repos/$GITHUB_REPO/releases/tags/$RELEASE_TAG"
|
||||
fi
|
||||
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url" </dev/null)"
|
||||
code="$(echo "$resp" | tail -n1)"
|
||||
body="$(echo "$resp" | sed '$d')"
|
||||
code="$(printf '%s' "$resp" | tail -n1)"
|
||||
body="$(printf '%s' "$resp" | sed '$d')"
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "Error: GitHub API returned $code for $api_url" >&2
|
||||
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
|
||||
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
|
||||
printf '%s' "$body" | jq -r '.message // .' 2>/dev/null || printf '%s' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$body"
|
||||
# GitHub API can return release body with unescaped control chars; jq rejects them. Strip control chars (keep \n \r).
|
||||
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
|
||||
printf '%s' "$body"
|
||||
}
|
||||
|
||||
# List releases and exit.
|
||||
list_releases() {
|
||||
check_jq
|
||||
detect_repo
|
||||
api_url="https://api.github.com/repos/$GITHUB_REPO/releases?per_page=20"
|
||||
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url" </dev/null)"
|
||||
code="$(echo "$resp" | tail -n1)"
|
||||
body="$(echo "$resp" | sed '$d')"
|
||||
code="$(printf '%s' "$resp" | tail -n1)"
|
||||
body="$(printf '%s' "$resp" | sed '$d')"
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "Error: GitHub API returned $code" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$body" | jq -r '.[] | "\(.tag_name) \(.name) \(.published_at // .created_at)"'
|
||||
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
|
||||
printf '%s' "$body" | jq -r '.[] | "\(.tag_name) \(.name) \(.published_at // .created_at)"'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Find asset download URL whose name contains the given RID.
|
||||
# Find asset download URL whose name contains the given RID. Output trimmed single line or empty.
|
||||
find_asset_url() {
|
||||
local release_json rid
|
||||
local release_json rid raw
|
||||
release_json="$1"
|
||||
rid="$2"
|
||||
echo "$release_json" | jq -r --arg rid "$rid" '
|
||||
raw="$(jq_release "$release_json" --arg rid "$rid" '
|
||||
.assets[] | select(.name | test($rid)) | .browser_download_url
|
||||
' | head -n1
|
||||
' | head -n1)"
|
||||
# Trim CR and leading/trailing whitespace (defensive for API quirks)
|
||||
printf '%s' "$raw" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
# Remove user systemd unit (stop, disable, rm file). No root. Safe if not present.
|
||||
@@ -368,7 +346,7 @@ do_install_from_source() {
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v dotnet >/dev/null 2>&1; then
|
||||
echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-9.0)." >&2
|
||||
echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-10.0)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -393,7 +371,7 @@ do_install_from_source() {
|
||||
echo "Error: Could not determine RID from build output." >&2
|
||||
exit 1
|
||||
fi
|
||||
publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net9.0/$rid/publish"
|
||||
publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net10.0/$rid/publish"
|
||||
if [ ! -d "$publish_dir" ] || [ ! -f "$publish_dir/DnsmasqWebUI" ]; then
|
||||
echo "Error: Build output not found at $publish_dir" >&2
|
||||
exit 1
|
||||
@@ -449,7 +427,6 @@ do_install() {
|
||||
|
||||
echo "Installing dnsmasq-webui..."
|
||||
check_jq
|
||||
detect_repo
|
||||
|
||||
if [ "$SYSTEM_INSTALL" = true ]; then
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
@@ -476,18 +453,18 @@ do_install() {
|
||||
print_env_detection "$rid"
|
||||
echo "Fetching release..."
|
||||
release_json="$(fetch_release)"
|
||||
tag="$(echo "$release_json" | jq -r '.tag_name')"
|
||||
tag="$(jq_release "$release_json" '.tag_name')"
|
||||
echo "Release: $tag"
|
||||
url="$(find_asset_url "$release_json" "$rid" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
||||
url="$(find_asset_url "$release_json" "$rid")"
|
||||
if [ -z "$url" ] || [ "$url" = "null" ]; then
|
||||
echo "Error: No asset found for RID $rid in release $tag." >&2
|
||||
echo "Available assets:" >&2
|
||||
echo "$release_json" | jq -r '.assets[].name' | sed 's/^/ /' >&2
|
||||
jq_release "$release_json" '.assets[].name' | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
tmpzip="${TMPDIR:-/tmp}/dnsmasq-webui-$rid.zip"
|
||||
tmpzip="$(mktemp)"
|
||||
echo "Downloading $url ..."
|
||||
curl -sSL -A "dnsmasq-webui-install/1.0" -o "$tmpzip" "$url" </dev/null
|
||||
echo "Extracting to $INSTALL_DIR ..."
|
||||
@@ -627,12 +604,6 @@ while [ $# -gt 0 ]; do
|
||||
-h|-?|--help)
|
||||
usage
|
||||
;;
|
||||
--repo)
|
||||
shift
|
||||
[ $# -gt 0 ] || { echo "Error: --repo requires OWNER/REPO" >&2; exit 1; }
|
||||
GITHUB_REPO="$1"
|
||||
shift
|
||||
;;
|
||||
--list)
|
||||
LIST=true
|
||||
shift
|
||||
@@ -697,26 +668,26 @@ if [ "$PURGE" = true ] && [ "$UNINSTALL" != true ]; then
|
||||
echo "Error: --purge must be used with --uninstall. E.g. $0 --uninstall --purge" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$UNINSTALL" = true ]; then
|
||||
if [ "$UPDATE" = true ] || [ -n "$RELEASE_TAG" ] || [ "$SERVICE" = true ] || [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with install/update options (--version, --update, --service, --build-from-source). Run uninstall alone, then install if needed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$UNINSTALL" = true ]; then
|
||||
if [ "$UPDATE" = true ] || [ -n "$RELEASE_TAG" ] || [ "$SERVICE" = true ] || [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with install/update options (--version, --update, --service, --build-from-source). Run uninstall alone, then install if needed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --build-from-source cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$RELEASE_TAG" ] || [ "$UPDATE" = true ]; then
|
||||
echo "Error: --build-from-source builds from current source; do not use --version or --update." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --build-from-source cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$RELEASE_TAG" ] || [ "$UPDATE" = true ]; then
|
||||
echo "Error: --build-from-source builds from current source; do not use --version or --update." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$LIST" = true ]; then
|
||||
if [ "$UNINSTALL" = true ] || [ "$UPDATE" = true ] || [ "$SERVICE" = true ] || [ -n "$RELEASE_TAG" ]; then
|
||||
echo "Error: --list lists releases and exits; do not combine with install/uninstall options." >&2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
|
||||
#
|
||||
# What this script does:
|
||||
# Start (default): clear mount (unless --no-clear), sync source -> mount, clean up previous test data,
|
||||
# Start (default): sync source -> mount (preserving existing unless --clear), clean up previous test data,
|
||||
# then docker compose up -d [--build] [--force-recreate].
|
||||
# --stop: docker compose down (stop and remove containers/networks).
|
||||
# --tidy: docker compose down, then clear the mount directory for a clean next run.
|
||||
@@ -18,7 +18,7 @@ MOUNT_DIR=""
|
||||
PREPARE_ONLY=false
|
||||
BUILD=false
|
||||
RECREATE=false
|
||||
NO_CLEAR=false
|
||||
CLEAR=false
|
||||
STOP=false
|
||||
TIDY=false
|
||||
|
||||
@@ -26,10 +26,10 @@ usage() {
|
||||
echo "Usage: $0 [OPTIONS] [--]"
|
||||
echo ""
|
||||
echo "Prepare the testdata mount directory and optionally start or stop the Docker test harness"
|
||||
echo "(app + dnsmasq + DHCP client). By default: clear mount, sync testdata -> mount, then up -d (no rebuild)."
|
||||
echo "(app + dnsmasq + DHCP client). By default: sync testdata -> mount (preserving existing), then up -d (no rebuild)."
|
||||
echo ""
|
||||
echo "Steps (when starting):"
|
||||
echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount."
|
||||
echo " 1. Optionally clear mount dir (only with --clear), then sync source -> mount."
|
||||
echo " 2. Clean up previous test data (e.g. managed config) so the harness starts clean."
|
||||
echo " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
|
||||
echo ""
|
||||
@@ -39,9 +39,8 @@ usage() {
|
||||
echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount."
|
||||
echo ""
|
||||
echo "Mount behaviour:"
|
||||
echo " (default) Clear mount dir completely, then sync. Use for a clean run."
|
||||
echo " --no-clear Do not clear mount dir; only sync over existing contents."
|
||||
echo " Use to preserve leases or debug files between runs."
|
||||
echo " (default) Preserve mount dir; sync source over existing contents."
|
||||
echo " --clear Clear mount dir completely before sync. Use for a clean run."
|
||||
echo ""
|
||||
echo "Compose behaviour:"
|
||||
echo " --prepare-only Only prepare the mount; do not run docker compose."
|
||||
@@ -62,19 +61,19 @@ usage() {
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0"
|
||||
echo " Full run: clear mount, sync testdata, start containers (no rebuild)."
|
||||
echo " Sync testdata to mount (preserving existing), start containers (no rebuild)."
|
||||
echo ""
|
||||
echo " $0 --build"
|
||||
echo " Clear and sync, then rebuild images and start containers."
|
||||
echo " Sync, rebuild images, and start containers."
|
||||
echo ""
|
||||
echo " $0 --recreate"
|
||||
echo " Clear and sync, then up -d --force-recreate (fresh containers)."
|
||||
echo " Sync, then up -d --force-recreate (fresh containers)."
|
||||
echo ""
|
||||
echo " $0 --no-clear"
|
||||
echo " Preserve mount contents, sync over it, start (no rebuild)."
|
||||
echo " $0 --clear"
|
||||
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
|
||||
echo ""
|
||||
echo " $0 --prepare-only"
|
||||
echo " Only clear and sync testdata -> testdata-mount; no containers."
|
||||
echo " Only sync testdata -> testdata-mount; no containers."
|
||||
echo " Then run: docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
echo ""
|
||||
echo " $0 --source myfixtures --mount mymount --prepare-only"
|
||||
@@ -106,8 +105,8 @@ while [ $# -gt 0 ]; do
|
||||
MOUNT_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
--no-clear)
|
||||
NO_CLEAR=true
|
||||
--clear)
|
||||
CLEAR=true
|
||||
shift
|
||||
;;
|
||||
--prepare-only)
|
||||
@@ -189,7 +188,7 @@ docker compose -f "$COMPOSE_FILE" down
|
||||
|
||||
mkdir -p "$MOUNT_DIR"
|
||||
|
||||
if [ "$NO_CLEAR" = false ]; then
|
||||
if [ "$CLEAR" = true ]; then
|
||||
echo "Clearing mount directory: $MOUNT_DIR"
|
||||
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -59,14 +59,17 @@ default_rid() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Optional version override (e.g. v0.0.4 from CI tag). When set, passed to dotnet publish for assembly metadata.
|
||||
VERSION_OVERRIDE=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|-?|--help)
|
||||
echo "Usage: $0 [OPTIONS] [RID]"
|
||||
echo "Usage: $0 [OPTIONS] [RID] [VERSION]"
|
||||
echo ""
|
||||
echo "Build a self-contained folder publish for Linux. If RID is omitted, the script"
|
||||
echo "auto-detects from the current OS and architecture (recommended on Ubuntu and Alpine)."
|
||||
echo "Pass options first, then RID if desired (e.g. $0 --trim linux-x64)."
|
||||
echo "Pass options first, then RID, then optional VERSION (e.g. v0.0.4 from a release tag)."
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --trim Enable trimming (smaller output; can cause 404/routing issues with Blazor)"
|
||||
@@ -85,7 +88,7 @@ while [ $# -gt 0 ]; do
|
||||
echo " ubuntu.24.04-x64 ubuntu.24.04-arm64"
|
||||
echo " ubuntu.22.04-x64 ubuntu.22.04-arm64"
|
||||
echo ""
|
||||
echo "Output: src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/"
|
||||
echo "Output: src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Publish for current machine (auto-detect RID)"
|
||||
@@ -112,6 +115,10 @@ while [ $# -gt 0 ]; do
|
||||
*)
|
||||
RID="$1"
|
||||
shift
|
||||
if [ $# -gt 0 ] && [ -n "$1" ] && [ "${1#-}" = "$1" ]; then
|
||||
VERSION_OVERRIDE="$1"
|
||||
shift
|
||||
fi
|
||||
break
|
||||
;;
|
||||
esac
|
||||
@@ -148,22 +155,32 @@ if [ "$CLEAN" = true ]; then
|
||||
dotnet clean "$PROJECT" -c Release -nologo -v q
|
||||
fi
|
||||
|
||||
VERSION_ARGS=""
|
||||
if [ -n "$VERSION_OVERRIDE" ]; then
|
||||
# Strip leading 'v' (e.g. v0.0.4 -> 0.0.4) for assembly metadata
|
||||
VER="${VERSION_OVERRIDE#v}"
|
||||
VERSION_ARGS="-p:Version=$VER -p:InformationalVersion=$VER"
|
||||
echo "Using version: $VER (from $VERSION_OVERRIDE)"
|
||||
fi
|
||||
|
||||
if [ "$TRIM" = true ]; then
|
||||
echo "Publishing self-contained (trimmed, folder) for $RID..."
|
||||
dotnet publish "$PROJECT" \
|
||||
-c Release \
|
||||
-r "$RID" \
|
||||
--self-contained true \
|
||||
-p:PublishTrimmed=true
|
||||
-p:PublishTrimmed=true \
|
||||
$VERSION_ARGS
|
||||
else
|
||||
echo "Publishing self-contained (no trim, folder) for $RID..."
|
||||
dotnet publish "$PROJECT" \
|
||||
-c Release \
|
||||
-r "$RID" \
|
||||
--self-contained true
|
||||
--self-contained true \
|
||||
$VERSION_ARGS
|
||||
fi
|
||||
|
||||
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net9.0/$RID/publish"
|
||||
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish"
|
||||
echo ""
|
||||
echo "Done. Output: $OUT_DIR"
|
||||
echo "Run on this host: $OUT_DIR/DnsmasqWebUI"
|
||||
|
||||
@@ -17,25 +17,30 @@ public class ConfigValueSourceTests
|
||||
[Fact]
|
||||
public void GetReadOnlyTooltip_WhenReadOnlyWithoutLineNumber_ReturnsFileNameOnly()
|
||||
{
|
||||
var source = new ConfigValueSource("/etc/dnsmasq.d/02.conf", "02.conf", IsManaged: false, LineNumber: null);
|
||||
var fileName = "02.conf";
|
||||
var fullPath = $"/etc/dnsmasq.d/{fileName}";
|
||||
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: null);
|
||||
var tooltip = source.GetReadOnlyTooltip();
|
||||
Assert.NotNull(tooltip);
|
||||
Assert.Contains("02.conf", tooltip);
|
||||
Assert.Contains(fileName, tooltip);
|
||||
Assert.Contains("readonly", tooltip);
|
||||
Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip);
|
||||
Assert.Contains(fullPath, tooltip);
|
||||
Assert.DoesNotContain("line", tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetReadOnlyTooltip_WhenReadOnlyWithLineNumber_IncludesLineNumber()
|
||||
{
|
||||
var source = new ConfigValueSource("/etc/dnsmasq.d/02.conf", "02.conf", IsManaged: false, LineNumber: 3);
|
||||
var fileName = "02.conf";
|
||||
var fullPath = $"/etc/dnsmasq.d/{fileName}";
|
||||
int? lineNumber = 3;
|
||||
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: lineNumber);
|
||||
var tooltip = source.GetReadOnlyTooltip();
|
||||
Assert.NotNull(tooltip);
|
||||
Assert.Contains("02.conf", tooltip);
|
||||
Assert.Contains("line 3", tooltip);
|
||||
Assert.Contains(fileName, tooltip);
|
||||
Assert.Contains($"line {lineNumber}", tooltip);
|
||||
Assert.Contains("readonly", tooltip);
|
||||
Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip);
|
||||
Assert.Contains(fullPath, tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -20,136 +20,219 @@ public class DnsmasqConfDhcpHostLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_BasicMacIpNameLease_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.False(e!.IsComment);
|
||||
Assert.False(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", e.MacAddresses[0]);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal("testpc", e.Name);
|
||||
Assert.Equal("infinite", e.Lease);
|
||||
Assert.Equal(mac, e.MacAddresses[0]);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Equal(lease, e.Lease);
|
||||
Assert.Empty(e.Extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_CommentedLine_ParsesWithIsComment()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
|
||||
var line = $"#dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.False(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DeletedLine_ParsesWithIsDeleted()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "oldpc";
|
||||
|
||||
var line = $"##dhcp-host={mac},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.True(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TrailingComment_ParsesComment()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop", 1);
|
||||
string mac = "11:22:33:44:55:66";
|
||||
string address = "192.168.1.11";
|
||||
string name = "laptop";
|
||||
string lease = "infinite";
|
||||
string comment = "main laptop";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease} # {comment}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("main laptop", e!.Comment);
|
||||
Assert.Equal(comment, e!.Comment);
|
||||
Assert.Single(e.MacAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_MultipleMacs_SameIp_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2", 1);
|
||||
string mac1 = "11:22:33:44:55:66";
|
||||
string mac2 = "12:34:56:78:90:12";
|
||||
string address = "192.168.0.2";
|
||||
|
||||
var line = $"dhcp-host={mac1},{mac2},{address}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(2, e!.MacAddresses.Count);
|
||||
Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]);
|
||||
Assert.Equal("12:34:56:78:90:12", e.MacAddresses[1]);
|
||||
Assert.Equal("192.168.0.2", e.Address);
|
||||
Assert.Equal(mac1, e.MacAddresses[0]);
|
||||
Assert.Equal(mac2, e.MacAddresses[1]);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_WithSetTag_PutsInExtra()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=AA:BB:CC:DD:CC:BB,redhost1,192.168.1.41,infinite,set:red", 1);
|
||||
string mac = "AA:BB:CC:DD:CC:BB";
|
||||
string name = "redhost1";
|
||||
string address = "192.168.1.41";
|
||||
string lease = "infinite";
|
||||
string extraTag = "set:red";
|
||||
|
||||
var line = $"dhcp-host={mac},{name},{address},{lease},{extraTag}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.41", e!.Address);
|
||||
Assert.Equal("redhost1", e.Name);
|
||||
Assert.Contains(e.Extra, x => x == "set:red");
|
||||
Assert.Equal(address, e!.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Contains(e.Extra, x => x == extraTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_HostnameOnly_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=lap,192.168.0.199", 1);
|
||||
string name = "lap";
|
||||
string address = "192.168.0.199";
|
||||
|
||||
var line = $"dhcp-host={name},{address}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("lap", e!.Name);
|
||||
Assert.Equal("192.168.0.199", e.Address);
|
||||
Assert.Equal(name, e!.Name);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Empty(e.MacAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Ignore_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=00:20:e0:3b:13:af,ignore", 1);
|
||||
string mac = "00:20:e0:3b:13:af";
|
||||
|
||||
var line = $"dhcp-host={mac},ignore";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.Ignore);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("00:20:e0:3b:13:af", e.MacAddresses[0]);
|
||||
Assert.Equal(mac, e.MacAddresses[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_NumericLease_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,pc,3600", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "pc";
|
||||
string lease = "3600";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("3600", e!.Lease);
|
||||
Assert.Equal(lease, e!.Lease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_WithIdClient_PutsInExtra()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=id:01:02:03:04,192.168.1.50,myhost", 1);
|
||||
string idPart = "id:01:02:03:04";
|
||||
string address = "192.168.1.50";
|
||||
string name = "myhost";
|
||||
|
||||
var line = $"dhcp-host={idPart},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal("192.168.1.50", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Roundtrip_Basic()
|
||||
{
|
||||
var line = "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite";
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("dhcp-host=", back);
|
||||
Assert.Contains("aa:bb:cc:dd:ee:ff", back);
|
||||
Assert.Contains("192.168.1.10", back);
|
||||
Assert.Contains("testpc", back);
|
||||
Assert.Contains("infinite", back);
|
||||
Assert.Contains(mac, back);
|
||||
Assert.Contains(address, back);
|
||||
Assert.Contains(name, back);
|
||||
Assert.Contains(lease, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Roundtrip_WithComment()
|
||||
{
|
||||
var line = "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop";
|
||||
string mac = "11:22:33:44:55:66";
|
||||
string address = "192.168.1.11";
|
||||
string name = "laptop";
|
||||
string lease = "infinite";
|
||||
string comment = "main laptop";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease} # {comment}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("dhcp-host=", back);
|
||||
Assert.Contains("main laptop", back);
|
||||
Assert.Contains(comment, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_CommentedEntry_Prefix()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
|
||||
var line = $"#dhcp-host={mac},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("#dhcp-host=", back);
|
||||
@@ -158,7 +241,13 @@ public class DnsmasqConfDhcpHostLineParserTests
|
||||
[Fact]
|
||||
public void ToLine_DeletedEntry_DoubleHash()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,old", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "old";
|
||||
|
||||
var line = $"##dhcp-host={mac},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("##dhcp-host=", back);
|
||||
|
||||
@@ -26,10 +26,12 @@ public class DnsmasqConfDirectiveParserTests
|
||||
[Fact]
|
||||
public void TryParseKeyValue_KeyValue_ReturnsKeyAndValue()
|
||||
{
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53");
|
||||
var value = "53";
|
||||
var line = $"port={value}";
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
|
||||
Assert.Equal("53", kv.Value.value);
|
||||
Assert.Equal(value, kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -50,16 +52,42 @@ public class DnsmasqConfDirectiveParserTests
|
||||
[Fact]
|
||||
public void StripComment_CommentAfterSpace_StripsToEnd()
|
||||
{
|
||||
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # DNS port"));
|
||||
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # comment"));
|
||||
var before = "port=53";
|
||||
var comment1 = " DNS port";
|
||||
var comment2 = " comment";
|
||||
Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment1));
|
||||
Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_LineWithComment_ValueExcludesComment()
|
||||
{
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53 # DNS port");
|
||||
var value = "53";
|
||||
var comment = " DNS port";
|
||||
var line = $"port={value} #{comment}";
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
|
||||
Assert.Equal("53", kv.Value.value);
|
||||
Assert.Equal(value, kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_ValueContainsEquals_ParsesFirstEqualsAsKeyValueSeparator()
|
||||
{
|
||||
var line = "server=/local/1.2.3.4";
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal("server", kv!.Value.key);
|
||||
Assert.Equal("/local/1.2.3.4", kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_ErrorOverload_InvalidLine_ReturnsFalseAndSetsError()
|
||||
{
|
||||
// KeyValueLine requires at least one key character before '='; "=no-key" has no key and fails.
|
||||
var ok = DnsmasqConfDirectiveParser.TryParseKeyValue("=no-key", out var kv, out var error, out var errorPosition);
|
||||
Assert.False(ok);
|
||||
Assert.NotNull(error);
|
||||
Assert.True(errorPosition.Absolute >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,73 +19,89 @@ public class DnsmasqConfFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseFile_BlankLine_BlankKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([""]);
|
||||
var raw = "";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
|
||||
Assert.Single(lines);
|
||||
var blank = Assert.IsType<BlankLine>(lines[0]);
|
||||
Assert.Equal(1, blank.LineNumber);
|
||||
Assert.Equal("", blank.RawLine);
|
||||
Assert.Equal(raw, blank.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_BlankLineWithSpaces_BlankKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([" \t "]);
|
||||
var raw = " \t ";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
|
||||
Assert.Single(lines);
|
||||
var blank = Assert.IsType<BlankLine>(lines[0]);
|
||||
Assert.Equal(" \t ", blank.RawLine);
|
||||
Assert.Equal(raw, blank.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_CommentLine_CommentKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["# Not managed by this app."]);
|
||||
var commentLine = "# Not managed by this app.";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([commentLine]);
|
||||
Assert.Single(lines);
|
||||
var comment = Assert.IsType<CommentLine>(lines[0]);
|
||||
Assert.Equal(1, comment.LineNumber);
|
||||
Assert.Equal("# Not managed by this app.", comment.RawLine);
|
||||
Assert.Equal(commentLine, comment.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_OtherDirective_OtherKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["domain=local"]);
|
||||
var raw = "domain=local";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
|
||||
Assert.Single(lines);
|
||||
var other = Assert.IsType<OtherLine>(lines[0]);
|
||||
Assert.Equal("domain=local", other.RawLine);
|
||||
Assert.Equal(raw, other.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_AddnHostsLine_AddnHostsKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["addn-hosts=/var/lib/dnsmasq/hosts"]);
|
||||
var path = "/var/lib/dnsmasq/hosts";
|
||||
var line = $"addn-hosts={path}";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
|
||||
Assert.Single(lines);
|
||||
var addn = Assert.IsType<AddnHostsLine>(lines[0]);
|
||||
Assert.Equal("/var/lib/dnsmasq/hosts", addn.AddnHostsPath);
|
||||
Assert.Equal(path, addn.AddnHostsPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_AddnHosts_EmitsAddnHostsLine()
|
||||
{
|
||||
var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = "/etc/hosts" };
|
||||
Assert.Equal("addn-hosts=/etc/hosts", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var path = "/etc/hosts";
|
||||
var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = path };
|
||||
Assert.Equal($"addn-hosts={path}", DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_DhcpHostLine_DhcpHostKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"]);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
|
||||
Assert.Single(lines);
|
||||
var dhcpLine = Assert.IsType<DhcpHostLine>(lines[0]);
|
||||
Assert.Single(dhcpLine.DhcpHost.MacAddresses);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]);
|
||||
Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address);
|
||||
Assert.Equal(mac, dhcpLine.DhcpHost.MacAddresses[0]);
|
||||
Assert.Equal(address, dhcpLine.DhcpHost.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_CommentedDhcpHost_TreatedAsComment()
|
||||
{
|
||||
// Parser treats any line starting with # as Comment (does not parse dhcp-host content)
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc"]);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "oldpc";
|
||||
var line = $"#dhcp-host={mac},{address},{name}";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
|
||||
Assert.Single(lines);
|
||||
Assert.Equal(DnsmasqConfLineKind.Comment, lines[0].Kind);
|
||||
}
|
||||
@@ -93,13 +109,21 @@ public class DnsmasqConfFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseFile_MixedFile_ParsesAll()
|
||||
{
|
||||
var domainLine = "domain=local";
|
||||
string dhcpMac = "11:22:33:44:55:66";
|
||||
string dhcpAddress = "192.168.1.11";
|
||||
string dhcpName = "laptop";
|
||||
string dhcpLease = "infinite";
|
||||
var rangeLine = "dhcp-range=192.168.1.100,192.168.1.200";
|
||||
var dhcpLine = $"dhcp-host={dhcpMac},{dhcpAddress},{dhcpName},{dhcpLease}";
|
||||
|
||||
var input = new[]
|
||||
{
|
||||
"# Sample config",
|
||||
"",
|
||||
"domain=local",
|
||||
"dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite",
|
||||
"dhcp-range=192.168.1.100,192.168.1.200"
|
||||
domainLine,
|
||||
dhcpLine,
|
||||
rangeLine
|
||||
};
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(input);
|
||||
Assert.Equal(5, lines.Count);
|
||||
@@ -107,48 +131,55 @@ public class DnsmasqConfFileLineParserTests
|
||||
Assert.IsType<CommentLine>(lines[0]);
|
||||
Assert.IsType<BlankLine>(lines[1]);
|
||||
var other2 = Assert.IsType<OtherLine>(lines[2]);
|
||||
Assert.Equal("domain=local", other2.RawLine);
|
||||
Assert.Equal(domainLine, other2.RawLine);
|
||||
var dhcp3 = Assert.IsType<DhcpHostLine>(lines[3]);
|
||||
Assert.Equal("192.168.1.11", dhcp3.DhcpHost.Address);
|
||||
Assert.Equal(dhcpAddress, dhcp3.DhcpHost.Address);
|
||||
var other4 = Assert.IsType<OtherLine>(lines[4]);
|
||||
Assert.Equal("dhcp-range=192.168.1.100,192.168.1.200", other4.RawLine);
|
||||
Assert.Equal(rangeLine, other4.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Blank_PreservesRawLine()
|
||||
{
|
||||
var line = new BlankLine { LineNumber = 1, RawLine = " " };
|
||||
Assert.Equal(" ", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var raw = " ";
|
||||
var line = new BlankLine { LineNumber = 1, RawLine = raw };
|
||||
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Comment_PreservesRawLine()
|
||||
{
|
||||
var line = new CommentLine { LineNumber = 1, RawLine = "# comment" };
|
||||
Assert.Equal("# comment", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var raw = "# comment";
|
||||
var line = new CommentLine { LineNumber = 1, RawLine = raw };
|
||||
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Other_PreservesRawLine()
|
||||
{
|
||||
var line = new OtherLine { LineNumber = 1, RawLine = "domain=local" };
|
||||
Assert.Equal("domain=local", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var raw = "domain=local";
|
||||
var line = new OtherLine { LineNumber = 1, RawLine = raw };
|
||||
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_DhcpHost_SerializesViaDhcpHostLineParser()
|
||||
{
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
var dhcp = new DhcpHostEntry
|
||||
{
|
||||
LineNumber = 1,
|
||||
MacAddresses = ["aa:bb:cc:dd:ee:ff"],
|
||||
Address = "192.168.1.10",
|
||||
Name = "testpc",
|
||||
Lease = "infinite"
|
||||
MacAddresses = [mac],
|
||||
Address = address,
|
||||
Name = name,
|
||||
Lease = lease
|
||||
};
|
||||
var line = new DhcpHostLine { LineNumber = 1, DhcpHost = dhcp };
|
||||
var back = DnsmasqConfFileLineParser.ToLine(line);
|
||||
Assert.Equal("dhcp-host=aa:bb:cc:dd:ee:ff, testpc, 192.168.1.10, infinite", back);
|
||||
Assert.Equal($"dhcp-host={mac}, {name}, {address}, {lease}", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Parser semantics: flags (option present/absent/with value), last-wins, case-sensitive, path order.</summary>
|
||||
public class DnsmasqConfIncludeParserCoreBehaviourTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.True(result);
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "port=53\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var firstVal = "100";
|
||||
var secondVal = "200";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"cache-size={firstVal}\n");
|
||||
File.WriteAllText(f2, $"cache-size={secondVal}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal(secondVal, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var logPath = "/data/dnsmasq.log";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"log-facility={logPath}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal(logPath, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var firstLogPath = "/var/log/dnsmasq.log";
|
||||
var secondLogPath = "/data/dnsmasq.log";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"log-facility={firstLogPath}\n");
|
||||
File.WriteAllText(f2, $"log-facility={secondLogPath}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal(secondLogPath, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Relative_ResolvesAgainstDir()
|
||||
{
|
||||
var dir = Path.GetTempPath();
|
||||
var subDirName = "sub";
|
||||
var fileName = "pid.pid";
|
||||
var relativePath = $"{subDirName}/{fileName}";
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(relativePath, dir);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subDirName, fileName)), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Absolute_ReturnsAsIs()
|
||||
{
|
||||
var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid");
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir");
|
||||
Assert.Equal(Path.GetFullPath(absPath), result);
|
||||
}
|
||||
|
||||
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
|
||||
|
||||
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var invalidValue = "1";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"expand-hosts={invalidValue}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var wrongKey = "Port";
|
||||
var value = "53";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"{wrongKey}={value}\n");
|
||||
var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
|
||||
Assert.Null(parsedValue);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
var port1 = "53";
|
||||
var port2 = "5353";
|
||||
var port3 = "54";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n");
|
||||
File.WriteAllText(extra, $"port={port3}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.Equal(port3, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- pathToLines overloads: same result as file-based when content is pre-read ---
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var lines = new[] { "expand-hosts", "no-resolv" };
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, string.Join("\n", lines) + "\n");
|
||||
var paths = new[] { conf };
|
||||
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[Path.GetFullPath(conf)] = lines
|
||||
};
|
||||
var fromFile = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts);
|
||||
var fromDict = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.Equal(fromFile, fromDict);
|
||||
Assert.True(fromDict);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-last-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var value = "1000";
|
||||
var lines = new[] { $"cache-size={value}" };
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, lines[0] + "\n");
|
||||
var paths = new[] { conf };
|
||||
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[Path.GetFullPath(conf)] = lines
|
||||
};
|
||||
var (fromFileVal, fromFileDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
|
||||
var (fromDictVal, fromDictDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal(fromFileVal, fromDictVal);
|
||||
Assert.Equal(value, fromDictVal);
|
||||
Assert.Equal(fromFileDir, fromDictDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_WithPathToLines_ReturnsSameResultAsFileBased()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptl-multi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var lines = new[] { "server=1.1.1.1", "server=8.8.8.8" };
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, string.Join("\n", lines) + "\n");
|
||||
var paths = new[] { conf };
|
||||
var pathToLines = new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[Path.GetFullPath(conf)] = lines
|
||||
};
|
||||
var fromFile = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var fromDict = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Server);
|
||||
Assert.Equal(fromFile, fromDict);
|
||||
Assert.Equal(2, fromDict.Count);
|
||||
Assert.Equal("1.1.1.1", fromDict[0]);
|
||||
Assert.Equal("8.8.8.8", fromDict[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Empty path list ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EmptyPathList_ReturnsNullAndNull()
|
||||
{
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(Array.Empty<string>(), DnsmasqConfKeys.Port);
|
||||
Assert.Null(value);
|
||||
Assert.Null(configDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_EmptyPathList_ReturnsEmptyList()
|
||||
{
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(Array.Empty<string>(), DnsmasqConfKeys.Server);
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
// --- Non-existent file in path list: skipped without throw, last-wins from existing ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_NonExistentFileInList_SkipsMissingFileLastWinsFromExisting()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-missing-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var existing = Path.Combine(dir, "existing.conf");
|
||||
var missing = Path.Combine(dir, "missing.conf");
|
||||
var other = Path.Combine(dir, "other.conf");
|
||||
var valueInExisting = "100";
|
||||
var valueInOther = "200";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(existing, $"port={valueInExisting}\n");
|
||||
File.WriteAllText(other, $"port={valueInOther}\n");
|
||||
Assert.False(File.Exists(missing));
|
||||
var paths = new[] { existing, missing, other };
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.Equal(valueInOther, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>DHCP advanced, TFTP/PXE, DNSSEC, enable-dbus/ubus, fast-dns-retry.</summary>
|
||||
public class DnsmasqConfIncludeParserDhcpTftpDnssecTests
|
||||
{
|
||||
// --- One test per option: key wired, when set returns value(s) ---
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpMatch_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpmatch-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:bios,option:client-arch,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-match={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpMatch);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpBoot_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpboot-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "tag:bios-x86,firmware/ipxe.pxe,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-boot={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpBoot);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpIgnore_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpignore-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "tag:!known";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-ignore={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpIgnore);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpVendorclass_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpvc-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:pxe,option:vendor-class-identifier,PXEClient";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-vendorclass={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpVendorclass);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpUserclass_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpuc-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:ipxe,iPXE";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-userclass={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpUserclass);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_RaParam_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-raparam-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "eth0,high,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ra-param={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RaParam);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Slaac_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-slaac-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "eth0,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"slaac={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Slaac);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_EnableTftp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftp-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "enable-tftp\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableTftp);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_TftpRoot_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftproot-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/var/lib/tftpboot";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"tftp-root={path}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TftpRoot);
|
||||
Assert.Equal(path, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_TftpSecure_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-tftpsec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "tftp-secure\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TftpSecure);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_PxeService_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pxe-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "x86PC,pxelinux.0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"pxe-service={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PxeService);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_PxePrompt_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pxeprompt-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "timeout,0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"pxe-prompt={line}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PxePrompt);
|
||||
Assert.Equal(line, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Dnssec_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnssec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dnssec\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Dnssec);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_TrustAnchor_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-trust-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = ". IN DS 19036 8 2 49AAC11D7B6F6446702E54A1607371607A1A41855200FD2CE1CDE32F024E8FC5";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"trust-anchor={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TrustAnchor);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DnssecCheckUnsigned_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dnsseccu-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dnssec-check-unsigned\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EnableDbus_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dbus-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var svc = "org.freedesktop.NetworkManager";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"enable-dbus={svc}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableDbus);
|
||||
Assert.Equal(svc, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EnableUbus_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ubus-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var svc = "com.example.dnsmasq";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"enable-ubus={svc}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EnableUbus);
|
||||
Assert.Equal(svc, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_FastDnsRetry_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-fastretry-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "500,5000";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"fast-dns-retry={line}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FastDnsRetry);
|
||||
Assert.Equal(line, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Boolean flags: no-hosts, bogus-priv, strict-order, domain-needed, no-poll, bind-*, rebind-*, clear-on-reload, filter*, localise-queries, log-debug, dhcp-authoritative, leasefile-ro.</summary>
|
||||
public class DnsmasqConfIncludeParserFlagsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_BogusPriv_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-boguspriv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "bogus-priv\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BogusPriv));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_StrictOrder_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-strict-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "strict-order\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.StrictOrder));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DomainNeeded_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-domainneed-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "domain-needed\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DomainNeeded));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoPoll_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nopoll-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-poll\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoPoll));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_BindInterfaces_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-bindif-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "bind-interfaces\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BindInterfaces));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_BindDynamic_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-binddyn-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "bind-dynamic\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BindDynamic));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoNegcache_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noneg-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-negcache\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoNegcache));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DnsLoopDetect_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-loop-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dns-loop-detect\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DnsLoopDetect));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_StopDnsRebind_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebind-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "stop-dns-rebind\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.StopDnsRebind));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_RebindLocalhostOk_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebindlo-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "rebind-localhost-ok\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RebindLocalhostOk));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ClearOnReload_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-clear-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "clear-on-reload\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ClearOnReload));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Filterwin2k_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filtw2k-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "filterwin2k\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Filterwin2k));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FilterA_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filta-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "filter-A\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterA));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FilterAaaa_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filtaaaa-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "filter-AAAA\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterAaaa));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LocaliseQueries_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localise-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "localise-queries\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocaliseQueries));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LogDebug_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logdbg-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "log-debug\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogDebug));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpAuthoritative_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpauth-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-authoritative\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpAuthoritative));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Hosts (addn-hosts, hostsdir) and DHCP lease file parsing.</summary>
|
||||
public class DnsmasqConfIncludeParserHostsAndLeaseTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
|
||||
var paths = new[] { mainPath };
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.GetFullPath("/data/leases"), result);
|
||||
}
|
||||
|
||||
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
|
||||
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
|
||||
Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines");
|
||||
Assert.Contains("1.1.1.1", servers);
|
||||
Assert.Contains("8.8.8.8", servers);
|
||||
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
|
||||
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
|
||||
Assert.Single(listenAddrs);
|
||||
Assert.Equal("172.28.0.2", listenAddrs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var firstLeasePath = "/var/first.leases";
|
||||
var secondLeasePath = "/var/second.leases";
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, $"dhcp-leasefile={firstLeasePath}\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(Path.GetFullPath(secondLeasePath), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var subdir = "subdir";
|
||||
var leasesFile = "leases";
|
||||
var relativePath = $"{subdir}/{leasesFile}";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"dhcp-leasefile={relativePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty()
|
||||
{
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty<string>());
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var path1 = "/etc/hosts.d/first";
|
||||
var path2 = "/data/hosts";
|
||||
var path3 = "/data/extra.hosts";
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, $"addn-hosts={path1}\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, $"addn-hosts={path2}\naddn-hosts={path3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath(path1), result[0]);
|
||||
Assert.Equal(Path.GetFullPath(path2), result[1]);
|
||||
Assert.Equal(Path.GetFullPath(path3), result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var pathPart1 = "hosts.d";
|
||||
var pathPart2 = "app.hosts";
|
||||
var addnRelativePath = $"{pathPart1}/{pathPart2}";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, $"addn-hosts={addnRelativePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
|
||||
Assert.Single(result);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, pathPart1, pathPart2)), result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var managedPath = Path.Combine(dir, "managed.conf");
|
||||
var path1 = "/etc/hosts.a";
|
||||
var path2 = "/etc/hosts.b";
|
||||
var path3 = "/etc/hosts.c";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"addn-hosts={path1}\n");
|
||||
File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath(path1), result[0].Path);
|
||||
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath(path2), result[1].Path);
|
||||
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath(path3), result[2].Path);
|
||||
Assert.Equal(2, result[2].Source.LineNumber);
|
||||
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedPath = Path.Combine(dir, "zz-managed.conf");
|
||||
var readOnlyPath = "/other/hosts";
|
||||
var managedAddnPath = "/managed/hosts";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n");
|
||||
File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.True(result[1].Source.IsManaged);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Config discovery: conf-file=, conf-dir=, GetIncludedPaths, GetFirstConfDir, GetIncludedPathsWithSource, conf-dir suffix.</summary>
|
||||
public class DnsmasqConfIncludeParserIncludeTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath()
|
||||
{
|
||||
var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainOnly_ReturnsSinglePath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var includeFileName = "extra.conf";
|
||||
var includePath = Path.Combine(dir, includeFileName);
|
||||
File.WriteAllText(includePath, "# extra\n");
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-file={includeFileName}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(includePath), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDirName = "d";
|
||||
var subDir = Path.Combine(baseDir, confDirName);
|
||||
Directory.CreateDirectory(subDir);
|
||||
var file1 = "aa.conf";
|
||||
var file2 = "zz.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, file2), "");
|
||||
File.WriteAllText(Path.Combine(subDir, file1), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-dir={confDirName}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_NoConfDir_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\nconf-file=other.conf\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.Null(first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDirName = "dnsmasq.d";
|
||||
var subDir = Path.Combine(baseDir, confDirName);
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, $"conf-dir={confDirName}\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(Path.GetFullPath(subDir), first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_CommentsAndBlanks_Ignored()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
File.WriteAllText(extra, "");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(extra), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDirMissing_ReturnsMainOnly()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-missing-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=nonexistent-subdir\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirrors harness layout: main + conf-dir with multiple files (including lan-dns.conf) + conf-file. Asserts all appear in load order with correct sources.</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPathsWithSource_ConfDirAndConfFile_ReturnsAllFilesInLoadOrderWithSources()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-harness-layout-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDir = Path.Combine(baseDir, "dnsmasq.d");
|
||||
Directory.CreateDirectory(confDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(confDir, "01-other.conf"), "# other\n");
|
||||
File.WriteAllText(Path.Combine(confDir, "02-servers.conf"), "# servers\n");
|
||||
File.WriteAllText(Path.Combine(confDir, "lan-dns.conf"), "# lan dns\n");
|
||||
File.WriteAllText(Path.Combine(baseDir, "zz-dnsmasq-webui.conf"), "# managed\n");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=dnsmasq.d\nconf-file=zz-dnsmasq-webui.conf\n");
|
||||
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(main);
|
||||
Assert.Equal(5, withSource.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), withSource[0].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.Main, withSource[0].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "01-other.conf")), withSource[1].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[1].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "02-servers.conf")), withSource[2].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[2].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "lan-dns.conf")), withSource[3].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[3].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "zz-dnsmasq-webui.conf")), withSource[4].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfFile, withSource[4].Source);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
var fileConf1 = "a.conf";
|
||||
var fileTxt = "b.txt";
|
||||
var fileConf2 = "c.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, fileConf1), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileTxt), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileConf2), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,*.conf\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal));
|
||||
Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
var fileIncluded = "a.conf";
|
||||
var fileExcluded = "b.conf.bak";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, fileIncluded), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileExcluded), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,.bak\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Direct test for EnumerateConfigLinesInDnsmasqOrder: main has line1, conf-file=extra, line2; extra has extra1. Asserts interleaving order.</summary>
|
||||
[Fact]
|
||||
public void EnumerateConfigLinesInDnsmasqOrder_ConfFileInMiddle_InterleavesMainThenIncludedThenMain()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-enum-order-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(main, "line1\nconf-file=extra.conf\nline2\n");
|
||||
File.WriteAllText(extra, "extra1\n");
|
||||
var mainFull = Path.GetFullPath(main);
|
||||
var extraFull = Path.GetFullPath(extra);
|
||||
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
|
||||
var seq = DnsmasqConfIncludeParser.EnumerateConfigLinesInDnsmasqOrder(mainFull, mainDir, DnsmasqConfFileSource.Main, null).ToList();
|
||||
Assert.Equal(5, seq.Count);
|
||||
Assert.Equal((mainFull, 0, "", DnsmasqConfFileSource.Main), seq[0]);
|
||||
Assert.Equal((mainFull, 1, "line1", DnsmasqConfFileSource.Main), seq[1]);
|
||||
Assert.Equal((extraFull, 0, "", DnsmasqConfFileSource.ConfFile), seq[2]);
|
||||
Assert.Equal((extraFull, 1, "extra1", DnsmasqConfFileSource.ConfFile), seq[3]);
|
||||
Assert.Equal((mainFull, 3, "line2", DnsmasqConfFileSource.Main), seq[4]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Interfaces, auth-server, DHCP/option/resolv multi-value, rebind/bogus/ignore/alias, filter-rr, cache-rr.</summary>
|
||||
public class DnsmasqConfIncludeParserInterfacesAndFilteringTests
|
||||
{
|
||||
// --- Additional tests: one per conf field so every option has a documented example test ---
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Interface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-if-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "interface=eth0\ninterface=wlan0\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Interface);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("eth0", result[0]);
|
||||
Assert.Equal("wlan0", result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ExceptInterface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-except-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "except-interface=eth1\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExceptInterface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth1", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NoDhcpInterface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcpi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-dhcp-interface=eth2\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpInterface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth2", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NoDhcpv4Interface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcp4-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-dhcpv4-interface=eth0\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpv4Interface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth0", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NoDhcpv6Interface_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nodhcp6-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-dhcpv6-interface=eth0\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoDhcpv6Interface);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("eth0", result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_AuthServer_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-auth-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "lan,eth0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"auth-server={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AuthServer);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpHost_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcphost-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "00:11:22:33:44:55,192.168.1.100,router,12h";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpHost);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpOption_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpopt-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "option:router,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-option={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOption);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ResolvFile_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-resolv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/resolv.dnsmasq";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"resolv-file={path}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ResolvFile);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(path, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_RebindDomainOk_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rebind-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "/local.lan/";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"rebind-domain-ok={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RebindDomainOk);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_BogusNxdomain_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-bogusnx-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "64.94.110.11";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"bogus-nxdomain={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.BogusNxdomain);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_IgnoreAddress_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignoreaddr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "192.168.1.0/24";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ignore-address={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.IgnoreAddress);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Alias_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-alias-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "1.2.3.0,6.7.8.0,255.255.255.0";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"alias={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Alias);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_FilterRr_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-filterrr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "TXT,MX";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"filter-rr={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.FilterRr);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_CacheRr_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cacherr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "TXT";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"cache-rr={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.CacheRr);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_NoHosts_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-nohosts-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "no-hosts\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
// --- Flags (8): key-only line → GetFlag returns true ---
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_ReadEthers_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.ReadEthers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_DhcpRapidCommit_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.DhcpRapidCommit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_TftpNoFail_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.TftpNoFail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_TftpNoBlocksize_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.TftpNoBlocksize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Localmx_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.Localmx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_Selfmx_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.Selfmx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_EnableRa_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
WriteFlagAndAssertTrue(DnsmasqConfKeys.EnableRa);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LogDhcp_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
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]
|
||||
public void GetMultiValueFromConfigFiles_DhcpOptionForce_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "208,f1:00:74:7e";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-option-force={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpOptionForce);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Ipset_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "/example.com/vpn,search";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ipset={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Ipset);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Nftset_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "/example.com/ip#test#vpn";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"nftset={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Nftset);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpMac_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:red,00:60:8C:*:*:*";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-mac={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpMac);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpNameMatch_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "set:wpad-ignore,wpad";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-name-match={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpNameMatch);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpIgnoreNames_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "tag:wpad-ignore";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-ignore-names={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpIgnoreNames);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
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]
|
||||
public void GetLastValueFromConfigFiles_MxTarget_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var value = "mail.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"mx-target={value}\n");
|
||||
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MxTarget);
|
||||
Assert.Equal(value, result);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpScript_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var value = "/usr/bin/dnsmasq-script";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-script={value}\n");
|
||||
var (result, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpScript);
|
||||
Assert.Equal(value, result);
|
||||
}
|
||||
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();
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, key + "\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, key));
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
private static string CreateTempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-newopts-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the parser correctly handles the official dnsmasq example (testdata/dnsmasq.official.conf.example).
|
||||
/// All options in that file are commented out, so parsing it should yield no active values (single path, empty/false/null).
|
||||
/// </summary>
|
||||
public class DnsmasqConfIncludeParserOfficialExampleTests
|
||||
{
|
||||
private static string GetOfficialExamplePath()
|
||||
{
|
||||
var path = TestDataHelper.GetPath("dnsmasq.official.conf.example");
|
||||
Assert.True(File.Exists(path), "Testdata dnsmasq.official.conf.example required (download from https://github.com/imp/dnsmasq)");
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OfficialExample_ReturnsSinglePath()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(mainPath), paths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_OfficialExample_AllCommented_ReturnsNull()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
var (cacheVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
|
||||
var (mxTargetVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.MxTarget);
|
||||
var (dhcpScriptVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpScript);
|
||||
Assert.Null(portVal);
|
||||
Assert.Null(cacheVal);
|
||||
Assert.Null(mxTargetVal);
|
||||
Assert.Null(dhcpScriptVal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var dhcpOptionForce = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpOptionForce);
|
||||
var ipset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Ipset);
|
||||
var nftset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Nftset);
|
||||
var dhcpMac = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpMac);
|
||||
var dhcpNameMatch = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpNameMatch);
|
||||
var dhcpIgnoreNames = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpIgnoreNames);
|
||||
Assert.Empty(servers);
|
||||
Assert.Empty(dhcpOptionForce);
|
||||
Assert.Empty(ipset);
|
||||
Assert.Empty(nftset);
|
||||
Assert.Empty(dhcpMac);
|
||||
Assert.Empty(dhcpNameMatch);
|
||||
Assert.Empty(dhcpIgnoreNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OfficialExample_AllCommented_ReturnsFalse()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ReadEthers));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.DhcpRapidCommit));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.TftpNoFail));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.TftpNoBlocksize));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.Localmx));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.Selfmx));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.EnableRa));
|
||||
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.LogDhcp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OfficialExample_FileContainsExpectedOptionNames()
|
||||
{
|
||||
var path = GetOfficialExamplePath();
|
||||
var content = File.ReadAllText(path);
|
||||
// Sanity: official example documents the options we support (as commented lines).
|
||||
Assert.Contains("read-ethers", content);
|
||||
Assert.Contains("dhcp-option-force", content);
|
||||
Assert.Contains("dhcp-rapid-commit", content);
|
||||
Assert.Contains("dhcp-script", content);
|
||||
Assert.Contains("tftp-no-fail", content);
|
||||
Assert.Contains("tftp-no-blocksize", content);
|
||||
Assert.Contains("mx-target", content);
|
||||
Assert.Contains("localmx", content);
|
||||
Assert.Contains("selfmx", content);
|
||||
Assert.Contains("enable-ra", content);
|
||||
Assert.Contains("log-dhcp", content);
|
||||
Assert.Contains("ipset=", content);
|
||||
Assert.Contains("nftset=", content);
|
||||
Assert.Contains("dhcp-mac", content);
|
||||
Assert.Contains("dhcp-name-match", content);
|
||||
Assert.Contains("dhcp-ignore-names", content);
|
||||
// SRV records use option name srv-host (not "srv")
|
||||
Assert.Contains("srv-host", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
Assert.NotNull(addnHosts);
|
||||
Assert.Empty(addnHosts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNoHostsFromConfigFiles_OfficialExample_AllCommented_ReturnsFalse()
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
|
||||
Assert.False(noHosts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>DNS records and local names: hostsdir, domain, cname, mx-host, srv, ptr/txt/naptr/host-record, dynamic-host, interface-name.</summary>
|
||||
public class DnsmasqConfIncludeParserRecordsTests
|
||||
{
|
||||
// --- One test per option: key wired, when set returns value(s) ---
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_Hostsdir_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hostsdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/etc/dnsmasq.d/hosts";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"hostsdir={path}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Hostsdir);
|
||||
Assert.Equal(path, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Domain_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-domain-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "home,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"domain={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Domain);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Cname_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cname-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "mail.example.com,real.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"cname={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Cname);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_MxHost_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-mx-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "example.com,mail.example.com,50";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"mx-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MxHost);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_Srv_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-srv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "_http._tcp.example.com,server.example.com,80";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"srv-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Srv);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_PtrRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ptr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "1.168.192.in-addr.arpa,host.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"ptr-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PtrRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_TxtRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-txt-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "example.com,v=spf1 include:_spf.example.com";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"txt-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.TxtRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_NaptrRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-naptr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "example.com,100,10,u,sip+E2U,sips:.*@example.com,.";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"naptr-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NaptrRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_HostRecord_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hostrecord-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "router.example.com,192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"host-record={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.HostRecord);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DynamicHost_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dynamichost-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "hostname,example.com,192.168.1.50";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dynamic-host={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DynamicHost);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_InterfaceName_WhenSet_ReturnsValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-ifname-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var line = "eth0,router.local";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"interface-name={line}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.InterfaceName);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(line, result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Server/local, rev-server, dhcp-range, all-servers, log-queries.</summary>
|
||||
public class DnsmasqConfIncludeParserServerAndResolvTests
|
||||
{
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var v1 = "1.1.1.1";
|
||||
var v2 = "/local/";
|
||||
var v3 = "/example.com/192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
|
||||
File.WriteAllText(f2, $"server={v3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(v1, result[0]);
|
||||
Assert.Equal(v2, result[1]);
|
||||
Assert.Equal(v3, result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var range1 = "172.28.0.10,172.28.0.50,12h";
|
||||
var range2 = "192.168.1.10,192.168.1.100,255.255.255.0,24h";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"dhcp-range={range1}\ndhcp-range={range2}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(range1, result[0]);
|
||||
Assert.Equal(range2, result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_AllServers_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-allsrv-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "all-servers\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AllServers);
|
||||
Assert.True(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogQueries_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logq-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "log-queries=extra\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogQueries);
|
||||
Assert.Equal("extra", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_RevServer_ReturnsAllValues()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-rev-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var v1 = "1.2.3.0/24,192.168.0.1";
|
||||
var v2 = "10.0.0.0/8,10.0.0.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"rev-server={v1}\nrev-server={v2}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.RevServer);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(v1, result[0]);
|
||||
Assert.Equal(v2, result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
|
||||
/// <summary>Single-value options (last wins): local-ttl, pid-file, user, group, auth-ttl, edns-packet-max, query-port, port-limit, min/max-port, log-async, local-service, dhcp-lease-max, neg/max/min-cache-ttl, dhcp-ttl.</summary>
|
||||
public class DnsmasqConfIncludeParserSingleValueTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_LeasefileRo_WhenPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-leasero-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "leasefile-ro\n");
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LeasefileRo));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LocalTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "local-ttl=300\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocalTtl);
|
||||
Assert.Equal("300", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_PidFile_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-pid-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var path = "/var/run/dnsmasq.pid";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, $"pid-file={path}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PidFile);
|
||||
Assert.Equal(path, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_User_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-user-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "user=nobody\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.User);
|
||||
Assert.Equal("nobody", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_Group_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-group-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "group=dip\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Group);
|
||||
Assert.Equal("dip", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_AuthTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-authttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "auth-ttl=60\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.AuthTtl);
|
||||
Assert.Equal("60", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_EdnsPacketMax_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-edns-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "edns-packet-max=1232\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.EdnsPacketMax);
|
||||
Assert.Equal("1232", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_QueryPort_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-qport-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "query-port=0\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.QueryPort);
|
||||
Assert.Equal("0", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_PortLimit_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-portlimit-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "port-limit=3\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.PortLimit);
|
||||
Assert.Equal("3", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MinPort_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-minport-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "min-port=1024\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MinPort);
|
||||
Assert.Equal("1024", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MaxPort_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxport-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "max-port=65535\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxPort);
|
||||
Assert.Equal("65535", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogAsync_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logasync-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "log-async=25\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogAsync);
|
||||
Assert.Equal("25", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LocalService_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-localsvc-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "local-service=host\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LocalService);
|
||||
Assert.Equal("host", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpLeaseMax_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-leasemax-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-lease-max=150\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpLeaseMax);
|
||||
Assert.Equal("150", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_NegTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-negttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "neg-ttl=60\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NegTtl);
|
||||
Assert.Equal("60", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MaxTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "max-ttl=3600\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxTtl);
|
||||
Assert.Equal("3600", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MaxCacheTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-maxcttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "max-cache-ttl=86400\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MaxCacheTtl);
|
||||
Assert.Equal("86400", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_MinCacheTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-mincttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "min-cache-ttl=60\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.MinCacheTtl);
|
||||
Assert.Equal("60", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_DhcpTtl_WhenSet_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcpttl-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-ttl=0\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpTtl);
|
||||
Assert.Equal("0", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,645 +0,0 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
|
||||
namespace DnsmasqWebUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DnsmasqConfIncludeParser. Discovers conf-file= and conf-dir= from main dnsmasq config
|
||||
/// to build ordered list of included file paths and first conf-dir for managed file.
|
||||
/// </summary>
|
||||
public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath()
|
||||
{
|
||||
var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_MainOnly_ReturnsSinglePath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var includePath = Path.Combine(dir, "extra.conf");
|
||||
File.WriteAllText(includePath, "# extra\n");
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-file=extra.conf\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(includePath), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, "zz.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "aa.conf"), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "aa.conf")), paths[1]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "zz.conf")), paths[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_NoConfDir_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "domain=local\nconf-file=other.conf\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.Null(first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var subDir = Path.Combine(baseDir, "dnsmasq.d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=dnsmasq.d\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(Path.GetFullPath(subDir), first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_CommentsAndBlanks_Ignored()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
File.WriteAllText(extra, "");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(extra), paths[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDirMissing_ReturnsMainOnly()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-missing-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=nonexistent-subdir\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirrors harness layout: main + conf-dir with multiple files (including lan-dns.conf) + conf-file. Asserts all appear in load order with correct sources.</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPathsWithSource_ConfDirAndConfFile_ReturnsAllFilesInLoadOrderWithSources()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-harness-layout-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var confDir = Path.Combine(baseDir, "dnsmasq.d");
|
||||
Directory.CreateDirectory(confDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(confDir, "01-other.conf"), "# other\n");
|
||||
File.WriteAllText(Path.Combine(confDir, "02-servers.conf"), "# servers\n");
|
||||
File.WriteAllText(Path.Combine(confDir, "lan-dns.conf"), "# lan dns\n");
|
||||
File.WriteAllText(Path.Combine(baseDir, "zz-dnsmasq-webui.conf"), "# managed\n");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=dnsmasq.d\nconf-file=zz-dnsmasq-webui.conf\n");
|
||||
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(main);
|
||||
Assert.Equal(5, withSource.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), withSource[0].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.Main, withSource[0].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "01-other.conf")), withSource[1].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[1].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "02-servers.conf")), withSource[2].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[2].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(confDir, "lan-dns.conf")), withSource[3].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfDir, withSource[3].Source);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(baseDir, "zz-dnsmasq-webui.conf")), withSource[4].Path);
|
||||
Assert.Equal(DnsmasqConfFileSource.ConfFile, withSource[4].Source);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
if (!File.Exists(mainPath))
|
||||
return;
|
||||
var paths = new[] { mainPath };
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.GetFullPath("/data/leases"), result);
|
||||
}
|
||||
|
||||
/// <summary>Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata.</summary>
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts()
|
||||
{
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
if (!File.Exists(mainPath))
|
||||
return;
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
|
||||
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
|
||||
Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines");
|
||||
Assert.Contains("1.1.1.1", servers);
|
||||
Assert.Contains("8.8.8.8", servers);
|
||||
Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths");
|
||||
Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines");
|
||||
Assert.Single(listenAddrs);
|
||||
Assert.Equal("172.28.0.2", listenAddrs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, "dhcp-leasefile=/var/first.leases\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, "dhcp-leasefile=/var/second.leases\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(Path.GetFullPath("/var/second.leases"), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "dhcp-leasefile=subdir/leases\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "subdir", "leases")), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty()
|
||||
{
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty<string>());
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, "addn-hosts=/etc/hosts.d/first\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, "addn-hosts=/data/hosts\naddn-hosts=/data/extra.hosts\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.d/first"), result[0]);
|
||||
Assert.Equal(Path.GetFullPath("/data/hosts"), result[1]);
|
||||
Assert.Equal(Path.GetFullPath("/data/extra.hosts"), result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "addn-hosts=hosts.d/app.hosts\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
|
||||
Assert.Single(result);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "hosts.d", "app.hosts")), result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionPresent_ReturnsTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts\nno-resolv\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.True(result);
|
||||
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoResolv));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_OptionAbsent_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "port=53\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.NoHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-last-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, "cache-size=100\n");
|
||||
File.WriteAllText(f2, "cache-size=200\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal("200", value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "log-facility=/data/dnsmasq.log\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal("/data/dnsmasq.log", value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_LogFacility_LastWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, "log-facility=/var/log/dnsmasq.log\n");
|
||||
File.WriteAllText(f2, "log-facility=/data/dnsmasq.log\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal("/data/dnsmasq.log", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Relative_ResolvesAgainstDir()
|
||||
{
|
||||
var dir = Path.GetTempPath();
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath("sub/pid.pid", dir);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "sub", "pid.pid")), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Absolute_ReturnsAsIs()
|
||||
{
|
||||
var abs = Path.Combine(Path.GetTempPath(), "absolute.pid");
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(abs, "/some/dir");
|
||||
Assert.Equal(Path.GetFullPath(abs), result);
|
||||
}
|
||||
|
||||
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
|
||||
|
||||
/// <summary>dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it.</summary>
|
||||
[Fact]
|
||||
public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts=1\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "Port=53\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
|
||||
Assert.Null(value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54.</summary>
|
||||
[Fact]
|
||||
public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(main, "port=53\nconf-file=extra.conf\nport=5353\n");
|
||||
File.WriteAllText(extra, "port=54\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.Equal("54", value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, "a.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "b.txt"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "c.conf"), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,*.conf\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal));
|
||||
Assert.Contains(paths, p => p.EndsWith("c.conf", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith("b.txt", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix).</summary>
|
||||
[Fact]
|
||||
public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, "a.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "b.conf.bak"), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,.bak\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith("b.conf.bak", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(baseDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var managedPath = Path.Combine(dir, "managed.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, "addn-hosts=/etc/hosts.a\n");
|
||||
File.WriteAllText(f2, "addn-hosts=/etc/hosts.b\naddn-hosts=/etc/hosts.c\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.a"), result[0].Path);
|
||||
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.b"), result[1].Path);
|
||||
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.c"), result[2].Path);
|
||||
Assert.Equal(2, result[2].Source.LineNumber);
|
||||
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedPath = Path.Combine(dir, "zz-managed.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "addn-hosts=/other/hosts\n");
|
||||
File.WriteAllText(managedPath, "addn-hosts=/managed/hosts\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.True(result[1].Source.IsManaged);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, "server=1.1.1.1\nlocal=/local/\n");
|
||||
File.WriteAllText(f2, "server=/example.com/192.168.1.1\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal("1.1.1.1", result[0]);
|
||||
Assert.Equal("/local/", result[1]);
|
||||
Assert.Equal("/example.com/192.168.1.1", result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-range=172.28.0.10,172.28.0.50,12h\ndhcp-range=192.168.1.10,192.168.1.100,255.255.255.0,24h\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("172.28.0.10,172.28.0.50,12h", result[0]);
|
||||
Assert.Equal("192.168.1.10,192.168.1.100,255.255.255.0,24h", result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,10 @@ public class DnsmasqConfigSetServiceTests
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\ncache-size=500\naddn-hosts=/etc/hosts.extra\n");
|
||||
int port = 53;
|
||||
int cacheSize = 500;
|
||||
var addnHostsPath = "/etc/hosts.extra";
|
||||
File.WriteAllText(mainPath, $"port={port}\ncache-size={cacheSize}\naddn-hosts={addnHostsPath}\n");
|
||||
var options = Options.Create(new DnsmasqOptions
|
||||
{
|
||||
MainConfigPath = mainPath,
|
||||
@@ -52,10 +55,10 @@ public class DnsmasqConfigSetServiceTests
|
||||
var (config, sources) = service.GetEffectiveConfigWithSources();
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(53, config.Port);
|
||||
Assert.Equal(500, config.CacheSize);
|
||||
Assert.Equal(port, config.Port);
|
||||
Assert.Equal(cacheSize, config.CacheSize);
|
||||
Assert.Single(config.AddnHostsPaths);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), config.AddnHostsPaths[0]);
|
||||
Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
|
||||
Assert.Empty(config.ServerLocalValues);
|
||||
Assert.Empty(config.DhcpRanges);
|
||||
|
||||
@@ -64,7 +67,7 @@ public class DnsmasqConfigSetServiceTests
|
||||
Assert.Equal(Path.GetFileName(mainPath), sources.Port!.FileName);
|
||||
Assert.NotNull(sources.CacheSize);
|
||||
Assert.Single(sources.AddnHostsPaths);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), sources.AddnHostsPaths[0].Path);
|
||||
Assert.Equal(Path.GetFullPath(addnHostsPath), sources.AddnHostsPaths[0].Path);
|
||||
Assert.NotNull(sources.AddnHostsPaths[0].Source);
|
||||
Assert.Equal(Path.GetFileName(mainPath), sources.AddnHostsPaths[0].Source!.FileName);
|
||||
}
|
||||
|
||||
@@ -19,63 +19,113 @@ public class DnsmasqLeasesFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_ValidFiveFields_Parses()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05");
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(946689575, e!.Epoch);
|
||||
Assert.Equal("00:00:00:00:00:05", e.Mac);
|
||||
Assert.Equal("192.168.1.155", e.Address);
|
||||
Assert.Equal("wdt", e.Name);
|
||||
Assert.Equal("01:00:00:00:00:00:05", e.ClientId);
|
||||
Assert.Equal(epoch, e!.Epoch);
|
||||
Assert.Equal(mac, e.Mac);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Equal(clientId, e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_UnknownHostname_Asterisk()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689522 00:00:00:00:00:04 192.168.1.237 * 01:00:00:00:00:00:04");
|
||||
long epoch = 946689522;
|
||||
string mac = "00:00:00:00:00:04";
|
||||
string address = "192.168.1.237";
|
||||
string name = "*";
|
||||
string clientId = "01:00:00:00:00:00:04";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("*", e!.Name);
|
||||
Assert.Equal("192.168.1.237", e.Address);
|
||||
Assert.Equal(name, e!.Name);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_UnknownClientId_Asterisk()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689351 00:0f:b0:3a:b5:0b 192.168.1.208 colinux *");
|
||||
long epoch = 946689351;
|
||||
string mac = "00:0f:b0:3a:b5:0b";
|
||||
string address = "192.168.1.208";
|
||||
string name = "colinux";
|
||||
string clientId = "*";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("colinux", e!.Name);
|
||||
Assert.Equal("*", e.ClientId);
|
||||
Assert.Equal(name, e!.Name);
|
||||
Assert.Equal(clientId, e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_MultipleSpacesBetweenFields_Parses()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(" 946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 ");
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $" {epoch} {mac} {address} {name} {clientId} ";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(946689575, e!.Epoch);
|
||||
Assert.Equal("00:00:00:00:00:05", e.Mac);
|
||||
Assert.Equal("192.168.1.155", e.Address);
|
||||
Assert.Equal("wdt", e.Name);
|
||||
Assert.Equal("01:00:00:00:00:00:05", e.ClientId);
|
||||
Assert.Equal(epoch, e!.Epoch);
|
||||
Assert.Equal(mac, e.Mac);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Equal(clientId, e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TooFewFields_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155"));
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05"));
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine($"{epoch} {mac} {address}"));
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine($"{epoch} {mac}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TrailingJunk_ReturnsNull()
|
||||
{
|
||||
// Parser uses .End() so extra text after fifth field fails
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 extra"));
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId} extra";
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_InvalidEpoch_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("notanum 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"));
|
||||
string invalidEpoch = "notanum";
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{invalidEpoch} {mac} {address} {name} {clientId}";
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(line));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -90,9 +140,18 @@ public class DnsmasqLeasesFileLineParserTests
|
||||
[Fact]
|
||||
public void Timestamp_ConvertsEpochToDateTime()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05");
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var dt = e!.Timestamp;
|
||||
Assert.Equal(epoch, e!.Epoch);
|
||||
var dt = e.Timestamp;
|
||||
Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
@@ -11,7 +11,9 @@ public class EffectiveDnsmasqConfigTests
|
||||
new(
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: Array.Empty<string>(),
|
||||
HostsdirPath: null,
|
||||
ServerLocalValues: Array.Empty<string>(),
|
||||
RevServerValues: Array.Empty<string>(),
|
||||
AddressValues: Array.Empty<string>(),
|
||||
Interfaces: Array.Empty<string>(),
|
||||
ListenAddresses: Array.Empty<string>(),
|
||||
@@ -19,17 +21,74 @@ public class EffectiveDnsmasqConfigTests
|
||||
DhcpRanges: Array.Empty<string>(),
|
||||
DhcpHostLines: Array.Empty<string>(),
|
||||
DhcpOptionLines: Array.Empty<string>(),
|
||||
DhcpMatchValues: Array.Empty<string>(),
|
||||
DhcpBootValues: Array.Empty<string>(),
|
||||
DhcpIgnoreValues: Array.Empty<string>(),
|
||||
DhcpVendorclassValues: Array.Empty<string>(),
|
||||
DhcpUserclassValues: Array.Empty<string>(),
|
||||
RaParamValues: Array.Empty<string>(),
|
||||
SlaacValues: Array.Empty<string>(),
|
||||
PxeServiceValues: Array.Empty<string>(),
|
||||
TrustAnchorValues: Array.Empty<string>(),
|
||||
ResolvFiles: Array.Empty<string>(),
|
||||
RebindDomainOkValues: Array.Empty<string>(),
|
||||
BogusNxdomainValues: Array.Empty<string>(),
|
||||
IgnoreAddressValues: Array.Empty<string>(),
|
||||
AliasValues: Array.Empty<string>(),
|
||||
FilterRrValues: Array.Empty<string>(),
|
||||
CacheRrValues: Array.Empty<string>(),
|
||||
AuthServerValues: Array.Empty<string>(),
|
||||
NoDhcpInterfaceValues: Array.Empty<string>(),
|
||||
NoDhcpv4InterfaceValues: Array.Empty<string>(),
|
||||
NoDhcpv6InterfaceValues: Array.Empty<string>(),
|
||||
DomainValues: Array.Empty<string>(), CnameValues: Array.Empty<string>(), MxHostValues: Array.Empty<string>(), SrvValues: Array.Empty<string>(),
|
||||
PtrRecordValues: Array.Empty<string>(), TxtRecordValues: Array.Empty<string>(), NaptrRecordValues: Array.Empty<string>(),
|
||||
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,
|
||||
@@ -38,12 +97,29 @@ public class EffectiveDnsmasqConfigTests
|
||||
User: null,
|
||||
Group: null,
|
||||
LogFacility: logFacility,
|
||||
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
|
||||
DhcpTtl: null,
|
||||
TftpRootPath: null,
|
||||
PxePrompt: null,
|
||||
EnableDbus: null,
|
||||
EnableUbus: null,
|
||||
FastDnsRetry: null,
|
||||
DhcpScriptPath: null,
|
||||
MxTarget: null,
|
||||
Conntrack: null
|
||||
);
|
||||
|
||||
[Fact]
|
||||
@@ -76,35 +152,41 @@ public class EffectiveDnsmasqConfigTests
|
||||
[Fact]
|
||||
public void GetLogsPath_AbsolutePath_ReturnsPath()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "/data/dnsmasq.log");
|
||||
Assert.Equal("/data/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
var path = "/data/dnsmasq.log";
|
||||
var config = CreateConfig(logFacility: path);
|
||||
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_AbsolutePathWithTrim_ReturnsPath()
|
||||
{
|
||||
var config = CreateConfig(logFacility: " /var/log/dnsmasq.log ");
|
||||
Assert.Equal("/var/log/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
var path = "/var/log/dnsmasq.log";
|
||||
var pathWithSpaces = $" {path} ";
|
||||
var config = CreateConfig(logFacility: pathWithSpaces);
|
||||
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_RelativePathWithSlash_ReturnsPath()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "logs/dnsmasq.log");
|
||||
Assert.Equal("logs/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
var path = "logs/dnsmasq.log";
|
||||
var config = CreateConfig(logFacility: path);
|
||||
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_SyslogFacilityName_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "local0");
|
||||
var facility = "local0";
|
||||
var config = CreateConfig(logFacility: facility);
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_Stderr_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "-");
|
||||
var facility = "-";
|
||||
var config = CreateConfig(logFacility: facility);
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_WhitespaceOnly_ReturnsPassthrough()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine(" \t ", 2);
|
||||
var raw = " \t ";
|
||||
var e = HostsFileLineParser.ParseLine(raw, 2);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsPassthrough);
|
||||
}
|
||||
@@ -30,7 +31,8 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_CommentOnly_TreatsWholeLineAsComment()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("# This is a comment", 1);
|
||||
var commentLine = "# This is a comment";
|
||||
var e = HostsFileLineParser.ParseLine(commentLine, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.True(e.IsPassthrough);
|
||||
@@ -42,7 +44,10 @@ public class HostsFileLineParserTests
|
||||
public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress()
|
||||
{
|
||||
// Per hosts(5): text from # to EOL is comment - so "# 127.0.0.1 localhost" is entirely comment
|
||||
var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var line = $"# {address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.Equal("", e.Address);
|
||||
@@ -52,75 +57,99 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_IPv4Localhost_ParsesAddressAndName()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var line = $"{address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.False(e!.IsPassthrough);
|
||||
Assert.Equal("127.0.0.1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv4WithCanonicalAndAlias_ParsesAll()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("127.0.1.1 thishost.example.org thishost", 1);
|
||||
string address = "127.0.1.1";
|
||||
string canonical = "thishost.example.org";
|
||||
string alias = "thishost";
|
||||
var line = $"{address} {canonical} {alias}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("127.0.1.1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.True(e.Names.Count >= 1);
|
||||
Assert.Equal("thishost.example.org", e.Names[0]);
|
||||
Assert.Equal(canonical, e.Names[0]);
|
||||
if (e.Names.Count >= 2)
|
||||
Assert.Equal("thishost", e.Names[1]);
|
||||
Assert.Equal(alias, e.Names[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv4WithMultipleSpaces_ParsesCorrectly()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("192.168.1.10 foo.example.org foo", 1);
|
||||
string address = "192.168.1.10";
|
||||
string name1 = "foo.example.org";
|
||||
string name2 = "foo";
|
||||
var line = $"{address} {name1} {name2}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.True(e.Names.Count >= 1);
|
||||
Assert.Equal("foo.example.org", e.Names[0]);
|
||||
Assert.Equal(name1, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv6_ParsesAddressAndNames()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("::1 localhost ip6-localhost ip6-loopback", 1);
|
||||
string address = "::1";
|
||||
string name1 = "localhost";
|
||||
var line = $"{address} {name1} ip6-localhost ip6-loopback";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("::1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.True(e.Names.Count >= 1);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
Assert.Equal(name1, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv6Multicast_Parses()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("ff02::1 ip6-allnodes", 1);
|
||||
string address = "ff02::1";
|
||||
string name = "ip6-allnodes";
|
||||
var line = $"{address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("ff02::1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("ip6-allnodes", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_InlineComment_StopsAtHash()
|
||||
{
|
||||
// Token stops at #; only address and names before # are parsed
|
||||
var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost # loopback", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
string comment = "loopback";
|
||||
var line = $"{address} {name} # {comment}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("127.0.0.1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_LeadingWhitespace_TrimmedAndParsed()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine(" 192.168.1.13 bar", 1);
|
||||
string address = "192.168.1.13";
|
||||
string name = "bar";
|
||||
var line = $" {address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.13", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("bar", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -136,7 +165,8 @@ public class HostsFileLineParserTests
|
||||
public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough()
|
||||
{
|
||||
// Content requires address + whitespace + at least one name
|
||||
var e = HostsFileLineParser.ParseLine("192.168.1.1", 1);
|
||||
string address = "192.168.1.1";
|
||||
var e = HostsFileLineParser.ParseLine(address, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsPassthrough);
|
||||
}
|
||||
@@ -144,7 +174,9 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ToLine_Entry_Roundtrips()
|
||||
{
|
||||
var line = "127.0.0.1 localhost";
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var line = $"{address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
var back = HostsFileLineParser.ToLine(e!);
|
||||
@@ -154,19 +186,23 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ToLine_CommentedEntry_PrefixPreserved()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var commentLine = $"# {address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(commentLine, 1);
|
||||
Assert.NotNull(e);
|
||||
// When we parse "# ..." we get isComment=true, address="", names=[]. ToLine for passthrough returns RawLine.
|
||||
e = new HostEntry { LineNumber = 1, Address = "127.0.0.1", Names = ["localhost"], IsComment = true };
|
||||
e = new HostEntry { LineNumber = 1, Address = address, Names = [name], IsComment = true };
|
||||
var back = HostsFileLineParser.ToLine(e);
|
||||
Assert.Equal("# 127.0.0.1 localhost", back);
|
||||
Assert.Equal(commentLine, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Passthrough_ReturnsRawLine()
|
||||
{
|
||||
var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true };
|
||||
Assert.Equal(" \t ", HostsFileLineParser.ToLine(e));
|
||||
var raw = " \t ";
|
||||
var e = new HostEntry { LineNumber = 1, RawLine = raw, IsPassthrough = true };
|
||||
Assert.Equal(raw, HostsFileLineParser.ToLine(e));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@using System.Reflection
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -6,26 +7,29 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="DnsmasqWebUI.styles.css" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
|
||||
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css?v=@_cacheBust" />
|
||||
<link rel="stylesheet" href="lib/bootstrap-icons/bootstrap-icons.min.css?v=@_cacheBust" />
|
||||
<link rel="stylesheet" href="app.css?v=@_cacheBust" />
|
||||
<link rel="stylesheet" href="DnsmasqWebUI.styles.css?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png?v=@_cacheBust" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png?v=@_cacheBust" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png?v=@_cacheBust" />
|
||||
<title>@_appTitle</title>
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="js/ec-option-help.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@code {
|
||||
private static readonly string _cacheBust = typeof(App).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0";
|
||||
private string? _appTitle => AppOptions?.Value?.EffectiveTitle;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@inject ILoggingClient LoggingClient
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (IsVisible)
|
||||
{
|
||||
<dialog @ref="_dialogRef" class="settings-modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">App logs filters</h5>
|
||||
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-2">Logger categories matching these prefixes are excluded from the app logs display.</p>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control" placeholder="e.g. Microsoft.AspNetCore."
|
||||
@bind="_newPrefix" @bind:event="oninput" @onkeydown="OnNewPrefixKeydown" />
|
||||
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)">Add</button>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" style="max-height: 12rem; overflow-y: auto;">
|
||||
@foreach (var prefix in _prefixes)
|
||||
{
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between py-1 px-2">
|
||||
<code class="small text-break">@prefix</code>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-danger" aria-label="Remove" @onclick="() => RemovePrefix(prefix)">
|
||||
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
@if (_prefixes.Count == 0)
|
||||
{
|
||||
<li class="list-group-item text-muted small py-2">No filters. All categories are shown.</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between align-items-center">
|
||||
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving">Restore defaults</button>
|
||||
<div>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@(_saving ? "Saving…" : "Save")</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
|
||||
public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||
{
|
||||
private ElementReference _dialogRef;
|
||||
private IJSObjectReference? _jsModule;
|
||||
private DotNetObjectReference<AppLogsFiltersModal>? _dotNetRef;
|
||||
private List<string> _prefixes = [];
|
||||
private List<string> _originalPrefixes = [];
|
||||
private string _newPrefix = string.Empty;
|
||||
private bool _saving;
|
||||
private bool _moduleLoaded;
|
||||
private bool _dialogInitialized;
|
||||
private bool _wasVisible;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnDialogClosed(string returnValue)
|
||||
{
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var justOpened = IsVisible && !_wasVisible;
|
||||
_wasVisible = IsVisible;
|
||||
if (justOpened)
|
||||
{
|
||||
_dialogInitialized = false;
|
||||
try
|
||||
{
|
||||
var filters = await LoggingClient.GetFiltersAsync(_cts.Token);
|
||||
_prefixes = filters.ToList();
|
||||
_originalPrefixes = filters.ToList();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { /* leave current */ }
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
catch (JSDisconnectedException) { }
|
||||
catch (JSException) { }
|
||||
}
|
||||
|
||||
if (IsVisible && _moduleLoaded && _jsModule != null)
|
||||
{
|
||||
if (!_dialogInitialized)
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
|
||||
_dialogInitialized = true;
|
||||
}
|
||||
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private void AddPrefix()
|
||||
{
|
||||
var p = _newPrefix?.Trim();
|
||||
if (string.IsNullOrEmpty(p) || _prefixes.Contains(p, StringComparer.Ordinal)) return;
|
||||
_prefixes.Add(p);
|
||||
_newPrefix = string.Empty;
|
||||
}
|
||||
|
||||
private void RemovePrefix(string prefix)
|
||||
{
|
||||
_prefixes.Remove(prefix);
|
||||
}
|
||||
|
||||
private void OnNewPrefixKeydown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
AddPrefix();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_saving = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
await LoggingClient.SetFiltersAsync(_prefixes, _cts.Token);
|
||||
_originalPrefixes = [.. _prefixes];
|
||||
await Close();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { /* leave modal open */ }
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreDefaults()
|
||||
{
|
||||
_saving = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var defaults = await LoggingClient.RestoreFilterDefaultsAsync(_cts.Token);
|
||||
_prefixes = defaults.ToList();
|
||||
_originalPrefixes = [.. _prefixes];
|
||||
await Close();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { /* leave modal open */ }
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
_dotNetRef?.Dispose();
|
||||
if (_jsModule != null)
|
||||
{
|
||||
try { await _jsModule.DisposeAsync(); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* App logs filters modal – scoped styles; root is the dialog */
|
||||
.settings-modal-dialog {
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0;
|
||||
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.settings-modal-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-width: 28rem;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
min-width: 4.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@* App logs — live view via SignalR from ILogger *@
|
||||
@using DnsmasqWebUI.Models.Logs
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ILogger<AppLogsSection> Logger
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between flex-wrap gap-1">
|
||||
<span class="d-flex align-items-center gap-1 flex-wrap">
|
||||
<strong>App logs</strong>
|
||||
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 bg-success @(_justUpdated ? "status-dot-updating" : null)" style="width: 0.5rem; height: 0.5rem;" title="@(_justUpdated ? "Updating…" : "Live")" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-1 flex-wrap">
|
||||
<select class="form-select form-select-sm" style="width: auto;" aria-label="Log level" @bind="_logLevel" @bind:after="OnLogLevelChanged" disabled="@_logLevelDisabled">
|
||||
<option value="Trace">Trace</option>
|
||||
<option value="Debug">Debug</option>
|
||||
<option value="Information">Information</option>
|
||||
<option value="Warning">Warning</option>
|
||||
<option value="Error">Error</option>
|
||||
<option value="Critical">Critical</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="App logs category filters" @onclick="OpenFiltersModal" title="Configure category filters">
|
||||
<i class="bi bi-funnel-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="App logs polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure app logs polling interval">
|
||||
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
<AppLogsFiltersModal IsVisible="_filtersModalVisible" OnClose="CloseFiltersModal" />
|
||||
<LogsPreView Id="app-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
|
||||
@@ -0,0 +1,195 @@
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Models.Logs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
|
||||
/// <summary>
|
||||
/// App logs block: connects to LogsHub, receives AppLogsUpdate pushes, polls at interval as fallback.
|
||||
/// Snapshot on connect; RequestAppLogsUpdate on poll. Log level is configurable at runtime (persisted to appsettings.Overrides.json).
|
||||
/// </summary>
|
||||
public partial class AppLogsSection : IAsyncDisposable
|
||||
{
|
||||
private const string AppLogsPreId = "app-logs-pre";
|
||||
|
||||
[Inject] private ILoggingClient LoggingClient { get; set; } = null!;
|
||||
|
||||
private bool _justUpdated;
|
||||
private bool _logsContentReceived;
|
||||
private int _intervalSeconds;
|
||||
private string _logLevel = "Information";
|
||||
private bool _logLevelDisabled;
|
||||
private bool _filtersModalVisible;
|
||||
private HubConnection? _hubConnection;
|
||||
private IJSObjectReference? _logsJs;
|
||||
private Timer? _pollTimer;
|
||||
private Timer? _justUpdatedResetTimer;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
|
||||
|
||||
[Parameter] public int LogsMaxLines { get; set; } = 500;
|
||||
|
||||
[Parameter] public bool LogsAutoScroll { get; set; } = true;
|
||||
|
||||
[Parameter] public EventCallback OnOpenSettings { get; set; }
|
||||
|
||||
private string _initialPlaceholder => _hubConnection?.State == HubConnectionState.Connected ? "Waiting for logs…" : "Connecting…";
|
||||
|
||||
private object LogsOptions => new { maxLines = LogsMaxLines, autoScroll = LogsAutoScroll };
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (RefreshIntervalSeconds == _intervalSeconds) return;
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
RestartPollTimer();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUri)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On<LogsUpdatePayload>("AppLogsUpdate", OnAppLogsUpdate);
|
||||
|
||||
try
|
||||
{
|
||||
_logsJs = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/logs.js");
|
||||
}
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import skipped (prerender)"); return; }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import skipped (circuit disconnected)"); return; }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import failed"); return; }
|
||||
|
||||
await _hubConnection.StartAsync(_cts.Token);
|
||||
RestartPollTimer();
|
||||
|
||||
try
|
||||
{
|
||||
_logLevel = await LoggingClient.GetLevelAsync(_cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception) { /* leave default */ }
|
||||
|
||||
// Request initial snapshot (replace)
|
||||
try
|
||||
{
|
||||
await _hubConnection.InvokeAsync("RequestAppLogsSnapshot", _cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception) { /* ignore */ }
|
||||
}
|
||||
|
||||
private void OpenFiltersModal() => _filtersModalVisible = true;
|
||||
private void CloseFiltersModal() => _filtersModalVisible = false;
|
||||
|
||||
private async Task OnLogLevelChanged()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_logLevel)) return;
|
||||
_logLevelDisabled = true;
|
||||
try
|
||||
{
|
||||
var updated = await LoggingClient.SetLevelAsync(_logLevel, _cts.Token);
|
||||
_logLevel = updated;
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception) { /* leave binding as-is */ }
|
||||
finally
|
||||
{
|
||||
_logLevelDisabled = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void RestartPollTimer()
|
||||
{
|
||||
_pollTimer?.Dispose();
|
||||
_pollTimer = new Timer(
|
||||
_ => _ = InvokeAsync(PollTick),
|
||||
null,
|
||||
TimeSpan.FromSeconds(_intervalSeconds),
|
||||
TimeSpan.FromSeconds(_intervalSeconds));
|
||||
}
|
||||
|
||||
private async Task PollTick()
|
||||
{
|
||||
if (_cts.Token.IsCancellationRequested || _hubConnection?.State != HubConnectionState.Connected)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await _hubConnection.InvokeAsync("RequestAppLogsUpdate", _cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception) { /* ignore */ }
|
||||
}
|
||||
|
||||
private async void OnAppLogsUpdate(LogsUpdatePayload payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_logsJs == null) return;
|
||||
try
|
||||
{
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsync("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsync("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
_logsContentReceived = true;
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
|
||||
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
|
||||
catch (JSException) { /* JS error; ignore */ }
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
||||
}
|
||||
|
||||
private void SetJustUpdated()
|
||||
{
|
||||
_justUpdated = true;
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_justUpdatedResetTimer = new Timer(_ =>
|
||||
{
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_justUpdatedResetTimer = null;
|
||||
_ = InvokeAsync(() =>
|
||||
{
|
||||
_justUpdated = false;
|
||||
StateHasChanged();
|
||||
});
|
||||
}, null, TimeSpan.FromSeconds(2), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_pollTimer?.Dispose();
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
if (_hubConnection != null)
|
||||
{
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
if (_logsJs != null)
|
||||
{
|
||||
try { await _logsJs.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync failed"); }
|
||||
_logsJs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Status dot pulse – shared with StatusSection, LogsSection */
|
||||
@keyframes status-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot.status-dot-updating {
|
||||
animation: status-dot-pulse 0.7s ease-in-out infinite;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+24
-8
@@ -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 });
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+17
-1
@@ -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)
|
||||
|
||||
+24
-6
@@ -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,40 +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.SectionDhcp, "DHCP"),
|
||||
(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))
|
||||
@@ -42,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;
|
||||
}
|
||||
}
|
||||
|
||||
+72
-7
@@ -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;
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@* Renders a pre element for logs. Once Freeze is true, stops re-rendering so Blazor does not overwrite JS-owned content. *@
|
||||
<pre id="@Id" class="pre-block mb-0" style="max-height: 24rem;">@Placeholder</pre>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Id { get; set; } = "";
|
||||
[Parameter] public string Placeholder { get; set; } = "Connecting…";
|
||||
[Parameter] public bool Freeze { get; set; }
|
||||
|
||||
protected override bool ShouldRender() => !Freeze;
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@* Recent logs — live view from LogsCommand; only this component re-renders on its timer *@
|
||||
@* Recent logs — live view via SignalR from LogsCommand; client polls at interval, server pushes deltas *@
|
||||
@using DnsmasqWebUI.Models.Logs
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ILogger<LogsSection> Logger
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (_status != null && (_status.LogsConfigured || _status.LogsPath != null))
|
||||
{
|
||||
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between">
|
||||
<strong>Recent logs</strong>
|
||||
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between flex-wrap gap-1">
|
||||
<span class="d-flex align-items-center gap-1">
|
||||
<strong>Recent logs</strong>
|
||||
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 bg-success @(_justUpdated ? "status-dot-updating" : null)" style="width: 0.5rem; height: 0.5rem;" title="@(_justUpdated ? "Updating…" : "Live")" aria-hidden="true"></span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Recent logs polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure recent logs polling interval">
|
||||
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -14,17 +24,10 @@
|
||||
}
|
||||
@if (_status.LogsConfigured)
|
||||
{
|
||||
@if (string.IsNullOrWhiteSpace(_status.LogsOutput))
|
||||
{
|
||||
<p class="text-muted small">No output.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<pre class="pre-block mb-0" style="max-height: 24rem;">@_status.LogsOutput</pre>
|
||||
}
|
||||
<LogsPreView Id="dnsmasq-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
|
||||
}
|
||||
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing">Refresh</button>
|
||||
@if (_status.LogsPath != null)
|
||||
{
|
||||
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
|
||||
|
||||
@@ -1,81 +1,181 @@
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Logs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
|
||||
/// <summary>
|
||||
/// Recent logs block: polls LogsCommand output at its own interval and re-renders only itself.
|
||||
/// Recent logs block: connects to LogsHub, polls at interval via RequestDnsmasqLogs,
|
||||
/// receives DnsmasqLogsUpdate pushes, updates DOM via JS interop.
|
||||
/// </summary>
|
||||
public partial class LogsSection : IDisposable
|
||||
public partial class LogsSection : IAsyncDisposable
|
||||
{
|
||||
private const string LogsPreId = "dnsmasq-logs-pre";
|
||||
|
||||
private DnsmasqServiceStatus? _status;
|
||||
private bool _refreshing;
|
||||
private bool _justUpdated;
|
||||
private bool _logsContentReceived;
|
||||
private int _intervalSeconds;
|
||||
private HubConnection? _hubConnection;
|
||||
private IJSObjectReference? _logsJs;
|
||||
private Timer? _pollTimer;
|
||||
private Timer? _justUpdatedResetTimer;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Timer? _timer;
|
||||
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
|
||||
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
|
||||
|
||||
[Parameter] public int LogsMaxLines { get; set; } = 500;
|
||||
|
||||
[Parameter] public bool LogsAutoScroll { get; set; } = true;
|
||||
|
||||
[Parameter] public EventCallback OnOpenSettings { get; set; }
|
||||
|
||||
[Inject] private IStatusClient StatusClient { get; set; } = null!;
|
||||
private object LogsOptions => new { maxLines = LogsMaxLines, autoScroll = LogsAutoScroll };
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300);
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(
|
||||
_ => _ = InvokeAsync(OnRefreshTick),
|
||||
null,
|
||||
TimeSpan.FromSeconds(_intervalSeconds),
|
||||
TimeSpan.FromSeconds(_intervalSeconds));
|
||||
}
|
||||
private string _initialPlaceholder => _hubConnection?.State == HubConnectionState.Connected ? "Waiting for logs…" : "Connecting…";
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300);
|
||||
if (next == _intervalSeconds) return;
|
||||
_intervalSeconds = next;
|
||||
_timer?.Dispose();
|
||||
_timer = new Timer(
|
||||
_ => _ = InvokeAsync(OnRefreshTick),
|
||||
_status = Status;
|
||||
if (RefreshIntervalSeconds == _intervalSeconds) return;
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
RestartPollTimer();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
_status = Status;
|
||||
|
||||
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUri)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On<LogsUpdatePayload>("DnsmasqLogsUpdate", OnDnsmasqLogsUpdate);
|
||||
|
||||
try
|
||||
{
|
||||
_logsJs = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/logs.js");
|
||||
}
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (prerender)"); return; }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (circuit disconnected)"); return; }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: JS import failed"); return; }
|
||||
|
||||
await _hubConnection.StartAsync(_cts.Token);
|
||||
RestartPollTimer();
|
||||
|
||||
// Initial request
|
||||
await RequestRefreshAsync();
|
||||
}
|
||||
|
||||
private void RestartPollTimer()
|
||||
{
|
||||
_pollTimer?.Dispose();
|
||||
_pollTimer = new Timer(
|
||||
_ => _ = InvokeAsync(PollTick),
|
||||
null,
|
||||
TimeSpan.FromSeconds(_intervalSeconds),
|
||||
TimeSpan.FromSeconds(_intervalSeconds));
|
||||
}
|
||||
|
||||
private async Task OnRefreshTick()
|
||||
private async Task PollTick()
|
||||
{
|
||||
if (_cts.Token.IsCancellationRequested) return;
|
||||
await RefreshAsync();
|
||||
StateHasChanged();
|
||||
if (_cts.Token.IsCancellationRequested || _hubConnection?.State != HubConnectionState.Connected)
|
||||
return;
|
||||
await RequestRefreshAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
private async Task RequestRefreshAsync()
|
||||
{
|
||||
if (_hubConnection?.State != HubConnectionState.Connected)
|
||||
return;
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var token = _cts.Token;
|
||||
_status = await StatusClient.GetStatusAsync(token);
|
||||
await _hubConnection.InvokeAsync("RequestDnsmasqLogs", _cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch
|
||||
catch (Exception)
|
||||
{
|
||||
// Don't overwrite on background refresh
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
private async void OnDnsmasqLogsUpdate(LogsUpdatePayload payload)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
try
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_logsJs == null) return;
|
||||
try
|
||||
{
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
_logsContentReceived = true;
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
|
||||
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
|
||||
catch (JSException) { /* JS error; ignore */ }
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
||||
}
|
||||
|
||||
private void SetJustUpdated()
|
||||
{
|
||||
_justUpdated = true;
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_justUpdatedResetTimer = new Timer(_ =>
|
||||
{
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_justUpdatedResetTimer = null;
|
||||
_ = InvokeAsync(() =>
|
||||
{
|
||||
_justUpdated = false;
|
||||
StateHasChanged();
|
||||
});
|
||||
}, null, TimeSpan.FromSeconds(2), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_pollTimer?.Dispose();
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
if (_hubConnection != null)
|
||||
{
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
if (_logsJs != null)
|
||||
{
|
||||
try { await _logsJs.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync failed"); }
|
||||
_logsJs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Status dot pulse – shared with StatusSection */
|
||||
@keyframes status-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot.status-dot-updating {
|
||||
animation: status-dot-pulse 0.7s ease-in-out infinite;
|
||||
}
|
||||
@@ -7,8 +7,9 @@
|
||||
<strong>Service status</strong>
|
||||
@{
|
||||
var isActive = _status.DnsmasqStatus == "active";
|
||||
var isUpdating = _refreshing || IsReloading;
|
||||
}
|
||||
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger")" style="width: 0.5rem; height: 0.5rem;" title="@(isActive ? "Running" : "Not running")" aria-hidden="true"></span>
|
||||
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger") @(isUpdating ? "status-dot-updating" : null)" style="width: 0.5rem; height: 0.5rem;" title="@(isUpdating ? "Updating…" : (isActive ? "Running" : "Not running"))" aria-hidden="true"></span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Service status polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure service status polling interval">
|
||||
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
|
||||
@@ -17,13 +17,15 @@ public partial class StatusSection : IDisposable
|
||||
|
||||
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
|
||||
|
||||
[Parameter] public bool IsReloading { get; set; }
|
||||
|
||||
[Parameter] public EventCallback OnOpenSettings { get; set; }
|
||||
|
||||
[Inject] private IStatusClient StatusClient { get; set; } = null!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300);
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(
|
||||
_ => _ = InvokeAsync(OnRefreshTick),
|
||||
@@ -34,9 +36,8 @@ public partial class StatusSection : IDisposable
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300);
|
||||
if (next == _intervalSeconds) return;
|
||||
_intervalSeconds = next;
|
||||
if (RefreshIntervalSeconds == _intervalSeconds) return;
|
||||
_intervalSeconds = RefreshIntervalSeconds;
|
||||
_timer?.Dispose();
|
||||
_timer = new Timer(
|
||||
_ => _ = InvokeAsync(OnRefreshTick),
|
||||
@@ -52,9 +53,13 @@ public partial class StatusSection : IDisposable
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static readonly TimeSpan MinUpdatingDuration = TimeSpan.FromSeconds(1);
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
var started = DateTime.UtcNow;
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var token = _cts.Token;
|
||||
@@ -67,6 +72,13 @@ public partial class StatusSection : IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - started;
|
||||
var remaining = MinUpdatingDuration - elapsed;
|
||||
if (remaining > TimeSpan.Zero)
|
||||
{
|
||||
try { await Task.Delay(remaining, _cts.Token).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Status dot pulse when fetching/updating status */
|
||||
@keyframes status-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot.status-dot-updating {
|
||||
animation: status-dot-pulse 0.7s ease-in-out infinite;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
@inherits LayoutComponentBase
|
||||
@namespace DnsmasqWebUI.Components.Layout
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@implements IDisposable
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
@@ -14,8 +16,41 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss" aria-label="Dismiss">×</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _settingsModalOpen;
|
||||
private string _settingsModalTitle = "Settings";
|
||||
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
SettingsModalService.OpenRequested += OnSettingsOpenRequested;
|
||||
}
|
||||
|
||||
private void OnSettingsOpenRequested(SettingsModalContext context, string title)
|
||||
{
|
||||
_settingsModalContext = context;
|
||||
_settingsModalTitle = title;
|
||||
_settingsModalOpen = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task CloseSettingsModal()
|
||||
{
|
||||
_settingsModalOpen = false;
|
||||
SettingsModalService.NotifySettingsChanged();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SettingsModalService.OpenRequested -= OnSettingsOpenRequested;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ main.main {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #052767 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
@namespace DnsmasqWebUI.Components.Layout
|
||||
@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="container-fluid d-flex align-items-center">
|
||||
<div class="top-row-inner">
|
||||
<a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="@AppTitle home">
|
||||
<span class="brand-logo-wrap"><img src="logo.webp" alt="" class="brand-logo" /></span>
|
||||
<span>@AppTitle</span>
|
||||
</a>
|
||||
<div class="nav-top-controls">
|
||||
<button type="button" class="nav-settings-btn" aria-label="Settings" title="Settings" @onclick="OpenAllSettings">
|
||||
<i class="bi bi-gear-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" />
|
||||
</div>
|
||||
</div>
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" />
|
||||
</div>
|
||||
|
||||
<div class="nav-scrollable" onclick="document.getElementById('nav-toggler')?.click()">
|
||||
@@ -29,8 +38,82 @@
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
<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>
|
||||
|
||||
@code {
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(v) || v == "?") return v;
|
||||
var i = v.IndexOf('+');
|
||||
return i >= 0 ? v[..i] : v;
|
||||
}
|
||||
private string ReleaseUrl => Version != "?"
|
||||
? $"{ReleasesBaseUrl}/tag/v{BaseVersion(Version)}"
|
||||
: 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");
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Flex row: brand left, controls right; single line, aligned with brand. */
|
||||
.top-row-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem 0.5rem 0.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Settings + hamburger: push right, align with brand */
|
||||
.nav-top-controls {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 1rem;
|
||||
transform: translateY(-50%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -16,10 +37,29 @@
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: relative;
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
/* Settings button: same height as toggler on mobile for alignment */
|
||||
.nav-settings-btn {
|
||||
padding: 0.375rem 0.5rem;
|
||||
height: 2.5rem;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.375rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-settings-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-settings-btn .bi-gear-fill {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
@@ -27,13 +67,24 @@
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar-brand:hover {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Logo size/alignment: see app.css .sidebar .navbar-brand .brand-logo-wrap / .brand-logo */
|
||||
/* Smaller logo on mobile so controls align with brand text */
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row .navbar-brand .brand-logo-wrap {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.top-row .navbar-brand .brand-logo {
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bootstrap Icons in nav: white, sized to match link text */
|
||||
.nav-item .nav-icon {
|
||||
@@ -80,12 +131,82 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
.nav-footer {
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8125rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
.nav-footer-link {
|
||||
color: rgba(255, 255, 255, 0.65) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-footer-link:hover {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Toggler is inside top-row; nav-scrollable is sibling of top-row */
|
||||
.top-row:has(.navbar-toggler:checked) ~ .nav-scrollable {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
@@ -94,11 +215,10 @@
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
/* Never collapse the sidebar for wide screens; flex to fill remaining sidebar height */
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject IClientSettingsService ClientSettingsService
|
||||
@inject ILogger<SettingsModal> Logger
|
||||
|
||||
@if (IsVisible)
|
||||
{
|
||||
@@ -12,40 +13,126 @@
|
||||
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_validationErrors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger mb-3" role="alert">
|
||||
<strong>Please fix the following:</strong>
|
||||
<ul class="mb-0 mt-1">
|
||||
@foreach (var err in _validationErrors)
|
||||
{
|
||||
<li>@err</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (SettingsContext == SettingsModalContext.All)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Search settings</label>
|
||||
<input type="search" class="form-control" placeholder="Search..."
|
||||
@bind="_searchTerm" @bind:event="oninput" />
|
||||
<input type="search" class="form-control" placeholder="Search..." autocomplete="off"
|
||||
@bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
|
||||
</div>
|
||||
}
|
||||
@if (ShouldShowSection("ServiceStatus"))
|
||||
@foreach (var group in GetVisibleGroups())
|
||||
{
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">Service status</h6>
|
||||
<label for="status-interval" class="form-label">Polling interval (seconds)</label>
|
||||
<input id="status-interval" type="number" class="form-control" min="5" step="1"
|
||||
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection("Logs"))
|
||||
{
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">Recent logs</h6>
|
||||
<label for="logs-interval" class="form-label">Polling interval (seconds)</label>
|
||||
<input id="logs-interval" type="number" class="form-control" min="5" step="1"
|
||||
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection("Leases"))
|
||||
{
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">DHCP leases</h6>
|
||||
<label for="leases-interval" class="form-label">Refresh interval (seconds)</label>
|
||||
<input id="leases-interval" type="number" class="form-control" min="5" step="1"
|
||||
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
|
||||
</section>
|
||||
var isAllSections = SettingsContext == SettingsModalContext.All;
|
||||
var isExpanded = isAllSections ? IsGroupExpanded(group) : true;
|
||||
<div class="settings-group">
|
||||
@if (isAllSections)
|
||||
{
|
||||
<button type="button" class="settings-group-header @(isExpanded ? "expanded" : "")"
|
||||
@onclick="() => ToggleGroup(group.Id)"
|
||||
aria-expanded="@isExpanded"
|
||||
aria-controls="group-@group.Id">
|
||||
<span class="settings-group-title">@group.DisplayName</span>
|
||||
<span class="settings-group-chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h6 class="settings-group-title settings-group-title-static mb-2 mt-3">@group.DisplayName</h6>
|
||||
}
|
||||
<div id="group-@group.Id" class="settings-group-content @(isExpanded ? "expanded" : "")"
|
||||
role="region" aria-label="@group.DisplayName">
|
||||
@foreach (var key in group.SectionKeys)
|
||||
{
|
||||
@if (ShouldShowSection(key))
|
||||
{
|
||||
<div class="settings-section">
|
||||
@switch (key)
|
||||
{
|
||||
case SettingsModalSections.ServiceStatus:
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
|
||||
var field = ClientSettingsFields.ServiceStatusPollingInterval;
|
||||
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="status-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
|
||||
@bind="field.Value" />
|
||||
break;
|
||||
}
|
||||
case SettingsModalSections.Logs:
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
|
||||
var field = ClientSettingsFields.RecentLogsPollingInterval;
|
||||
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="logs-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
|
||||
@bind="field.Value" />
|
||||
break;
|
||||
}
|
||||
case SettingsModalSections.AppLogs:
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
|
||||
var field = ClientSettingsFields.AppLogsPollingInterval;
|
||||
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="app-logs-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
|
||||
@bind="field.Value" />
|
||||
break;
|
||||
}
|
||||
case SettingsModalSections.Leases:
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
|
||||
var field = ClientSettingsFields.LeasesPollingInterval;
|
||||
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="leases-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
|
||||
@bind="field.Value" />
|
||||
break;
|
||||
}
|
||||
case SettingsModalSections.RecentLogsDisplay:
|
||||
{
|
||||
var maxLinesField = ClientSettingsFields.RecentLogsMaxLines;
|
||||
var autoScrollField = ClientSettingsFields.RecentLogsAutoScroll;
|
||||
<div class="mb-2">
|
||||
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
|
||||
<input id="recent-logs-max-lines" type="number" class="form-control" min="@maxLinesField.Min" max="@maxLinesField.Max" step="1"
|
||||
@bind="maxLinesField.Value" />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="autoScrollField.Value" />
|
||||
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
case SettingsModalSections.AppLogsDisplay:
|
||||
{
|
||||
var maxLinesField = ClientSettingsFields.AppLogsMaxLines;
|
||||
var autoScrollField = ClientSettingsFields.AppLogsAutoScroll;
|
||||
<div class="mb-2">
|
||||
<label for="app-logs-max-lines" class="form-label">Max lines</label>
|
||||
<input id="app-logs-max-lines" type="number" class="form-control" min="@maxLinesField.Min" max="@maxLinesField.Max" step="1"
|
||||
@bind="maxLinesField.Value" />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="autoScrollField.Value" />
|
||||
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -15,8 +15,9 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
private ElementReference _dialogRef;
|
||||
private IJSObjectReference? _jsModule;
|
||||
private DotNetObjectReference<SettingsModal>? _dotNetRef;
|
||||
private ClientSettings _editingSettings = new();
|
||||
private List<string> _validationErrors = new();
|
||||
private string _searchTerm = string.Empty;
|
||||
private HashSet<string> _expandedGroupIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _moduleLoaded;
|
||||
private bool _dialogInitialized;
|
||||
private bool _wasVisible;
|
||||
@@ -38,13 +39,13 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
// Only load from storage when the modal opens; parent re-renders (e.g. refresh timer) must not overwrite in-progress edits
|
||||
if (justOpened)
|
||||
{
|
||||
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_editingSettings = new ClientSettings
|
||||
{
|
||||
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
|
||||
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds,
|
||||
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds
|
||||
};
|
||||
_searchTerm = string.Empty;
|
||||
_expandedGroupIds.Clear();
|
||||
var visible = SettingsModalSections.Groups.Where(g => SettingsModalSections.GroupMatchesContextOrSearch(g, SettingsContext, _searchTerm)).ToList();
|
||||
if (visible.Count > 0)
|
||||
_expandedGroupIds.Add(visible[0].Id);
|
||||
_validationErrors.Clear();
|
||||
await ClientSettingsService.LoadSettingsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +53,15 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_moduleLoaded = true;
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: JS import skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: JS import skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: JS import failed"); }
|
||||
}
|
||||
|
||||
if (IsVisible && _moduleLoaded && _jsModule != null)
|
||||
@@ -68,48 +75,63 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void FilterSettings() => StateHasChanged();
|
||||
|
||||
private IEnumerable<SettingsModalSections.CollapsibleGroup> GetVisibleGroups() =>
|
||||
SettingsModalSections.Groups.Where(g => SettingsModalSections.GroupMatchesContextOrSearch(g, SettingsContext, _searchTerm));
|
||||
|
||||
private bool IsGroupExpanded(SettingsModalSections.CollapsibleGroup group)
|
||||
{
|
||||
var hasActiveFilter = SettingsContext == SettingsModalContext.All && !string.IsNullOrWhiteSpace(_searchTerm);
|
||||
if (hasActiveFilter)
|
||||
return true;
|
||||
return _expandedGroupIds.Contains(group.Id);
|
||||
}
|
||||
|
||||
private void ToggleGroup(string groupId)
|
||||
{
|
||||
if (_expandedGroupIds.Contains(groupId))
|
||||
_expandedGroupIds.Remove(groupId);
|
||||
else
|
||||
_expandedGroupIds.Add(groupId);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private bool ShouldShowSection(string key)
|
||||
{
|
||||
if (SettingsContext == SettingsModalContext.ServicePolling)
|
||||
return key == "ServiceStatus";
|
||||
if (SettingsContext == SettingsModalContext.LogsPolling)
|
||||
return key == "Logs";
|
||||
if (SettingsContext == SettingsModalContext.LeasesPolling)
|
||||
return key == "Leases";
|
||||
if (SettingsContext == SettingsModalContext.All)
|
||||
var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext);
|
||||
if (keys != null)
|
||||
return keys.Any(k => string.Equals(key, k, StringComparison.OrdinalIgnoreCase));
|
||||
return SettingsModalSections.MatchesSearch(key, _searchTerm);
|
||||
}
|
||||
|
||||
private void RunValidation()
|
||||
{
|
||||
_validationErrors.Clear();
|
||||
foreach (var (section, field) in ClientSettingsFields.ValidationChecks)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_searchTerm)) return true;
|
||||
var term = _searchTerm.Trim();
|
||||
return key switch
|
||||
{
|
||||
"ServiceStatus" => "service status polling".Contains(term, StringComparison.OrdinalIgnoreCase),
|
||||
"Logs" => "recent logs polling".Contains(term, StringComparison.OrdinalIgnoreCase),
|
||||
"Leases" => "dhcp leases refresh polling".Contains(term, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false
|
||||
};
|
||||
if (ShouldShowSection(section) && field.Validate() is { } err)
|
||||
_validationErrors.Add(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_editingSettings.ServiceStatusPollingIntervalSeconds = Math.Clamp(
|
||||
_editingSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
|
||||
_editingSettings.RecentLogsPollingIntervalSeconds = Math.Clamp(
|
||||
_editingSettings.RecentLogsPollingIntervalSeconds, 5, 300);
|
||||
_editingSettings.LeasesPollingIntervalSeconds = Math.Clamp(
|
||||
_editingSettings.LeasesPollingIntervalSeconds, 5, 300);
|
||||
RunValidation();
|
||||
if (_validationErrors.Count > 0)
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
await ClientSettingsService.SaveSettingsAsync(_editingSettings);
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
|
||||
await ClientSettingsService.SaveSettingsAsync(ClientSettingsFields.ToDto());
|
||||
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
|
||||
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -125,13 +147,12 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_jsModule != null)
|
||||
await _jsModule.DisposeAsync();
|
||||
}
|
||||
finally
|
||||
if (_jsModule != null)
|
||||
{
|
||||
try { await _jsModule.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync failed"); }
|
||||
_jsModule = null;
|
||||
}
|
||||
_dotNetRef?.Dispose();
|
||||
|
||||
@@ -33,3 +33,84 @@
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.125);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Collapsible section groups */
|
||||
.settings-group {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-group-header:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.settings-group-header.expanded {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.settings-group-chevron {
|
||||
flex-shrink: 0;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
border-right: 2px solid #6c757d;
|
||||
border-bottom: 2px solid #6c757d;
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-group-header.expanded .settings-group-chevron {
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
|
||||
.settings-group-title-static {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-group-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease-out;
|
||||
}
|
||||
|
||||
.settings-group-content.expanded {
|
||||
max-height: 500px;
|
||||
transition: max-height 0.35s ease-in;
|
||||
}
|
||||
|
||||
.settings-group-content > * {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-section + .settings-section {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@inject ILeasesClient LeasesClient
|
||||
@inject IReloadClient ReloadClient
|
||||
@inject IClientSettingsService ClientSettingsService
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@implements IDisposable
|
||||
|
||||
@@ -320,7 +321,11 @@ else
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
|
||||
</div>
|
||||
}
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
</section>
|
||||
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title">Config affecting DHCP</h2>
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -336,9 +341,6 @@ else
|
||||
private Timer? _leasesRefreshTimer;
|
||||
private int _leasesRefreshIntervalSeconds = 5;
|
||||
private string? _message;
|
||||
private bool _settingsModalOpen;
|
||||
private string _settingsModalTitle = "Client settings";
|
||||
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
|
||||
private bool _saving;
|
||||
|
||||
private IEnumerable<DhcpHostEntry> _visibleStaticEntries =>
|
||||
@@ -402,9 +404,16 @@ else
|
||||
("2592000", "30 days"),
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
SettingsModalService.SettingsChanged += OnSettingsChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
await Load();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
@@ -413,8 +422,8 @@ else
|
||||
_message = null;
|
||||
try
|
||||
{
|
||||
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
|
||||
await ClientSettingsService.LoadSettingsAsync();
|
||||
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
|
||||
_status = await StatusClient.GetStatusAsync();
|
||||
var entries = await DhcpHostsClient.GetDhcpHostsAsync();
|
||||
_entries = entries?.ToList() ?? new List<DhcpHostEntry>();
|
||||
@@ -428,20 +437,17 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenLeasesSettings()
|
||||
{
|
||||
_settingsModalContext = SettingsModalContext.LeasesPolling;
|
||||
_settingsModalTitle = "Leases refresh interval";
|
||||
_settingsModalOpen = true;
|
||||
}
|
||||
private void OpenLeasesSettings() => SettingsModalService.Open(SettingsModalContext.LeasesPolling, "Leases refresh interval");
|
||||
|
||||
private async Task CloseSettingsModal()
|
||||
private async void OnSettingsChanged()
|
||||
{
|
||||
_settingsModalOpen = false;
|
||||
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
|
||||
if (_status != null && _status.LeasesPathConfigured)
|
||||
StartLeasesRefreshTimer();
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
|
||||
if (_status != null && _status.LeasesPathConfigured)
|
||||
StartLeasesRefreshTimer();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private void StartLeasesRefreshTimer()
|
||||
@@ -640,6 +646,7 @@ else
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SettingsModalService.SettingsChanged -= OnSettingsChanged;
|
||||
_leasesRefreshTimer?.Dispose();
|
||||
_leasesRefreshTimer = null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@inject IConfigSetClient ConfigSetClient
|
||||
@inject IReloadClient ReloadClient
|
||||
@inject IClientSettingsService ClientSettingsService
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@implements IDisposable
|
||||
|
||||
@@ -55,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">
|
||||
@@ -77,21 +78,14 @@ else if (_status != null)
|
||||
</div>
|
||||
}
|
||||
|
||||
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" OnOpenSettings="OpenServiceStatusSettings" />
|
||||
<LogsSection RefreshIntervalSeconds="_recentLogsIntervalSeconds" OnOpenSettings="OpenRecentLogsSettings" />
|
||||
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
<StatusSection RefreshIntervalSeconds="@ClientSettingsFields.ServiceStatusPollingInterval.Value" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
|
||||
<LogsSection Status="_status" RefreshIntervalSeconds="@ClientSettingsFields.RecentLogsPollingInterval.Value" LogsMaxLines="@ClientSettingsFields.RecentLogsMaxLines.Value" LogsAutoScroll="@ClientSettingsFields.RecentLogsAutoScroll.Value" OnOpenSettings="OpenRecentLogsSettings" />
|
||||
<AppLogsSection RefreshIntervalSeconds="@ClientSettingsFields.AppLogsPollingInterval.Value" LogsMaxLines="@ClientSettingsFields.AppLogsMaxLines.Value" LogsAutoScroll="@ClientSettingsFields.AppLogsAutoScroll.Value" OnOpenSettings="OpenAppLogsSettings" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||
private int _serviceStatusIntervalSeconds = 15;
|
||||
private int _recentLogsIntervalSeconds = 15;
|
||||
private bool _settingsModalOpen;
|
||||
private string _settingsModalTitle = "Client settings";
|
||||
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
|
||||
private ClientSettings _clientSettings = new();
|
||||
|
||||
private DnsmasqServiceStatus? _status;
|
||||
private DnsmasqConfigSet? _configSet;
|
||||
@@ -103,16 +97,20 @@ else if (_status != null)
|
||||
private bool _reloadSuccess;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
SettingsModalService.SettingsChanged += OnSettingsChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
await Task.Delay(1, _cts.Token);
|
||||
var token = _cts.Token;
|
||||
_clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
|
||||
_recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
|
||||
await ClientSettingsService.LoadSettingsAsync();
|
||||
_status = await StatusClient.GetStatusAsync(token);
|
||||
if (_status != null)
|
||||
{
|
||||
@@ -137,12 +135,20 @@ else if (_status != null)
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenServiceStatusSettings() => OpenSettingsModal(SettingsModalContext.ServicePolling, "Service status polling");
|
||||
private void OpenServiceStatusSettings() => SettingsModalService.Open(SettingsModalContext.ServicePolling, "Service status polling");
|
||||
|
||||
private void OpenRecentLogsSettings() => OpenSettingsModal(SettingsModalContext.LogsPolling, "Recent logs polling");
|
||||
private void OpenRecentLogsSettings() => SettingsModalService.Open(SettingsModalContext.LogsPolling, "Recent logs polling");
|
||||
|
||||
private void OpenAppLogsSettings() => SettingsModalService.Open(SettingsModalContext.AppLogsPolling, "App logs polling");
|
||||
|
||||
private async void OnSettingsChanged()
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task ReloadDnsmasq()
|
||||
{
|
||||
@@ -193,24 +199,9 @@ else if (_status != null)
|
||||
return parts.Count > 0 ? string.Join("\n\n", parts) : null;
|
||||
}
|
||||
|
||||
private void OpenSettingsModal(SettingsModalContext context, string title)
|
||||
{
|
||||
_settingsModalContext = context;
|
||||
_settingsModalTitle = title;
|
||||
_settingsModalOpen = true;
|
||||
}
|
||||
|
||||
private async Task CloseSettingsModal()
|
||||
{
|
||||
_settingsModalOpen = false;
|
||||
_clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
|
||||
_recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SettingsModalService.SettingsChanged -= OnSettingsChanged;
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using DnsmasqWebUI
|
||||
@using DnsmasqWebUI.Models.Client
|
||||
@using DnsmasqWebUI.Models.Client.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
|
||||
@using DnsmasqWebUI.Components
|
||||
@using DnsmasqWebUI.Components.Modals
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
@@ -14,12 +15,14 @@ public class ConfigController : ControllerBase
|
||||
private readonly IDnsmasqConfigSetService _configSetService;
|
||||
private readonly IDnsmasqConfigService _configService;
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly ILogger<ConfigController> _logger;
|
||||
|
||||
public ConfigController(IDnsmasqConfigSetService configSetService, IDnsmasqConfigService configService, IReloadService reloadService)
|
||||
public ConfigController(IDnsmasqConfigSetService configSetService, IDnsmasqConfigService configService, IReloadService reloadService, ILogger<ConfigController> logger)
|
||||
{
|
||||
_configSetService = configSetService;
|
||||
_configService = configService;
|
||||
_reloadService = reloadService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Returns the config set (main + conf-file/conf-dir) and managed file path. Read-only structure for UI.</summary>
|
||||
@@ -28,11 +31,13 @@ public class ConfigController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get config set");
|
||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
||||
return Ok(set);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Get config set failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -43,11 +48,13 @@ public class ConfigController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get managed config");
|
||||
var content = await _configService.ReadManagedConfigAsync(ct);
|
||||
return Ok(content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Get managed config failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -62,10 +69,12 @@ public class ConfigController : ControllerBase
|
||||
{
|
||||
await _configService.WriteManagedConfigAsync(lines, ct);
|
||||
var reload = await _reloadService.ReloadAsync(ct);
|
||||
_logger.LogInformation("Config managed updated, reload success={Success}", reload.Success);
|
||||
return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Put managed config failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
@@ -11,11 +12,13 @@ public class DhcpController : ControllerBase
|
||||
{
|
||||
private readonly IDnsmasqConfigService _configService;
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly ILogger<DhcpController> _logger;
|
||||
|
||||
public DhcpController(IDnsmasqConfigService configService, IReloadService reloadService)
|
||||
public DhcpController(IDnsmasqConfigService configService, IReloadService reloadService, ILogger<DhcpController> logger)
|
||||
{
|
||||
_configService = configService;
|
||||
_reloadService = reloadService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("hosts")]
|
||||
@@ -23,11 +26,13 @@ public class DhcpController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get DHCP hosts");
|
||||
var entries = await _configService.ReadDhcpHostsAsync(ct);
|
||||
return Ok(entries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Get DHCP hosts failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -41,14 +46,17 @@ public class DhcpController : ControllerBase
|
||||
{
|
||||
await _configService.WriteDhcpHostsAsync(entries, ct);
|
||||
var reload = await _reloadService.ReloadAsync(ct);
|
||||
_logger.LogInformation("DHCP hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
|
||||
return Ok(new SaveWithReloadResult(true, reload));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "DHCP hosts put validation failed");
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Put DHCP hosts failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
@@ -12,12 +13,14 @@ public class HostsController : ControllerBase
|
||||
private readonly IHostsFileService _hostsService;
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly IHostsCache _hostsCache;
|
||||
private readonly ILogger<HostsController> _logger;
|
||||
|
||||
public HostsController(IHostsFileService hostsService, IReloadService reloadService, IHostsCache hostsCache)
|
||||
public HostsController(IHostsFileService hostsService, IReloadService reloadService, IHostsCache hostsCache, ILogger<HostsController> logger)
|
||||
{
|
||||
_hostsService = hostsService;
|
||||
_reloadService = reloadService;
|
||||
_hostsCache = hostsCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -25,11 +28,13 @@ public class HostsController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get hosts");
|
||||
var entries = await _hostsService.ReadAsync(ct);
|
||||
return Ok(entries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Get hosts failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -40,11 +45,13 @@ public class HostsController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get readonly hosts");
|
||||
var snapshot = await _hostsCache.GetSnapshotAsync(ct);
|
||||
return Ok(snapshot.ReadOnlyFiles);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Get readonly hosts failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -58,14 +65,17 @@ public class HostsController : ControllerBase
|
||||
{
|
||||
await _hostsService.WriteAsync(entries, ct);
|
||||
var reload = await _reloadService.ReloadAsync(ct);
|
||||
_logger.LogInformation("Hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
|
||||
return Ok(new SaveWithReloadResult(true, reload));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Hosts put validation failed");
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Put hosts failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
@@ -10,11 +11,13 @@ public class LeasesController : ControllerBase
|
||||
{
|
||||
private readonly ILeasesFileService _leasesService;
|
||||
private readonly ILeasesCache _cache;
|
||||
private readonly ILogger<LeasesController> _logger;
|
||||
|
||||
public LeasesController(ILeasesFileService leasesService, ILeasesCache cache)
|
||||
public LeasesController(ILeasesFileService leasesService, ILeasesCache cache, ILogger<LeasesController> logger)
|
||||
{
|
||||
_leasesService = leasesService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -22,6 +25,7 @@ public class LeasesController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get leases, refresh={Refresh}", refresh);
|
||||
if (refresh)
|
||||
_cache.Invalidate();
|
||||
var (available, entries) = await _leasesService.TryReadAsync(ct);
|
||||
@@ -33,6 +37,7 @@ public class LeasesController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Get leases failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class LoggingController : ControllerBase
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IConfigurationRoot _configurationRoot;
|
||||
private readonly RuntimeOverridesOptions _overridesOptions;
|
||||
private readonly ILogger<LoggingController> _logger;
|
||||
|
||||
public LoggingController(
|
||||
IConfiguration configuration,
|
||||
IOptions<RuntimeOverridesOptions> overridesOptions,
|
||||
ILogger<LoggingController> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_configurationRoot = (IConfigurationRoot)configuration;
|
||||
_overridesOptions = overridesOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Get the current minimum log level from configuration (Logging:LogLevel:Default).</summary>
|
||||
[HttpGet("level")]
|
||||
public ActionResult<LogLevelResponse> GetLevel()
|
||||
{
|
||||
var level = ParseLevel(_configuration["Logging:LogLevel:Default"]) ?? LogLevel.Information;
|
||||
return Ok(new LogLevelResponse(LevelName(level)));
|
||||
}
|
||||
|
||||
/// <summary>Set the minimum log level. Writes to appsettings.Overrides.json and reloads config.</summary>
|
||||
[HttpPost("level")]
|
||||
public ActionResult<LogLevelResponse> SetLevel([FromBody] LogLevelRequest request)
|
||||
{
|
||||
var level = ParseLevel(request?.LogLevel);
|
||||
if (level == null)
|
||||
{
|
||||
_logger.LogWarning("Set log level failed: invalid level requested");
|
||||
return BadRequest(new { error = "Invalid LogLevel. Use Trace, Debug, Information, Warning, Error, or Critical." });
|
||||
}
|
||||
|
||||
var path = GetOverridesPath();
|
||||
if (!TryUpdateOverrides(path, logLevel: level.Value, excludedPrefixes: null))
|
||||
{
|
||||
_logger.LogError("Failed to write log level overrides file to {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to write overrides file." });
|
||||
}
|
||||
|
||||
_configurationRoot.Reload();
|
||||
_logger.LogInformation("Log level changed to {Level}", LevelName(level.Value));
|
||||
return Ok(new LogLevelResponse(LevelName(level.Value)));
|
||||
}
|
||||
|
||||
/// <summary>Get excluded category prefixes. Prefers runtime overrides (ExcludedCategoryPrefixesOverrides) when present; otherwise uses defaults (ExcludedCategoryPrefixes).</summary>
|
||||
[HttpGet("filters")]
|
||||
public ActionResult<FiltersResponse> GetFilters()
|
||||
{
|
||||
var prefixes = AppLogsConfigHelper.GetEffectiveExcludedPrefixes(_configuration);
|
||||
return Ok(new FiltersResponse(prefixes));
|
||||
}
|
||||
|
||||
/// <summary>Set excluded category prefixes. Writes to appsettings.Overrides.json and reloads config.</summary>
|
||||
[HttpPost("filters")]
|
||||
public ActionResult<FiltersResponse> SetFilters([FromBody] FiltersRequest request)
|
||||
{
|
||||
var prefixes = request?.ExcludedCategoryPrefixes ?? [];
|
||||
var path = GetOverridesPath();
|
||||
if (!TryUpdateOverrides(path, logLevel: null, excludedPrefixes: prefixes))
|
||||
{
|
||||
_logger.LogError("Failed to write filters overrides file to {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to write overrides file." });
|
||||
}
|
||||
|
||||
_configurationRoot.Reload();
|
||||
_logger.LogInformation("App logs filters updated, count={Count}", prefixes.Count);
|
||||
return Ok(new FiltersResponse(prefixes));
|
||||
}
|
||||
|
||||
/// <summary>Restore filter defaults by removing overrides. Uses ExcludedCategoryPrefixes from appsettings.json.</summary>
|
||||
[HttpPost("filters/restore-defaults")]
|
||||
public ActionResult<FiltersResponse> RestoreFilterDefaults()
|
||||
{
|
||||
var path = GetOverridesPath();
|
||||
if (!TryRemoveFilterOverrides(path))
|
||||
{
|
||||
_logger.LogError("Failed to remove filter overrides from {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to update overrides file." });
|
||||
}
|
||||
|
||||
_configurationRoot.Reload();
|
||||
var defaults = AppLogsConfigHelper.GetDefaultExcludedPrefixes(_configuration);
|
||||
_logger.LogInformation("App logs filters restored to defaults, count={Count}", defaults.Count);
|
||||
return Ok(new FiltersResponse(defaults));
|
||||
}
|
||||
|
||||
private string GetOverridesPath()
|
||||
{
|
||||
var path = _overridesOptions.FilePath?.Trim();
|
||||
return string.IsNullOrEmpty(path)
|
||||
? Path.Combine(AppContext.BaseDirectory, RuntimeOverridesOptions.DefaultFileName)
|
||||
: path;
|
||||
}
|
||||
|
||||
private bool TryUpdateOverrides(string filePath, LogLevel? logLevel, List<string>? excludedPrefixes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var root = LoadOverrides(filePath);
|
||||
|
||||
if (logLevel.HasValue)
|
||||
{
|
||||
root["Logging"] = new JsonObject
|
||||
{
|
||||
["LogLevel"] = new JsonObject { ["Default"] = LevelName(logLevel.Value) }
|
||||
};
|
||||
}
|
||||
|
||||
if (excludedPrefixes != null)
|
||||
{
|
||||
// Use sentinel when empty so .NET config returns non-null (empty arrays bind to null)
|
||||
var values = excludedPrefixes.Count == 0
|
||||
? new[] { JsonValue.Create(AppLogsConfigHelper.ExplicitlyEmptySentinel) }
|
||||
: excludedPrefixes.Select(s => JsonValue.Create(s)).ToArray();
|
||||
root["AppLogs"] = new JsonObject
|
||||
{
|
||||
["ExcludedCategoryPrefixesOverrides"] = new JsonArray(values)
|
||||
};
|
||||
}
|
||||
|
||||
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
System.IO.File.WriteAllText(filePath, json);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryRemoveFilterOverrides(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = LoadOverrides(filePath);
|
||||
if (root["AppLogs"] is JsonObject appLogs)
|
||||
{
|
||||
appLogs.Remove("ExcludedCategoryPrefixesOverrides");
|
||||
if (appLogs.Count == 0)
|
||||
root.Remove("AppLogs");
|
||||
}
|
||||
|
||||
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
System.IO.File.WriteAllText(filePath, json);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject LoadOverrides(string filePath)
|
||||
{
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
return new JsonObject();
|
||||
|
||||
try
|
||||
{
|
||||
var json = System.IO.File.ReadAllText(filePath);
|
||||
var node = JsonNode.Parse(json);
|
||||
return node as JsonObject ?? new JsonObject();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
private static string LevelName(LogLevel level) => level switch
|
||||
{
|
||||
LogLevel.Trace => "Trace",
|
||||
LogLevel.Debug => "Debug",
|
||||
LogLevel.Information => "Information",
|
||||
LogLevel.Warning => "Warning",
|
||||
LogLevel.Error => "Error",
|
||||
LogLevel.Critical => "Critical",
|
||||
_ => "Information"
|
||||
};
|
||||
|
||||
private static LogLevel? ParseLevel(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
return value.Trim() switch
|
||||
{
|
||||
"Trace" => LogLevel.Trace,
|
||||
"Debug" => LogLevel.Debug,
|
||||
"Information" or "Info" => LogLevel.Information,
|
||||
"Warning" or "Warn" => LogLevel.Warning,
|
||||
"Error" => LogLevel.Error,
|
||||
"Critical" => LogLevel.Critical,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public record LogLevelRequest(string? LogLevel);
|
||||
public record LogLevelResponse(string LogLevel);
|
||||
public record FiltersRequest(List<string>? ExcludedCategoryPrefixes);
|
||||
public record FiltersResponse(List<string> ExcludedCategoryPrefixes);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
@@ -8,10 +9,12 @@ namespace DnsmasqWebUI.Controllers;
|
||||
public class ReloadController : ControllerBase
|
||||
{
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly ILogger<ReloadController> _logger;
|
||||
|
||||
public ReloadController(IReloadService reloadService)
|
||||
public ReloadController(IReloadService reloadService, ILogger<ReloadController> logger)
|
||||
{
|
||||
_reloadService = reloadService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@@ -20,10 +23,12 @@ public class ReloadController : ControllerBase
|
||||
try
|
||||
{
|
||||
var result = await _reloadService.ReloadAsync(ct);
|
||||
_logger.LogInformation("Reload requested, success={Success}", result.Success);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Reload request failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
@@ -15,15 +16,18 @@ public class StatusController : ControllerBase
|
||||
private readonly DnsmasqOptions _options;
|
||||
private readonly IDnsmasqConfigSetService _configSetService;
|
||||
private readonly IProcessRunner _processRunner;
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
|
||||
public StatusController(
|
||||
IOptions<DnsmasqOptions> options,
|
||||
IDnsmasqConfigSetService configSetService,
|
||||
IProcessRunner processRunner)
|
||||
IProcessRunner processRunner,
|
||||
ILogger<StatusController> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_configSetService = configSetService;
|
||||
_processRunner = processRunner;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -31,6 +35,7 @@ public class StatusController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get status");
|
||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
||||
var effectiveLeasesPath = _configSetService.GetLeasesPath();
|
||||
var (effectiveConfig, effectiveConfigSources) = _configSetService.GetEffectiveConfigWithSources();
|
||||
@@ -98,6 +103,7 @@ public class StatusController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Get status failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -140,6 +146,7 @@ public class StatusController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get status logs download");
|
||||
var fullPath = Path.IsPathRooted(logsPath) ? Path.GetFullPath(logsPath) : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), logsPath));
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
return NotFound();
|
||||
@@ -154,6 +161,7 @@ public class StatusController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Status logs download failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<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>
|
||||
@@ -10,6 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.2" />
|
||||
<PackageReference Include="Superpower" Version="3.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Threading.Channels;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// App logs setup: buffer, channel, and provider.
|
||||
/// Encapsulates the wiring that must run before the DI container exists (ILoggerProvider is created during host configuration).
|
||||
/// Log level and excluded category prefixes are configured via appsettings.Overrides.json (config source with reloadOnChange).
|
||||
/// </summary>
|
||||
public static class LoggingBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the app logs provider (forwards ILogger output to SignalR) and registers buffer and channel.
|
||||
/// Call before <c>builder.Build()</c>.
|
||||
/// </summary>
|
||||
public static ILoggingBuilder AddAppLogs(this ILoggingBuilder logging, IConfiguration configuration)
|
||||
{
|
||||
var services = logging.Services;
|
||||
|
||||
var buffer = new AppLogsBuffer();
|
||||
var pushChannel = Channel.CreateBounded<byte>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
|
||||
|
||||
services.AddSingleton(pushChannel);
|
||||
services.AddSingleton<IAppLogsBuffer>(buffer);
|
||||
services.AddSingleton<ILogsService, LogsService>();
|
||||
|
||||
logging.AddProvider(new AppLogsLoggerProvider(buffer, pushChannel, configuration));
|
||||
|
||||
return logging;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -30,6 +33,14 @@ public static class ServiceCollectionExtensions
|
||||
services.AddHttpClient<IHostsClient, HostsClient>(DnsmasqApiClientName);
|
||||
services.AddHttpClient<IDhcpHostsClient, DhcpHostsClient>(DnsmasqApiClientName);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public static class WebApplicationExtensions
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
|
||||
KnownNetworks = { new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Loopback, 8) },
|
||||
KnownIPNetworks = { new System.Net.IPNetwork(IPAddress.Loopback, 8) },
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace DnsmasqWebUI.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for log streaming. Clients receive DnsmasqLogsUpdate and AppLogsUpdate.
|
||||
/// Server push is done via IHubContext; this hub defines methods clients can invoke.
|
||||
/// </summary>
|
||||
public class LogsHub : Hub
|
||||
{
|
||||
private readonly ILogsService _logsService;
|
||||
|
||||
public LogsHub(ILogsService logsService)
|
||||
{
|
||||
_logsService = logsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client requests dnsmasq logs. Server runs LogsCommand, diffs, and pushes updates.
|
||||
/// </summary>
|
||||
public async Task RequestDnsmasqLogs()
|
||||
{
|
||||
await _logsService.RunAndPushDnsmasqLogsAsync(Context.ConnectionAborted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client requests a snapshot of recent app logs (replace). Used on connect.
|
||||
/// </summary>
|
||||
public async Task RequestAppLogsSnapshot()
|
||||
{
|
||||
await _logsService.PushAppLogsSnapshotAsync(Context.ConnectionAborted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client polls for new app logs (append). Fallback for reconnect/missed pushes.
|
||||
/// </summary>
|
||||
public async Task RequestAppLogsUpdate()
|
||||
{
|
||||
await _logsService.PushAppLogsPendingAsync(Context.ConnectionAborted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
|
||||
/// <summary>Client for runtime logging API (log level and category filters).</summary>
|
||||
public interface ILoggingClient
|
||||
{
|
||||
Task<string> GetLevelAsync(CancellationToken ct = default);
|
||||
Task<string> SetLevelAsync(string logLevel, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<string>> GetFiltersAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<string>> SetFiltersAsync(IReadOnlyList<string> excludedCategoryPrefixes, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<string>> RestoreFilterDefaultsAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Net.Http.Json;
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Http;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Client;
|
||||
|
||||
public sealed class LoggingClient : ILoggingClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public LoggingClient(HttpClient http) => _http = http;
|
||||
|
||||
public async Task<string> GetLevelAsync(CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.GetFromJsonAsync<LogLevelResponse>("api/logging/level", ApiJsonOptions.ClientOptions, ct);
|
||||
return response?.LogLevel ?? "Information";
|
||||
}
|
||||
|
||||
public async Task<string> SetLevelAsync(string logLevel, CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/logging/level", new { logLevel }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<LogLevelResponse>(ApiJsonOptions.ClientOptions, ct);
|
||||
return body?.LogLevel ?? "Information";
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetFiltersAsync(CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.GetFromJsonAsync<FiltersResponse>("api/logging/filters", ApiJsonOptions.ClientOptions, ct);
|
||||
return response?.ExcludedCategoryPrefixes ?? [];
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SetFiltersAsync(IReadOnlyList<string> excludedCategoryPrefixes, CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/logging/filters", new { excludedCategoryPrefixes }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<FiltersResponse>(ApiJsonOptions.ClientOptions, ct);
|
||||
return body?.ExcludedCategoryPrefixes ?? [];
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> RestoreFilterDefaultsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.PostAsync("api/logging/filters/restore-defaults", null, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<FiltersResponse>(ApiJsonOptions.ClientOptions, ct);
|
||||
return body?.ExcludedCategoryPrefixes ?? [];
|
||||
}
|
||||
|
||||
private record LogLevelResponse(string LogLevel);
|
||||
private record FiltersResponse(List<string> ExcludedCategoryPrefixes);
|
||||
}
|
||||
@@ -14,49 +14,139 @@ public static class DnsmasqConfKeys
|
||||
// --- Hosts ---
|
||||
public const string NoHosts = "no-hosts";
|
||||
public const string AddnHosts = "addn-hosts";
|
||||
public const string Hostsdir = "hostsdir";
|
||||
public const string ReadEthers = "read-ethers";
|
||||
|
||||
// --- DHCP lease file (last wins) ---
|
||||
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";
|
||||
public const string RevServer = "rev-server";
|
||||
public const string Address = "address";
|
||||
public const string Interface = "interface";
|
||||
public const string ListenAddress = "listen-address";
|
||||
public const string ExceptInterface = "except-interface";
|
||||
public const string NoDhcpInterface = "no-dhcp-interface";
|
||||
public const string NoDhcpv4Interface = "no-dhcpv4-interface";
|
||||
public const string NoDhcpv6Interface = "no-dhcpv6-interface";
|
||||
public const string AuthServer = "auth-server";
|
||||
public const string DhcpRange = "dhcp-range";
|
||||
public const string DhcpHost = "dhcp-host";
|
||||
public const string DhcpOption = "dhcp-option";
|
||||
public const string DhcpOptionForce = "dhcp-option-force";
|
||||
public const string DhcpMatch = "dhcp-match";
|
||||
public const string DhcpMac = "dhcp-mac";
|
||||
public const string DhcpNameMatch = "dhcp-name-match";
|
||||
public const string DhcpIgnoreNames = "dhcp-ignore-names";
|
||||
public const string DhcpBoot = "dhcp-boot";
|
||||
public const string DhcpIgnore = "dhcp-ignore";
|
||||
public const string DhcpVendorclass = "dhcp-vendorclass";
|
||||
public const string DhcpUserclass = "dhcp-userclass";
|
||||
public const string RaParam = "ra-param";
|
||||
public const string Slaac = "slaac";
|
||||
public const string PxeService = "pxe-service";
|
||||
public const string TrustAnchor = "trust-anchor";
|
||||
public const string ResolvFile = "resolv-file";
|
||||
public const string RebindDomainOk = "rebind-domain-ok";
|
||||
public const string BogusNxdomain = "bogus-nxdomain";
|
||||
public const string IgnoreAddress = "ignore-address";
|
||||
public const string Alias = "alias";
|
||||
public const string FilterRr = "filter-rr";
|
||||
public const string CacheRr = "cache-rr";
|
||||
public const string Ipset = "ipset";
|
||||
public const string Nftset = "nftset";
|
||||
// --- DNS records (authoritative / local) ---
|
||||
public const string Domain = "domain";
|
||||
public const string Cname = "cname";
|
||||
public const string MxHost = "mx-host";
|
||||
public const string MxTarget = "mx-target";
|
||||
public const string Srv = "srv-host";
|
||||
public const string PtrRecord = "ptr-record";
|
||||
public const string TxtRecord = "txt-record";
|
||||
public const string NaptrRecord = "naptr-record";
|
||||
public const string HostRecord = "host-record";
|
||||
public const string DynamicHost = "dynamic-host";
|
||||
public const string InterfaceName = "interface-name";
|
||||
|
||||
// --- Flags (no value) ---
|
||||
public const string ExpandHosts = "expand-hosts";
|
||||
public const string BogusPriv = "bogus-priv";
|
||||
public const string StrictOrder = "strict-order";
|
||||
public const string AllServers = "all-servers";
|
||||
public const string NoResolv = "no-resolv";
|
||||
public const string DomainNeeded = "domain-needed";
|
||||
public const string NoPoll = "no-poll";
|
||||
public const string BindInterfaces = "bind-interfaces";
|
||||
public const string BindDynamic = "bind-dynamic";
|
||||
public const string NoNegcache = "no-negcache";
|
||||
public const string DnsLoopDetect = "dns-loop-detect";
|
||||
public const string StopDnsRebind = "stop-dns-rebind";
|
||||
public const string RebindLocalhostOk = "rebind-localhost-ok";
|
||||
public const string ClearOnReload = "clear-on-reload";
|
||||
public const string Filterwin2k = "filterwin2k";
|
||||
public const string FilterA = "filter-A";
|
||||
public const string FilterAaaa = "filter-AAAA";
|
||||
public const string LocaliseQueries = "localise-queries";
|
||||
public const string LogDebug = "log-debug";
|
||||
public const string DhcpAuthoritative = "dhcp-authoritative";
|
||||
public const string DhcpRapidCommit = "dhcp-rapid-commit";
|
||||
public const string LeasefileRo = "leasefile-ro";
|
||||
public const string Localmx = "localmx";
|
||||
public const string Selfmx = "selfmx";
|
||||
public const string EnableRa = "enable-ra";
|
||||
public const string LogDhcp = "log-dhcp";
|
||||
public const string EnableTftp = "enable-tftp";
|
||||
public const string TftpSecure = "tftp-secure";
|
||||
public const string TftpNoFail = "tftp-no-fail";
|
||||
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";
|
||||
public const string Port = "port";
|
||||
public const string LocalTtl = "local-ttl";
|
||||
public const string AuthTtl = "auth-ttl";
|
||||
public const string EdnsPacketMax = "edns-packet-max";
|
||||
public const string QueryPort = "query-port";
|
||||
public const string PortLimit = "port-limit";
|
||||
public const string MinPort = "min-port";
|
||||
public const string MaxPort = "max-port";
|
||||
public const string LogAsync = "log-async";
|
||||
public const string LocalService = "local-service";
|
||||
public const string PidFile = "pid-file";
|
||||
public const string User = "user";
|
||||
public const string Group = "group";
|
||||
public const string LogFacility = "log-facility";
|
||||
public const string LogQueries = "log-queries";
|
||||
public const string DhcpLeaseMax = "dhcp-lease-max";
|
||||
public const string NegTtl = "neg-ttl";
|
||||
public const string MaxTtl = "max-ttl";
|
||||
public const string MaxCacheTtl = "max-cache-ttl";
|
||||
public const string MinCacheTtl = "min-cache-ttl";
|
||||
public const string DhcpTtl = "dhcp-ttl";
|
||||
public const string TftpRoot = "tftp-root";
|
||||
public const string PxePrompt = "pxe-prompt";
|
||||
public const string EnableDbus = "enable-dbus";
|
||||
public const string EnableUbus = "enable-ubus";
|
||||
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,25 +11,78 @@ 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 ---
|
||||
[DnsmasqConfKeys.NoHosts] = "Do not read /etc/hosts; only use addn-hosts and DHCP/hosts.",
|
||||
[DnsmasqConfKeys.AddnHosts] = "Additional hosts files (one per line).",
|
||||
[DnsmasqConfKeys.Hostsdir] = "Directory of hosts files; new/changed files are read automatically.",
|
||||
[DnsmasqConfKeys.ReadEthers] = "Read /etc/ethers for MAC→IP mappings (like dhcp-host).",
|
||||
|
||||
// --- Resolver / DNS ---
|
||||
[DnsmasqConfKeys.ExpandHosts] = "Expand simple names in /etc/hosts with the local domain.",
|
||||
[DnsmasqConfKeys.BogusPriv] = "Do not forward reverse lookups for private IP ranges.",
|
||||
[DnsmasqConfKeys.StrictOrder] = "Query servers in the order specified; do not try the next on failure.",
|
||||
[DnsmasqConfKeys.AllServers] = "Send each query to all upstream servers; use the first reply.",
|
||||
[DnsmasqConfKeys.NoResolv] = "Do not read /etc/resolv.conf; use only server= and resolv-file=.",
|
||||
[DnsmasqConfKeys.DomainNeeded] = "Never forward plain names (without a domain) to upstream.",
|
||||
[DnsmasqConfKeys.Port] = "Listen on this port for DNS queries (default 53).",
|
||||
[DnsmasqConfKeys.LogQueries] = "Log DNS queries. Optional value: extra, proto, or auth.",
|
||||
[ServerLocalLabel] = "Upstream DNS servers (server=) and local-only domains (local=). Order is preserved.",
|
||||
[DnsmasqConfKeys.RevServer] = "Reverse DNS server (e.g. rev-server=1.2.3.0/24,192.168.0.1). Can repeat.",
|
||||
[DnsmasqConfKeys.Address] = "Map a domain or hostname to an IP (e.g. for ad blocking or local names). Can repeat.",
|
||||
[DnsmasqConfKeys.ResolvFile] = "File(s) to read upstream server addresses from (e.g. from DHCP). Can repeat.",
|
||||
[DnsmasqConfKeys.AuthTtl] = "TTL for answers from the authoritative server.",
|
||||
[DnsmasqConfKeys.EdnsPacketMax] = "Largest EDNS.0 UDP packet size (default 1232).",
|
||||
[DnsmasqConfKeys.QueryPort] = "Source port for outbound DNS queries (0 = single OS-allocated port).",
|
||||
[DnsmasqConfKeys.PortLimit] = "Number of source ports for outbound queries.",
|
||||
[DnsmasqConfKeys.MinPort] = "Minimum source port for outbound DNS queries.",
|
||||
[DnsmasqConfKeys.MaxPort] = "Maximum source port for outbound DNS queries.",
|
||||
[DnsmasqConfKeys.DnsLoopDetect] = "Detect DNS forwarding loops and disable bad upstream servers.",
|
||||
[DnsmasqConfKeys.StopDnsRebind] = "Reject private-range addresses from upstream (anti-rebind).",
|
||||
[DnsmasqConfKeys.RebindLocalhostOk] = "Allow 127.0.0.0/8 and ::1 in upstream replies.",
|
||||
[DnsmasqConfKeys.ClearOnReload] = "Clear DNS cache when resolv.conf or servers are reloaded.",
|
||||
[DnsmasqConfKeys.Filterwin2k] = "Filter Windows LDAP/SOA/SRV queries that trigger dial-on-demand.",
|
||||
[DnsmasqConfKeys.FilterA] = "Remove A (IPv4) records from answers.",
|
||||
[DnsmasqConfKeys.FilterAaaa] = "Remove AAAA (IPv6) records from answers.",
|
||||
[DnsmasqConfKeys.LocaliseQueries] = "Return interface-specific answers from hosts/DHCP.",
|
||||
[DnsmasqConfKeys.FastDnsRetry] = "DNS retry parameters: initial delay (ms) and optional continue time (ms).",
|
||||
[DnsmasqConfKeys.RebindDomainOk] = "Domains exempt from rebind checks. Can repeat.",
|
||||
[DnsmasqConfKeys.BogusNxdomain] = "Transform replies containing these IPs to NXDOMAIN. Can repeat.",
|
||||
[DnsmasqConfKeys.IgnoreAddress] = "Ignore A/AAAA replies containing these IPs. Can repeat.",
|
||||
[DnsmasqConfKeys.Alias] = "Rewrite IPs in upstream replies (DNS doctoring). Can repeat.",
|
||||
[DnsmasqConfKeys.FilterRr] = "Remove these RR types from answers. Can repeat.",
|
||||
[DnsmasqConfKeys.CacheRr] = "Cache these RR types in addition to default. Can repeat.",
|
||||
[DnsmasqConfKeys.AuthServer] = "Authoritative DNS mode (domain, interface/IP). Can repeat.",
|
||||
[DnsmasqConfKeys.NoDhcpInterface] = "No DHCP/TFTP/RA on these interfaces; DNS only. Can repeat.",
|
||||
[DnsmasqConfKeys.NoDhcpv4Interface] = "No DHCPv4 on these interfaces. Can repeat.",
|
||||
[DnsmasqConfKeys.NoDhcpv6Interface] = "No DHCPv6/RA on these interfaces. Can repeat.",
|
||||
[DnsmasqConfKeys.Ipset] = "Add resolved IPs to ipset (domain, set names). Can repeat.",
|
||||
[DnsmasqConfKeys.Nftset] = "Add resolved IPs to nftables sets. Can repeat.",
|
||||
|
||||
// --- DNS records ---
|
||||
[DnsmasqConfKeys.Domain] = "Local domain(s); optional IP. Can repeat (e.g. domain=home,192.168.1.1).",
|
||||
[DnsmasqConfKeys.Cname] = "CNAME records (alias, target). Can repeat.",
|
||||
[DnsmasqConfKeys.MxHost] = "MX records (domain, host, priority). Can repeat.",
|
||||
[DnsmasqConfKeys.MxTarget] = "Default target for MX records (used with localmx).",
|
||||
[DnsmasqConfKeys.Localmx] = "Serve MX for local machines using mx-target.",
|
||||
[DnsmasqConfKeys.Selfmx] = "Serve MX pointing to self for local machines.",
|
||||
[DnsmasqConfKeys.Srv] = "SRV records. Can repeat.",
|
||||
[DnsmasqConfKeys.PtrRecord] = "PTR records for reverse DNS. Can repeat.",
|
||||
[DnsmasqConfKeys.TxtRecord] = "TXT records. Can repeat.",
|
||||
[DnsmasqConfKeys.NaptrRecord] = "NAPTR records. Can repeat.",
|
||||
[DnsmasqConfKeys.HostRecord] = "A/AAAA host records (name, IP). Can repeat.",
|
||||
[DnsmasqConfKeys.DynamicHost] = "Dynamic host entries. Can repeat.",
|
||||
[DnsmasqConfKeys.InterfaceName] = "Name per interface (for localise-queries). Can repeat.",
|
||||
|
||||
// --- DHCP ---
|
||||
[DnsmasqConfKeys.DhcpAuthoritative] = "DHCP server is authoritative; required for DHCP to work.",
|
||||
[DnsmasqConfKeys.DhcpRapidCommit] = "DHCPv4 Rapid Commit (RFC 4039); use only if single server or all commit.",
|
||||
[DnsmasqConfKeys.DhcpScript] = "Script run on lease add/delete (add|del, MAC, IP, hostname).",
|
||||
[DnsmasqConfKeys.LeasefileRo] = "Do not create or truncate the DHCP lease file.",
|
||||
[DnsmasqConfKeys.DhcpLeasefile] = "Path to the DHCP lease file.",
|
||||
[DnsmasqConfKeys.DhcpLeaseMax] = "Maximum number of DHCP leases to hold.",
|
||||
@@ -37,6 +90,34 @@ public static class DnsmasqOptionTooltips
|
||||
[DnsmasqConfKeys.DhcpRange] = "DHCP address range(s) and lease time. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpHost] = "DHCP host entries (reservations by MAC or client-id). Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpOption] = "DHCP option lines sent to clients. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpOptionForce] = "Like dhcp-option but force send even if not in parameter request list. Can repeat.",
|
||||
[DnsmasqConfKeys.DhcpMatch] = "Match DHCP clients (e.g. set tag from option). Can repeat.",
|
||||
[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.",
|
||||
[DnsmasqConfKeys.DhcpUserclass] = "Match by DHCP user class. Can repeat.",
|
||||
[DnsmasqConfKeys.RaParam] = "Router advertisement parameters (DHCPv6/RA). Can repeat.",
|
||||
[DnsmasqConfKeys.Slaac] = "SLAAC host part for DHCPv6/RA. Can repeat.",
|
||||
|
||||
// --- TFTP / PXE ---
|
||||
[DnsmasqConfKeys.EnableTftp] = "Enable the built-in TFTP server.",
|
||||
[DnsmasqConfKeys.TftpSecure] = "Only allow TFTP access from DHCP-assigned clients.",
|
||||
[DnsmasqConfKeys.TftpNoFail] = "Do not abort if tftp-root is missing.",
|
||||
[DnsmasqConfKeys.TftpNoBlocksize] = "Do not negotiate TFTP blocksize (for broken clients).",
|
||||
[DnsmasqConfKeys.TftpRoot] = "Root directory for TFTP files.",
|
||||
[DnsmasqConfKeys.PxePrompt] = "PXE boot prompt (e.g. timeout,0 to disable).",
|
||||
[DnsmasqConfKeys.PxeService] = "PXE service definitions. Can repeat.",
|
||||
|
||||
// --- DNSSEC ---
|
||||
[DnsmasqConfKeys.Dnssec] = "Validate DNSSEC on replies from upstream.",
|
||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = "Treat unsigned zones as bogus.",
|
||||
[DnsmasqConfKeys.TrustAnchor] = "DNSSEC trust anchors. Can repeat.",
|
||||
|
||||
// --- Cache ---
|
||||
[DnsmasqConfKeys.CacheSize] = "Maximum number of DNS cache entries (0 to disable).",
|
||||
@@ -57,6 +138,17 @@ public static class DnsmasqOptionTooltips
|
||||
[DnsmasqConfKeys.User] = "Run as this user after startup (requires root).",
|
||||
[DnsmasqConfKeys.Group] = "Run as this group after startup (requires root).",
|
||||
[DnsmasqConfKeys.LogFacility] = "Syslog facility for dnsmasq logs (e.g. local0, daemon).",
|
||||
[DnsmasqConfKeys.LogDebug] = "Extra debug logging.",
|
||||
[DnsmasqConfKeys.LogAsync] = "Asynchronous logging; optional queue size.",
|
||||
[DnsmasqConfKeys.BindDynamic] = "Bind per-interface; auto-add new interfaces (Linux).",
|
||||
[DnsmasqConfKeys.LocalService] = "Restrict to local network or localhost (net|host).",
|
||||
[DnsmasqConfKeys.EnableDbus] = "Enable DBus interface (optional service name).",
|
||||
[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,11 +27,15 @@ public static class EffectiveConfigFieldBuilder
|
||||
};
|
||||
}
|
||||
|
||||
public const string SectionHosts = "hosts";
|
||||
public const string SectionResolver = "resolver";
|
||||
public const string SectionDhcp = "dhcp";
|
||||
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)
|
||||
{
|
||||
@@ -40,60 +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)));
|
||||
// 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 ---
|
||||
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(Single(SectionResolver, DnsmasqConfKeys.StrictOrder, status, s => Config(s)?.StrictOrder, s => Sources(s)?.StrictOrder, null));
|
||||
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(Multi(SectionResolver, "server / local", status, Items(ec => ec?.ServerLocalValues, src => src?.ServerLocalValues)));
|
||||
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)));
|
||||
// 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)));
|
||||
|
||||
// --- DHCP ---
|
||||
list.Add(Single(SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null));
|
||||
list.Add(Single(SectionDhcp, DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, 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)));
|
||||
// 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));
|
||||
|
||||
// --- Cache ---
|
||||
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));
|
||||
// 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)));
|
||||
|
||||
// --- 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(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(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));
|
||||
// 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(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(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(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);
|
||||
}
|
||||
|
||||
@@ -105,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,30 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for resolving app logs config. Uses ExcludedCategoryPrefixesOverrides when present (avoids .NET config array merge);
|
||||
/// otherwise falls back to ExcludedCategoryPrefixes from appsettings.json.
|
||||
/// Uses a sentinel value when user explicitly clears all filters, since .NET config returns null for empty arrays.
|
||||
/// </summary>
|
||||
public static class AppLogsConfigHelper
|
||||
{
|
||||
private const string OverridesKey = "AppLogs:ExcludedCategoryPrefixesOverrides";
|
||||
private const string DefaultsKey = "AppLogs:ExcludedCategoryPrefixes";
|
||||
|
||||
/// <summary>Sentinel value stored when user clears all filters. Filtered out from results; extremely unlikely to match real categories.</summary>
|
||||
public const string ExplicitlyEmptySentinel = "__AppLogsExplicitlyEmpty__";
|
||||
|
||||
/// <summary>Gets effective excluded category prefixes from configuration.</summary>
|
||||
public static List<string> GetEffectiveExcludedPrefixes(IConfiguration configuration)
|
||||
{
|
||||
var overrides = configuration.GetSection(OverridesKey).Get<List<string>>();
|
||||
if (overrides != null)
|
||||
return overrides.Where(s => s != ExplicitlyEmptySentinel).ToList();
|
||||
return GetDefaultExcludedPrefixes(configuration);
|
||||
}
|
||||
|
||||
/// <summary>Gets default excluded category prefixes from appsettings.json (ignores overrides).</summary>
|
||||
public static List<string> GetDefaultExcludedPrefixes(IConfiguration configuration) =>
|
||||
configuration.GetSection(DefaultsKey).Get<List<string>>() ?? [];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Threading.Channels;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// ILoggerProvider that forwards log entries to IAppLogsBuffer for real-time display.
|
||||
/// Signals the push channel when logs are written so the hosted service can push to clients.
|
||||
/// Filter is evaluated at Log() time (not CreateLogger) so runtime config changes take effect despite LoggerFactory caching.
|
||||
/// </summary>
|
||||
public sealed class AppLogsLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly IAppLogsBuffer _buffer;
|
||||
private readonly Channel<byte> _pushChannel;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AppLogsLoggerProvider(
|
||||
IAppLogsBuffer buffer,
|
||||
Channel<byte> pushChannel,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_buffer = buffer;
|
||||
_pushChannel = pushChannel;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) =>
|
||||
new FilteringAppLogsLogger(categoryName, _buffer, _pushChannel, _configuration);
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
private sealed class FilteringAppLogsLogger : ILogger
|
||||
{
|
||||
private readonly string _category;
|
||||
private readonly IAppLogsBuffer _buffer;
|
||||
private readonly Channel<byte> _pushChannel;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public FilteringAppLogsLogger(string category, IAppLogsBuffer buffer, Channel<byte> pushChannel, IConfiguration configuration)
|
||||
{
|
||||
_category = category;
|
||||
_buffer = buffer;
|
||||
_pushChannel = pushChannel;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (IsExcluded(_category))
|
||||
return;
|
||||
|
||||
var msg = formatter(state, exception);
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
var level = logLevel switch
|
||||
{
|
||||
LogLevel.Trace => "TRC",
|
||||
LogLevel.Debug => "DBG",
|
||||
LogLevel.Information => "INF",
|
||||
LogLevel.Warning => "WRN",
|
||||
LogLevel.Error => "ERR",
|
||||
LogLevel.Critical => "CRT",
|
||||
_ => "???"
|
||||
};
|
||||
var line = $"{timestamp} [{level}] {_category}: {msg}";
|
||||
if (exception != null)
|
||||
line += $"{Environment.NewLine}{exception}";
|
||||
_buffer.Enqueue(line);
|
||||
_pushChannel.Writer.TryWrite(0);
|
||||
}
|
||||
|
||||
private bool IsExcluded(string category)
|
||||
{
|
||||
var prefixes = AppLogsConfigHelper.GetEffectiveExcludedPrefixes(_configuration);
|
||||
if (prefixes.Count == 0) return false;
|
||||
foreach (var prefix in prefixes)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(prefix) && category.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Ring buffer of recent app log lines. Filled by ILoggerProvider, drained for SignalR push.
|
||||
/// </summary>
|
||||
public interface IAppLogsBuffer
|
||||
{
|
||||
/// <summary>Returns up to maxLines most recent lines (oldest first).</summary>
|
||||
IReadOnlyList<string> GetRecent(int maxLines = 500);
|
||||
|
||||
/// <summary>Enqueues a formatted log line. Called by the logging provider.</summary>
|
||||
void Enqueue(string line);
|
||||
|
||||
/// <summary>Returns and clears pending lines for streaming push. Called by pusher.</summary>
|
||||
IReadOnlyList<string> DrainPending();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Runs LogsCommand, diffs output, chunks by lines, and pushes via SignalR.
|
||||
/// Triggered by client (RequestDnsmasqLogs) or file watcher when LogsPath changes.
|
||||
/// App logs: event-driven push when provider writes; snapshot on connect; poll fallback.
|
||||
/// </summary>
|
||||
public interface ILogsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs LogsCommand, diffs against cache, and pushes DnsmasqLogsUpdate to all connected clients.
|
||||
/// </summary>
|
||||
Task RunAndPushDnsmasqLogsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes recent app logs from buffer to all clients (replace). Called when client connects.
|
||||
/// </summary>
|
||||
Task PushAppLogsSnapshotAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes pending app log lines as append to all clients. Event-driven from provider; poll fallback.
|
||||
/// </summary>
|
||||
Task PushAppLogsPendingAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -7,4 +7,7 @@ public interface IProcessRunner : IApplicationScopedService
|
||||
{
|
||||
/// <summary>Runs a shell command with timeout and cancellation. Returns exit code (null if timed out or failed to start), stdout, stderr, and whether the run timed out.</summary>
|
||||
Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Same as RunAsync but caps stdout/stderr length. When exceeded, truncates and appends a notice.</summary>
|
||||
Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, int? maxOutputChars, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped service to open the app-level settings modal from anywhere (e.g. NavMenu, pages).
|
||||
/// MainLayout subscribes to <see cref="OpenRequested"/> and hosts the modal.
|
||||
/// Subscribe to <see cref="SettingsChanged"/> to refresh when settings are saved/closed.
|
||||
/// </summary>
|
||||
public interface ISettingsModalService
|
||||
{
|
||||
/// <summary>Raised when <see cref="Open"/> is called. MainLayout subscribes and shows the modal.</summary>
|
||||
event Action<SettingsModalContext, string>? OpenRequested;
|
||||
|
||||
/// <summary>Raised when the modal is closed (save or cancel). Pages subscribe to reload their intervals.</summary>
|
||||
event Action? SettingsChanged;
|
||||
|
||||
/// <summary>Opens the settings modal with the given context and title.</summary>
|
||||
void Open(SettingsModalContext context, string title);
|
||||
|
||||
/// <summary>Called when the modal closes. Raises <see cref="SettingsChanged"/>.</summary>
|
||||
void NotifySettingsChanged();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
/// <inheritdoc cref="Abstractions.IAppLogsBuffer" />
|
||||
public sealed class AppLogsBuffer : Abstractions.IAppLogsBuffer
|
||||
{
|
||||
private const int MaxLines = 1000;
|
||||
private readonly List<string> _lines = [];
|
||||
private readonly List<string> _pending = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
public IReadOnlyList<string> GetRecent(int maxLines = 500)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_lines.Count <= maxLines)
|
||||
return _lines.ToList();
|
||||
return _lines.TakeLast(maxLines).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public void Enqueue(string line)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_lines.Add(line);
|
||||
_pending.Add(line);
|
||||
while (_lines.Count > MaxLines)
|
||||
_lines.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> DrainPending()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_pending.Count == 0)
|
||||
return [];
|
||||
var copy = _pending.ToList();
|
||||
_pending.Clear();
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Threading.Channels;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Listens for signals from AppLogsLoggerProvider; drains pending app logs and pushes via LogsService.
|
||||
/// Event-driven: push happens when logs are written, with a short debounce to batch rapid writes.
|
||||
/// </summary>
|
||||
public sealed class AppLogsPushTriggeredHostedService : IApplicationHostedService
|
||||
{
|
||||
private const int DebounceMs = 50;
|
||||
|
||||
private readonly Channel<byte> _channel;
|
||||
private readonly ILogsService _logsService;
|
||||
private readonly ILogger<AppLogsPushTriggeredHostedService> _logger;
|
||||
|
||||
public AppLogsPushTriggeredHostedService(
|
||||
Channel<byte> appLogsPushChannel,
|
||||
ILogsService logsService,
|
||||
ILogger<AppLogsPushTriggeredHostedService> logger)
|
||||
{
|
||||
_channel = appLogsPushChannel;
|
||||
_logsService = logsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_ = RunLoopAsync(ct);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var _ in _channel.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
await Task.Delay(DebounceMs, ct);
|
||||
while (_channel.Reader.TryRead(out byte discarded)) { } // Drain extra signals during debounce
|
||||
try
|
||||
{
|
||||
await _logsService.PushAppLogsPendingAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error pushing app logs");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutdown; ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
/// <summary>Logs application start and stop with runtime and configuration context.</summary>
|
||||
public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHostedService
|
||||
{
|
||||
private readonly ILogger<ApplicationLifecycleLoggingHostedService> _logger;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly DnsmasqOptions _dnsmasq;
|
||||
private readonly DateTimeOffset _startedAt;
|
||||
|
||||
public ApplicationLifecycleLoggingHostedService(
|
||||
ILogger<ApplicationLifecycleLoggingHostedService> logger,
|
||||
IHostEnvironment env,
|
||||
IOptions<DnsmasqOptions> dnsmasq)
|
||||
{
|
||||
_logger = logger;
|
||||
_env = env;
|
||||
_dnsmasq = dnsmasq.Value;
|
||||
_startedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var version = typeof(ApplicationLifecycleLoggingHostedService).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "?";
|
||||
var mainDir = Path.GetDirectoryName(Path.GetFullPath(_dnsmasq.MainConfigPath)) ?? "";
|
||||
var managedConfigPath = Path.Combine(mainDir, _dnsmasq.ManagedFileName);
|
||||
var managedHostsPath = Path.Combine(mainDir, _dnsmasq.ManagedHostsFileName);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Application started: version={Version}, env={Environment}, os={OS}, arch={Arch}. " +
|
||||
"MainConfig={MainConfig}, managedConfig={ManagedConfig}, managedHosts={ManagedHosts}, systemHosts={SystemHosts}. " +
|
||||
"ReloadCommand={ReloadCmd}, StatusCommand={StatusCmd}",
|
||||
version,
|
||||
_env.EnvironmentName,
|
||||
RuntimeInformation.OSDescription.Trim(),
|
||||
RuntimeInformation.OSArchitecture,
|
||||
_dnsmasq.MainConfigPath,
|
||||
managedConfigPath,
|
||||
managedHostsPath,
|
||||
_dnsmasq.SystemHostsPath ?? "(not set)",
|
||||
string.IsNullOrWhiteSpace(_dnsmasq.ReloadCommand) ? "(not set)" : _dnsmasq.ReloadCommand,
|
||||
string.IsNullOrWhiteSpace(_dnsmasq.StatusCommand) ? "(not set)" : _dnsmasq.StatusCommand);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var uptime = DateTimeOffset.UtcNow - _startedAt;
|
||||
_logger.LogInformation("Application stopping: uptime={Uptime:F1}s", uptime.TotalSeconds);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
public sealed class ClientSettingsService : IClientSettingsService, IAsyncDisposable
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly ILogger<ClientSettingsService> _logger;
|
||||
private IJSObjectReference? _module;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -20,13 +21,23 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public ClientSettingsService(IJSRuntime jsRuntime) => _jsRuntime = jsRuntime;
|
||||
public ClientSettingsService(IJSRuntime jsRuntime, ILogger<ClientSettingsService> logger)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private async Task<IJSObjectReference> GetModuleAsync()
|
||||
private async Task<IJSObjectReference?> GetModuleAsync()
|
||||
{
|
||||
if (_module != null) return _module;
|
||||
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./js/client-settings-storage.js");
|
||||
try
|
||||
{
|
||||
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./js/client-settings-storage.js");
|
||||
}
|
||||
catch (InvalidOperationException ex) { _logger.LogDebug(ex, "JS import skipped: prerender (no JS available)"); return null; }
|
||||
catch (JSDisconnectedException ex) { _logger.LogDebug(ex, "JS import skipped: circuit disconnected"); return null; }
|
||||
catch (JSException ex) { _logger.LogDebug(ex, "JS import failed"); return null; }
|
||||
return _module;
|
||||
}
|
||||
|
||||
@@ -34,23 +45,34 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
|
||||
public async Task<ClientSettings> LoadSettingsAsync()
|
||||
{
|
||||
var module = await GetModuleAsync();
|
||||
if (module == null) return new ClientSettings();
|
||||
var json = await module.InvokeAsync<string?>("getItem");
|
||||
if (string.IsNullOrWhiteSpace(json)) return new ClientSettings();
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
var defaults = new ClientSettings();
|
||||
ClientSettingsFields.HydrateFrom(defaults);
|
||||
return defaults;
|
||||
}
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ClientSettings>(json, JsonOptions) ?? new ClientSettings();
|
||||
var settings = JsonSerializer.Deserialize<ClientSettings>(json, JsonOptions) ?? new ClientSettings();
|
||||
ClientSettingsFields.HydrateFrom(settings);
|
||||
return settings;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ClientSettings();
|
||||
var defaults = new ClientSettings();
|
||||
ClientSettingsFields.HydrateFrom(defaults);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveSettingsAsync(ClientSettings settings)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(settings, JsonOptions);
|
||||
var module = await GetModuleAsync();
|
||||
if (module == null) return;
|
||||
var json = JsonSerializer.Serialize(settings, JsonOptions);
|
||||
await module.InvokeVoidAsync("setItem", json);
|
||||
}
|
||||
|
||||
@@ -60,13 +82,10 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_module == null) return;
|
||||
try
|
||||
{
|
||||
await _module.DisposeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_module = null;
|
||||
}
|
||||
try { await _module.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { _logger.LogDebug(ex, "JS module DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { _logger.LogDebug(ex, "JS module DisposeAsync skipped: circuit disconnected"); }
|
||||
catch (JSException ex) { _logger.LogDebug(ex, "JS module DisposeAsync failed"); }
|
||||
finally { _module = null; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +218,10 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
{
|
||||
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths, pathToLines);
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths, pathToLines);
|
||||
var (hostsdirVal, hostsdirDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Hostsdir);
|
||||
var hostsdirPath = string.IsNullOrWhiteSpace(hostsdirVal) ? null : DnsmasqConfIncludeParser.ResolvePath(hostsdirVal?.Trim(), hostsdirDir);
|
||||
var serverLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var revServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.RevServer);
|
||||
var addressValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Address);
|
||||
var interfaces = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Interface);
|
||||
var listenAddresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ListenAddress);
|
||||
@@ -226,18 +229,82 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var dhcpRanges = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpRange);
|
||||
var dhcpHostLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpHost);
|
||||
var dhcpOptionLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpOption);
|
||||
var dhcpMatchValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpMatch);
|
||||
var dhcpBootValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpBoot);
|
||||
var dhcpIgnoreValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpIgnore);
|
||||
var dhcpVendorclassValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpVendorclass);
|
||||
var dhcpUserclassValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpUserclass);
|
||||
var raParamValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.RaParam);
|
||||
var slaacValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Slaac);
|
||||
var pxeServiceValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.PxeService);
|
||||
var trustAnchorValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TrustAnchor);
|
||||
var resolvFiles = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ResolvFile);
|
||||
var rebindDomainOk = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.RebindDomainOk);
|
||||
var bogusNxdomain = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.BogusNxdomain);
|
||||
var ignoreAddress = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.IgnoreAddress);
|
||||
var alias = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Alias);
|
||||
var filterRr = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.FilterRr);
|
||||
var cacheRr = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.CacheRr);
|
||||
var authServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.AuthServer);
|
||||
var noDhcpInterface = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoDhcpInterface);
|
||||
var noDhcpv4Interface = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoDhcpv4Interface);
|
||||
var noDhcpv6Interface = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoDhcpv6Interface);
|
||||
var domainValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Domain);
|
||||
var cnameValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Cname);
|
||||
var mxHostValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MxHost);
|
||||
var srvValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Srv);
|
||||
var ptrRecordValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.PtrRecord);
|
||||
var txtRecordValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TxtRecord);
|
||||
var naptrRecordValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NaptrRecord);
|
||||
var hostRecordValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.HostRecord);
|
||||
var dynamicHostValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DynamicHost);
|
||||
var interfaceNameValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.InterfaceName);
|
||||
var dhcpOptionForceLines = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpOptionForce);
|
||||
var ipsetValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Ipset);
|
||||
var nftsetValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Nftset);
|
||||
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);
|
||||
var strictOrder = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.StrictOrder);
|
||||
var allServers = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.AllServers);
|
||||
var noResolv = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoResolv);
|
||||
var domainNeeded = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DomainNeeded);
|
||||
var noPoll = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoPoll);
|
||||
var bindInterfaces = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.BindInterfaces);
|
||||
var bindDynamic = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.BindDynamic);
|
||||
var noNegcache = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NoNegcache);
|
||||
var dnsLoopDetect = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DnsLoopDetect);
|
||||
var stopDnsRebind = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.StopDnsRebind);
|
||||
var rebindLocalhostOk = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.RebindLocalhostOk);
|
||||
var clearOnReload = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ClearOnReload);
|
||||
var filterwin2k = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Filterwin2k);
|
||||
var filterA = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.FilterA);
|
||||
var filterAaaa = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.FilterAaaa);
|
||||
var localiseQueries = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LocaliseQueries);
|
||||
var logDebug = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogDebug);
|
||||
var dhcpAuthoritative = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpAuthoritative);
|
||||
var leasefileRo = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LeasefileRo);
|
||||
var enableTftp = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.EnableTftp);
|
||||
var tftpSecure = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TftpSecure);
|
||||
var dnssec = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Dnssec);
|
||||
var dnssecCheckUnsigned = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned);
|
||||
var readEthers = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ReadEthers);
|
||||
var dhcpRapidCommit = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpRapidCommit);
|
||||
var tftpNoFail = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TftpNoFail);
|
||||
var tftpNoBlocksize = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TftpNoBlocksize);
|
||||
var localmx = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Localmx);
|
||||
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);
|
||||
|
||||
@@ -255,6 +322,24 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var group = string.IsNullOrWhiteSpace(groupVal) ? null : groupVal.Trim();
|
||||
var (logFacVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogFacility);
|
||||
var logFacility = string.IsNullOrWhiteSpace(logFacVal) ? null : logFacVal.Trim();
|
||||
var (logQueriesVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogQueries);
|
||||
var logQueries = string.IsNullOrWhiteSpace(logQueriesVal) ? null : logQueriesVal.Trim();
|
||||
var (authTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.AuthTtl);
|
||||
var authTtl = TryParseInt(authTtlVal);
|
||||
var (ednsPacketMaxVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.EdnsPacketMax);
|
||||
var ednsPacketMax = TryParseInt(ednsPacketMaxVal);
|
||||
var (queryPortVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.QueryPort);
|
||||
var queryPort = TryParseInt(queryPortVal);
|
||||
var (portLimitVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.PortLimit);
|
||||
var portLimit = TryParseInt(portLimitVal);
|
||||
var (minPortVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MinPort);
|
||||
var minPort = TryParseInt(minPortVal);
|
||||
var (maxPortVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.MaxPort);
|
||||
var maxPort = TryParseInt(maxPortVal);
|
||||
var (logAsyncVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LogAsync);
|
||||
var logAsync = string.IsNullOrWhiteSpace(logAsyncVal) ? null : logAsyncVal.Trim();
|
||||
var (localServiceVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.LocalService);
|
||||
var localService = string.IsNullOrWhiteSpace(localServiceVal) ? null : localServiceVal.Trim();
|
||||
var (leaseMaxVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpLeaseMax);
|
||||
var dhcpLeaseMax = TryParseInt(leaseMaxVal);
|
||||
var (negTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.NegTtl);
|
||||
@@ -267,13 +352,39 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var minCacheTtl = TryParseInt(minCacheTtlVal);
|
||||
var (dhcpTtlVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpTtl);
|
||||
var dhcpTtl = TryParseInt(dhcpTtlVal);
|
||||
var (tftpRootVal, tftpRootDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.TftpRoot);
|
||||
var tftpRootPath = string.IsNullOrWhiteSpace(tftpRootVal) ? null : DnsmasqConfIncludeParser.ResolvePath(tftpRootVal?.Trim(), tftpRootDir);
|
||||
var (pxePromptVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.PxePrompt);
|
||||
var pxePrompt = string.IsNullOrWhiteSpace(pxePromptVal) ? null : pxePromptVal.Trim();
|
||||
var (enableDbusVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.EnableDbus);
|
||||
var enableDbus = string.IsNullOrWhiteSpace(enableDbusVal) ? null : enableDbusVal.Trim();
|
||||
var (enableUbusVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.EnableUbus);
|
||||
var enableUbus = string.IsNullOrWhiteSpace(enableUbusVal) ? null : enableUbusVal.Trim();
|
||||
var (fastDnsRetryVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.FastDnsRetry);
|
||||
var fastDnsRetry = string.IsNullOrWhiteSpace(fastDnsRetryVal) ? null : fastDnsRetryVal.Trim();
|
||||
var (dhcpScriptVal, dhcpScriptDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.DhcpScript);
|
||||
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,
|
||||
serverLocal, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines, resolvFiles,
|
||||
expandHosts, bogusPriv, strictOrder, noResolv, domainNeeded, noPoll, bindInterfaces, noNegcache, dhcpAuthoritative, leasefileRo,
|
||||
dhcpLeaseFilePath, cacheSize, port, localTtl, pidFilePath, user, group, logFacility, dhcpLeaseMax,
|
||||
negTtl, maxTtl, maxCacheTtl, minCacheTtl, dhcpTtl
|
||||
noHosts, addnHosts, hostsdirPath,
|
||||
serverLocal, revServer, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines,
|
||||
dhcpMatchValues, dhcpBootValues, dhcpIgnoreValues, dhcpVendorclassValues, dhcpUserclassValues, raParamValues, slaacValues, pxeServiceValues, trustAnchorValues, resolvFiles,
|
||||
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, conntrack
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,7 +392,9 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
{
|
||||
var (_, noHostsSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoHosts, managedFilePath);
|
||||
var addnHostsWithSource = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(paths, pathToLines, managedFilePath);
|
||||
var (_, hostsdirPathSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Hostsdir, managedFilePath);
|
||||
var serverLocalWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys, managedFilePath);
|
||||
var revServerWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.RevServer, managedFilePath);
|
||||
var addressWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Address, managedFilePath);
|
||||
var interfacesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Interface, managedFilePath);
|
||||
var listenAddressesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ListenAddress, managedFilePath);
|
||||
@@ -289,18 +402,82 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var dhcpRangesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpRange, managedFilePath);
|
||||
var dhcpHostLinesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpHost, managedFilePath);
|
||||
var dhcpOptionLinesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOption, managedFilePath);
|
||||
var dhcpMatchWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpMatch, managedFilePath);
|
||||
var dhcpBootWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpBoot, managedFilePath);
|
||||
var dhcpIgnoreWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpIgnore, managedFilePath);
|
||||
var dhcpVendorclassWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpVendorclass, managedFilePath);
|
||||
var dhcpUserclassWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpUserclass, managedFilePath);
|
||||
var raParamWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.RaParam, managedFilePath);
|
||||
var slaacWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Slaac, managedFilePath);
|
||||
var pxeServiceWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.PxeService, managedFilePath);
|
||||
var trustAnchorWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TrustAnchor, managedFilePath);
|
||||
var resolvFilesWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ResolvFile, managedFilePath);
|
||||
var rebindDomainOkWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.RebindDomainOk, managedFilePath);
|
||||
var bogusNxdomainWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.BogusNxdomain, managedFilePath);
|
||||
var ignoreAddressWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.IgnoreAddress, managedFilePath);
|
||||
var aliasWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Alias, managedFilePath);
|
||||
var filterRrWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.FilterRr, managedFilePath);
|
||||
var cacheRrWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.CacheRr, managedFilePath);
|
||||
var authServerWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.AuthServer, managedFilePath);
|
||||
var noDhcpInterfaceWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoDhcpInterface, managedFilePath);
|
||||
var noDhcpv4InterfaceWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoDhcpv4Interface, managedFilePath);
|
||||
var noDhcpv6InterfaceWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoDhcpv6Interface, managedFilePath);
|
||||
var domainWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Domain, managedFilePath);
|
||||
var cnameWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Cname, managedFilePath);
|
||||
var mxHostWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MxHost, managedFilePath);
|
||||
var srvWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Srv, managedFilePath);
|
||||
var ptrRecordWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.PtrRecord, managedFilePath);
|
||||
var txtRecordWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TxtRecord, managedFilePath);
|
||||
var naptrRecordWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NaptrRecord, managedFilePath);
|
||||
var hostRecordWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.HostRecord, managedFilePath);
|
||||
var dynamicHostWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DynamicHost, managedFilePath);
|
||||
var interfaceNameWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.InterfaceName, managedFilePath);
|
||||
var dhcpOptionForceWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpOptionForce, managedFilePath);
|
||||
var ipsetWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Ipset, managedFilePath);
|
||||
var nftsetWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Nftset, managedFilePath);
|
||||
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);
|
||||
var (_, strictOrderSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.StrictOrder, managedFilePath);
|
||||
var (_, allServersSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.AllServers, managedFilePath);
|
||||
var (_, noResolvSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoResolv, managedFilePath);
|
||||
var (_, domainNeededSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DomainNeeded, managedFilePath);
|
||||
var (_, noPollSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoPoll, managedFilePath);
|
||||
var (_, bindInterfacesSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.BindInterfaces, managedFilePath);
|
||||
var (_, bindDynamicSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.BindDynamic, managedFilePath);
|
||||
var (_, noNegcacheSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NoNegcache, managedFilePath);
|
||||
var (_, dnsLoopDetectSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DnsLoopDetect, managedFilePath);
|
||||
var (_, stopDnsRebindSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.StopDnsRebind, managedFilePath);
|
||||
var (_, rebindLocalhostOkSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.RebindLocalhostOk, managedFilePath);
|
||||
var (_, clearOnReloadSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ClearOnReload, managedFilePath);
|
||||
var (_, filterwin2kSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Filterwin2k, managedFilePath);
|
||||
var (_, filterASource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.FilterA, managedFilePath);
|
||||
var (_, filterAaaaSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.FilterAaaa, managedFilePath);
|
||||
var (_, localiseQueriesSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LocaliseQueries, managedFilePath);
|
||||
var (_, logDebugSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogDebug, managedFilePath);
|
||||
var (_, dhcpAuthoritativeSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpAuthoritative, managedFilePath);
|
||||
var (_, leasefileRoSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LeasefileRo, managedFilePath);
|
||||
var (_, enableTftpSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.EnableTftp, managedFilePath);
|
||||
var (_, tftpSecureSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TftpSecure, managedFilePath);
|
||||
var (_, dnssecSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Dnssec, managedFilePath);
|
||||
var (_, dnssecCheckUnsignedSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DnssecCheckUnsigned, managedFilePath);
|
||||
var (_, readEthersSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ReadEthers, managedFilePath);
|
||||
var (_, dhcpRapidCommitSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpRapidCommit, managedFilePath);
|
||||
var (_, tftpNoFailSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TftpNoFail, managedFilePath);
|
||||
var (_, tftpNoBlocksizeSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TftpNoBlocksize, managedFilePath);
|
||||
var (_, localmxSource) = DnsmasqConfIncludeParser.GetFlagFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Localmx, managedFilePath);
|
||||
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);
|
||||
@@ -310,16 +487,34 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
var (_, userSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.User, managedFilePath);
|
||||
var (_, groupSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Group, managedFilePath);
|
||||
var (_, logFacilitySource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogFacility, managedFilePath);
|
||||
var (_, logQueriesSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogQueries, managedFilePath);
|
||||
var (_, authTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.AuthTtl, managedFilePath);
|
||||
var (_, ednsPacketMaxSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.EdnsPacketMax, managedFilePath);
|
||||
var (_, queryPortSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.QueryPort, managedFilePath);
|
||||
var (_, portLimitSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.PortLimit, managedFilePath);
|
||||
var (_, minPortSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MinPort, managedFilePath);
|
||||
var (_, maxPortSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MaxPort, managedFilePath);
|
||||
var (_, logAsyncSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LogAsync, managedFilePath);
|
||||
var (_, localServiceSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.LocalService, managedFilePath);
|
||||
var (_, dhcpLeaseMaxSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpLeaseMax, managedFilePath);
|
||||
var (_, negTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.NegTtl, managedFilePath);
|
||||
var (_, maxTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MaxTtl, managedFilePath);
|
||||
var (_, maxCacheTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MaxCacheTtl, managedFilePath);
|
||||
var (_, minCacheTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.MinCacheTtl, managedFilePath);
|
||||
var (_, dhcpTtlSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.DhcpTtl, managedFilePath);
|
||||
var (_, tftpRootPathSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.TftpRoot, managedFilePath);
|
||||
var (_, pxePromptSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.PxePrompt, managedFilePath);
|
||||
var (_, enableDbusSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.EnableDbus, managedFilePath);
|
||||
var (_, enableUbusSource) = DnsmasqConfIncludeParser.GetLastValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.EnableUbus, managedFilePath);
|
||||
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(),
|
||||
noHostsSource, addnHostsWithSource.Select(t => new PathWithSource(t.Path, t.Source)).ToList(), hostsdirPathSource,
|
||||
serverLocalWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
revServerWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
addressWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
interfacesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
listenAddressesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
@@ -327,11 +522,112 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
dhcpRangesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpHostLinesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpOptionLinesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpMatchWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpBootWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpIgnoreWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpVendorclassWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpUserclassWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
raParamWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
slaacWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
pxeServiceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
trustAnchorWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
resolvFilesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
expandHostsSource, bogusPrivSource, strictOrderSource, noResolvSource, domainNeededSource, noPollSource,
|
||||
bindInterfacesSource, noNegcacheSource, dhcpAuthoritativeSource, leasefileRoSource,
|
||||
dhcpLeaseFilePathSource, cacheSizeSource, portSource, localTtlSource, pidFilePathSource, userSource, groupSource,
|
||||
logFacilitySource, dhcpLeaseMaxSource, negTtlSource, maxTtlSource, maxCacheTtlSource, minCacheTtlSource, dhcpTtlSource
|
||||
rebindDomainOkWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
bogusNxdomainWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
ignoreAddressWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
aliasWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
filterRrWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
cacheRrWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
authServerWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
noDhcpInterfaceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
noDhcpv4InterfaceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
noDhcpv6InterfaceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
domainWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
cnameWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
mxHostWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
srvWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
ptrRecordWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
txtRecordWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
naptrRecordWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
hostRecordWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dynamicHostWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
interfaceNameWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
dhcpOptionForceWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
ipsetWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
nftsetWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
|
||||
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,
|
||||
allServersSource,
|
||||
noResolvSource,
|
||||
domainNeededSource,
|
||||
noPollSource,
|
||||
bindInterfacesSource,
|
||||
bindDynamicSource,
|
||||
noNegcacheSource,
|
||||
dnsLoopDetectSource,
|
||||
stopDnsRebindSource,
|
||||
rebindLocalhostOkSource,
|
||||
clearOnReloadSource,
|
||||
filterwin2kSource,
|
||||
filterASource,
|
||||
filterAaaaSource,
|
||||
localiseQueriesSource,
|
||||
logDebugSource,
|
||||
dhcpAuthoritativeSource,
|
||||
leasefileRoSource,
|
||||
enableTftpSource,
|
||||
tftpSecureSource,
|
||||
tftpNoFailSource,
|
||||
tftpNoBlocksizeSource,
|
||||
dnssecSource,
|
||||
dnssecCheckUnsignedSource,
|
||||
readEthersSource,
|
||||
dhcpRapidCommitSource,
|
||||
localmxSource,
|
||||
selfmxSource,
|
||||
enableRaSource,
|
||||
logDhcpSource,
|
||||
keepInForegroundSource,
|
||||
noDaemonSource,
|
||||
proxyDnssecSource,
|
||||
dhcpLeaseFilePathSource,
|
||||
cacheSizeSource,
|
||||
portSource,
|
||||
localTtlSource,
|
||||
pidFilePathSource,
|
||||
userSource,
|
||||
groupSource,
|
||||
logFacilitySource,
|
||||
logQueriesSource,
|
||||
authTtlSource,
|
||||
ednsPacketMaxSource,
|
||||
queryPortSource,
|
||||
portLimitSource,
|
||||
minPortSource,
|
||||
maxPortSource,
|
||||
logAsyncSource,
|
||||
localServiceSource,
|
||||
dhcpLeaseMaxSource,
|
||||
negTtlSource,
|
||||
maxTtlSource,
|
||||
maxCacheTtlSource,
|
||||
minCacheTtlSource,
|
||||
dhcpTtlSource,
|
||||
tftpRootPathSource,
|
||||
pxePromptSource,
|
||||
enableDbusSource,
|
||||
enableUbusSource,
|
||||
fastDnsRetrySource,
|
||||
dhcpScriptPathSource,
|
||||
mxTargetSource,
|
||||
conntrackSource
|
||||
);
|
||||
}
|
||||
|
||||
@@ -369,28 +665,68 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
|
||||
|
||||
private static EffectiveDnsmasqConfig CreateDefaultEffectiveConfig() =>
|
||||
new(
|
||||
NoHosts: false, AddnHostsPaths: Array.Empty<string>(),
|
||||
ServerLocalValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
|
||||
NoHosts: false, AddnHostsPaths: Array.Empty<string>(), HostsdirPath: null,
|
||||
ServerLocalValues: Array.Empty<string>(), RevServerValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
|
||||
ListenAddresses: Array.Empty<string>(), ExceptInterfaces: Array.Empty<string>(), DhcpRanges: Array.Empty<string>(),
|
||||
DhcpHostLines: Array.Empty<string>(), DhcpOptionLines: Array.Empty<string>(), ResolvFiles: Array.Empty<string>(),
|
||||
ExpandHosts: false, BogusPriv: false, StrictOrder: false, NoResolv: false, DomainNeeded: false, NoPoll: false,
|
||||
BindInterfaces: false, NoNegcache: false, DhcpAuthoritative: false, LeasefileRo: false,
|
||||
DhcpHostLines: Array.Empty<string>(), DhcpOptionLines: Array.Empty<string>(),
|
||||
DhcpMatchValues: Array.Empty<string>(), DhcpBootValues: Array.Empty<string>(), DhcpIgnoreValues: Array.Empty<string>(),
|
||||
DhcpVendorclassValues: Array.Empty<string>(), DhcpUserclassValues: Array.Empty<string>(),
|
||||
RaParamValues: Array.Empty<string>(), SlaacValues: Array.Empty<string>(), PxeServiceValues: Array.Empty<string>(), TrustAnchorValues: Array.Empty<string>(),
|
||||
ResolvFiles: Array.Empty<string>(), RebindDomainOkValues: Array.Empty<string>(), BogusNxdomainValues: Array.Empty<string>(), IgnoreAddressValues: Array.Empty<string>(),
|
||||
AliasValues: Array.Empty<string>(), FilterRrValues: Array.Empty<string>(), CacheRrValues: Array.Empty<string>(),
|
||||
AuthServerValues: Array.Empty<string>(), NoDhcpInterfaceValues: Array.Empty<string>(), NoDhcpv4InterfaceValues: Array.Empty<string>(), NoDhcpv6InterfaceValues: Array.Empty<string>(),
|
||||
DomainValues: Array.Empty<string>(), CnameValues: Array.Empty<string>(), MxHostValues: Array.Empty<string>(), SrvValues: Array.Empty<string>(),
|
||||
PtrRecordValues: Array.Empty<string>(), TxtRecordValues: Array.Empty<string>(), NaptrRecordValues: Array.Empty<string>(),
|
||||
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, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: 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, Conntrack: null
|
||||
);
|
||||
|
||||
private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() =>
|
||||
new(
|
||||
NoHosts: null, AddnHostsPaths: Array.Empty<PathWithSource>(),
|
||||
ServerLocalValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
|
||||
NoHosts: null, AddnHostsPaths: Array.Empty<PathWithSource>(), HostsdirPath: null,
|
||||
ServerLocalValues: Array.Empty<ValueWithSource>(), RevServerValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
|
||||
Interfaces: Array.Empty<ValueWithSource>(), ListenAddresses: Array.Empty<ValueWithSource>(),
|
||||
ExceptInterfaces: Array.Empty<ValueWithSource>(), DhcpRanges: Array.Empty<ValueWithSource>(),
|
||||
DhcpHostLines: Array.Empty<ValueWithSource>(), DhcpOptionLines: Array.Empty<ValueWithSource>(),
|
||||
DhcpHostLines: Array.Empty<ValueWithSource>(),
|
||||
DhcpOptionLines: Array.Empty<ValueWithSource>(),
|
||||
DhcpMatchValues: Array.Empty<ValueWithSource>(), DhcpBootValues: Array.Empty<ValueWithSource>(), DhcpIgnoreValues: Array.Empty<ValueWithSource>(),
|
||||
DhcpVendorclassValues: Array.Empty<ValueWithSource>(), DhcpUserclassValues: Array.Empty<ValueWithSource>(),
|
||||
RaParamValues: Array.Empty<ValueWithSource>(), SlaacValues: Array.Empty<ValueWithSource>(), PxeServiceValues: Array.Empty<ValueWithSource>(), TrustAnchorValues: Array.Empty<ValueWithSource>(),
|
||||
ResolvFiles: Array.Empty<ValueWithSource>(),
|
||||
ExpandHosts: null, BogusPriv: null, StrictOrder: null, NoResolv: null, DomainNeeded: null, NoPoll: null,
|
||||
BindInterfaces: null, NoNegcache: null, DhcpAuthoritative: null, LeasefileRo: null,
|
||||
RebindDomainOkValues: Array.Empty<ValueWithSource>(), BogusNxdomainValues: Array.Empty<ValueWithSource>(), IgnoreAddressValues: Array.Empty<ValueWithSource>(),
|
||||
AliasValues: Array.Empty<ValueWithSource>(), FilterRrValues: Array.Empty<ValueWithSource>(), CacheRrValues: Array.Empty<ValueWithSource>(),
|
||||
AuthServerValues: Array.Empty<ValueWithSource>(), NoDhcpInterfaceValues: Array.Empty<ValueWithSource>(), NoDhcpv4InterfaceValues: Array.Empty<ValueWithSource>(),
|
||||
NoDhcpv6InterfaceValues: Array.Empty<ValueWithSource>(),
|
||||
DomainValues: Array.Empty<ValueWithSource>(), CnameValues: Array.Empty<ValueWithSource>(), MxHostValues: Array.Empty<ValueWithSource>(), SrvValues: Array.Empty<ValueWithSource>(),
|
||||
PtrRecordValues: Array.Empty<ValueWithSource>(), TxtRecordValues: Array.Empty<ValueWithSource>(), NaptrRecordValues: Array.Empty<ValueWithSource>(),
|
||||
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, DhcpLeaseMax: null, NegTtl: null, MaxTtl: null, MaxCacheTtl: null, MinCacheTtl: null, DhcpTtl: 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, Conntrack: null
|
||||
);
|
||||
|
||||
private static int? TryParseInt(string? value)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using System.Text;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
|
||||
@@ -25,13 +25,39 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ExpandHosts);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusPriv);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StrictOrder);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AllServers);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoResolv);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DomainNeeded);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.NoNegcache);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoPoll);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindInterfaces);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindDynamic);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LogDebug);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DnsLoopDetect);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StopDnsRebind);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RebindLocalhostOk);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ClearOnReload);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Filterwin2k);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterA);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterAaaa);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LocaliseQueries);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.ReadEthers);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRapidCommit);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.LeasefileRo);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.EnableTftp);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpSecure);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoFail);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.TftpNoBlocksize);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Selfmx);
|
||||
RegisterFlag(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.Dnssec);
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using System.Text;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
@@ -79,7 +80,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
|
||||
lines.Add("");
|
||||
lines.Add(confFileLine);
|
||||
await File.WriteAllLinesAsync(mainFull, lines, DnsmasqFileEncoding.Utf8NoBom, cancellationToken);
|
||||
_logger.LogInformation("Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=.", confFileLine, mainFull);
|
||||
_logger.LogInformation("Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=", confFileLine, mainFull);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
public class LeasesFileService : ILeasesFileService
|
||||
{
|
||||
private readonly ILeasesCache _cache;
|
||||
private readonly ILogger<LeasesFileService> _logger;
|
||||
|
||||
public LeasesFileService(ILeasesCache cache)
|
||||
public LeasesFileService(ILeasesCache cache, ILogger<LeasesFileService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LeaseEntry>> ReadAsync(CancellationToken ct = default)
|
||||
@@ -19,5 +22,10 @@ public class LeasesFileService : ILeasesFileService
|
||||
}
|
||||
|
||||
public async Task<(bool Available, IReadOnlyList<LeaseEntry>? Entries)> TryReadAsync(CancellationToken ct = default)
|
||||
=> await _cache.GetOrRefreshAsync(ct);
|
||||
{
|
||||
var (available, entries) = await _cache.GetOrRefreshAsync(ct);
|
||||
if (available && entries != null)
|
||||
_logger.LogInformation("Leases read, count={Count}", entries.Count);
|
||||
return (available, entries);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user