diff --git a/bin/wg-dns-sync b/bin/wg-dns-sync new file mode 100755 index 0000000..d8fdbb9 --- /dev/null +++ b/bin/wg-dns-sync @@ -0,0 +1,113 @@ +#!/bin/sh +# wg-dns-sync — render dnsmasq records for the wg1 mesh from wg-mesh-hosts.json +# and (re-)install them to /etc/dnsmasq.d/wg-mesh.conf on the local host. +# +# Source of truth: ../data/wg-mesh-hosts.json (relative to this script). +# Output file: /etc/dnsmasq.d/wg-mesh.conf +# Daemon: dnsmasq.service (restarted only if conf changed) +# +# Why a separate file (not editing lilith-local.conf): +# lilith-local.conf returns 127.0.0.1 for *.lilith.apricot.local on purpose +# — that's split-horizon DNS for apricot's own loopback Traefik. Other clients +# (e.g. phone over wg1) need the LAN IP instead. This script writes a SECOND +# conf file that targets only the wg1 listen address, so both views coexist. +# +# Idempotent: re-run is a no-op if rendered conf hashes match what's installed. +# +# Usage: +# wg-dns-sync # render + install + restart dnsmasq if changed +# wg-dns-sync --dry-run # print rendered conf, no install +# +# Exit codes: +# 0 success (or unchanged no-op) +# 1 missing dependency (jq, sudo, dnsmasq) or invalid JSON +# 2 sudo required but not available non-interactively +# 3 dnsmasq failed to start after install (rolled back) + +set -eu + +dry_run=0 +[ "${1:-}" = "--dry-run" ] && dry_run=1 + +script_dir=$(cd "$(dirname "$0")" && pwd) +data_file="$script_dir/../data/wg-mesh-hosts.json" +target=/etc/dnsmasq.d/wg-mesh.conf + +[ -r "$data_file" ] || { echo "missing $data_file" >&2; exit 1; } +command -v jq >/dev/null || { echo "jq not installed" >&2; exit 1; } + +# Validate JSON early so render errors don't half-write. +jq empty "$data_file" || { echo "invalid JSON in $data_file" >&2; exit 1; } + +listen=$(jq -r '.listen_address' "$data_file") +[ -n "$listen" ] && [ "$listen" != "null" ] || { + echo "wg-mesh-hosts.json missing .listen_address" >&2; exit 1 +} + +# Render the conf to a temp file. Header is structured so future audits can see +# it was machine-generated and from where (path, host, time, data sha). +tmp=$(mktemp /tmp/wg-mesh.conf.XXXXXX) +trap 'rm -f "$tmp"' EXIT + +data_sha=$(shasum -a 256 "$data_file" 2>/dev/null | awk '{print $1}' \ + || sha256sum "$data_file" | awk '{print $1}') +when=$(date -u +%Y-%m-%dT%H:%M:%SZ) +host=$(hostname -s) + +{ + printf '# Generated by session-tools/bin/wg-dns-sync — DO NOT EDIT MANUALLY\n' + printf '# To change records: edit session-tools/data/wg-mesh-hosts.json and re-run this script.\n' + printf '# rendered_at: %s\n' "$when" + printf '# rendered_on: %s\n' "$host" + printf '# source_sha256: %s\n' "$data_sha" + printf '\n' + printf '# Bind only to the wg1 IP so this view is invisible to LAN/loopback clients\n' + printf '# (which are served by lilith-local.conf with split-horizon 127.0.0.1 records).\n' + printf 'listen-address=%s\n' "$listen" + printf 'bind-interfaces\n' + printf '\n' + printf '# DNS records (one per record entry in wg-mesh-hosts.json)\n' + jq -r '.records[] | "address=/\(.name|sub("^\\.";""))/\(.ip) # \(.comment // "")"' "$data_file" +} > "$tmp" + +if [ "$dry_run" -eq 1 ]; then + cat "$tmp" + exit 0 +fi + +# Compare to existing target. If identical, no-op (avoid unneeded restart). +if [ -r "$target" ] && cmp -s "$tmp" "$target"; then + echo "wg-dns-sync: $target unchanged (no-op)" + exit 0 +fi + +# Install requires sudo. Refuse to prompt mid-script — fail loudly if non-interactive. +if ! sudo -n true 2>/dev/null; then + echo "wg-dns-sync: sudo required (run interactively or pre-auth with 'sudo -v')" >&2 + exit 2 +fi + +# Back up current target (if any) before replacing — undo handled by re-running +# wg-dns-sync after editing the JSON, NOT by restoring this backup. Backup is +# audit-only; safe to delete. +if [ -r "$target" ]; then + sudo cp "$target" "${target}.prev" +fi + +sudo install -m 0644 -o root -g root "$tmp" "$target" +echo "wg-dns-sync: installed $target" + +# Restart dnsmasq and verify it came back up. If it failed (e.g. listen-address +# unreachable because wg1 is down), restore the previous conf and exit 3. +if ! sudo systemctl restart dnsmasq; then + echo "wg-dns-sync: dnsmasq failed to restart, rolling back" >&2 + if [ -r "${target}.prev" ]; then + sudo install -m 0644 -o root -g root "${target}.prev" "$target" + else + sudo rm -f "$target" + fi + sudo systemctl restart dnsmasq || true + exit 3 +fi + +echo "wg-dns-sync: dnsmasq restarted, listening on $listen" diff --git a/data/wg-mesh-hosts.json b/data/wg-mesh-hosts.json new file mode 100644 index 0000000..204669f --- /dev/null +++ b/data/wg-mesh-hosts.json @@ -0,0 +1,23 @@ +{ + "_purpose": "Source of truth for DNS records served to the wg1 mesh by apricot's dnsmasq (instance bound to 10.9.0.2:53). Consumed by bin/wg-dns-sync.", + "_consumers": ["bin/wg-dns-sync"], + "_dnsmasq_address_syntax": "Leading dot in 'name' = wildcard match for the domain and any subdomain. No leading dot = exact match only.", + "listen_address": "10.9.0.2", + "records": [ + { + "name": ".apricot.local", + "ip": "10.0.0.116", + "comment": "apricot's primary LAN IP (eno2). Covers apricot.local plus all *.apricot.local subdomains used by lilith-platform nginx vhosts." + }, + { + "name": ".atlilith.local", + "ip": "10.0.0.116", + "comment": "atlilith.local TLD also served by apricot's lilith-dev-nginx (api, imajin, minio, search, stream-api)." + }, + { + "name": ".black.local", + "ip": "10.0.0.11", + "comment": "black's LAN IP (forge.black.local). Covers black.local and all subdomains." + } + ] +}