#!/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 #
/
/
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. 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-optsdir", "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", "connmark-allowlist-enable", "no-round-robin", "dns-forward-max", "connmark-allowlist", "do-0x20-encode", "caa-record", "dns-rr", "synth-domain", "auth-zone", "auth-soa", "auth-sec-servers", "auth-peer", "leasequery", "dhcp-generate-names", "dhcp-broadcast", "dhcp-sequential-ip", "dhcp-ignore-clid", "bootp-dynamic", "no-ping", "script-arp", "script-on-renewal", "dhcp-no-override", "dhcp-alternate-port", "dhcp-duid", "dhcp-luascript", "dhcp-scriptuser", "dhcp-relay", "dhcp-circuitid", "dhcp-remoteid", "dhcp-subscrid", "dhcp-proxy", "tag-if", "bridge-interface", "shared-network", "dhcp-pxe-vendor", "dhcp-option-pxe", "dnssec-no-timecheck", "dnssec-debug", "add-cpe-id", "dnssec-timestamp", "dnssec-limits", "dumpfile", "dumpmask", "use-stale-cache", "add-mac", "strip-mac", "add-subnet", "strip-subnet", "umbrella", "quiet-dhcp", "quiet-dhcp6", "quiet-ra", "quiet-tftp", ] 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
so we get one block per option. raw_dl = str(dl) option_to_block = {} blocks = re.split(r"") if "
" in block.lower() else len(block) + 1 m_dd = re.search(r" len(block): continue dt_part = block[:end_pos].strip() rest = block[end_pos:] if rest.lower().startswith(""): rest = rest[6:].lstrip() if ">" in dt_part: dt_part = dt_part.split(">", 1)[1] dt_html = "
" + dt_part + "
" dd_match = re.search(r"]*>(.*)", rest, re.DOTALL | re.IGNORECASE) dd_body = dd_match.group(1) if dd_match else "" dd_body = re.split(r"|", dd_body, flags=re.IGNORECASE)[0].strip() dd_html = "
" + dd_body + "
" if dd_body else "
" 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"