net-tools/bin/mesh-hosts-render
Natalie c3d788e20a feat(dx): add dx.hide_homelan to hide homelan config while DO-only
- data/mesh-hosts.json: "dx": {"hide_homelan": true} (with note). Data for apricot/pear/fennel/lan/services fully preserved for recovery.
- bin/mesh-hosts-render + bin/host-apply: respect the flag — filter to .class=="cloud" hosts only (yuzu, lime), emit dx mode note in headers, services filtered too.
- When true: generated /etc/hosts mesh-block and ~/.ssh/config net-tools fleet block only contain DO/cloud (homelan names like apricot.lan, bare fennel etc. hidden). dx-forges (ctforge/mcforge) unaffected at bottom.
- `net sync` (and direct renderers) now produce clean DO-only configs.
- README updated. To recover: set false + net sync.

Fulfills "hide the homelan config... now only use DO... may try to recover homelan so dont delete it".
2026-06-28 10:50:51 -04:00

215 lines
9 KiB
Bash
Executable file

#!/bin/sh
# mesh-hosts-render — render the fleet's /etc/hosts block from
# data/mesh-hosts.json (+ the daemon's discovered-IP overlay) and splice it in.
#
# Emits a marked, idempotently-replaceable block at the TOP of /etc/hosts (so it
# wins first-match resolution):
# # >>> mesh-hosts (managed by smart-lan-router/bin/mesh-hosts-render)
# 10.0.0.118 apricot.lan apricot
# 10.9.0.2 apricot.wg
# 10.0.0.118 quinn.apricot.lan www.quinn.apricot.lan ...
# ...
# # <<< mesh-hosts
#
# Name semantics (see mesh-hosts.json _schema.naming):
# <host>.lan + BARE <host> -> current LAN IP (overlay over static seed).
# Direct at home; via the tunnel when away (the daemon routes the LAN /24
# through wg then) — so this is the right default everywhere.
# <host>.wg -> mesh IP (explicit tunnel path).
# bare name of a LAN-less host (fennel/yuzu) -> its wg IP.
# services (mesh-hosts.json .services) -> the hosting host's current LAN IP.
#
# Adoption: on --install this also REMOVES (a) the legacy setup-lan-dns block and
# (b) any loose non-comment line that names a host/service this block manages —
# those are hand-maintained duplicates that go stale on DHCP drift. The program
# owns these names now; never hand-edit them in /etc/hosts.
#
# Usage:
# mesh-hosts-render # print the block to stdout (default; safe)
# mesh-hosts-render --install # splice/replace in /etc/hosts (needs root)
# mesh-hosts-render --diff # show what --install would change, no write
#
# Exit codes:
# 0 success (printed, or installed, or already up to date)
# 1 missing dependency / unlocatable or invalid JSON
# 2 --install needs root but it isn't available
set -eu
mode=print
case "${1:-}" in
""|--print) mode=print ;;
--install) mode=install ;;
--diff) mode=diff ;;
*) echo "mesh-hosts-render: unknown arg '$1' (use --print|--install|--diff)" >&2; exit 1 ;;
esac
BEGIN='# >>> mesh-hosts (managed by smart-lan-router/bin/mesh-hosts-render)'
END='# <<< mesh-hosts'
# Legacy block this tool replaces — stripped on install so its stale (drifted)
# host entries don't shadow ours in first-match resolution.
LEGACY_BEGIN='# >>> LAN hosts — managed by setup-lan-dns.sh'
LEGACY_END='# <<< LAN hosts'
HOSTS_FILE=/etc/hosts
# --- locate data file, surviving symlink invocation ----------------------------
self=$0
while [ -L "$self" ]; do
link=$(readlink "$self")
case $link in
/*) self=$link ;;
*) self=$(dirname "$self")/$link ;;
esac
done
root=$(cd "$(dirname "$self")" && pwd)
while [ "$root" != "/" ] && [ ! -f "$root/data/mesh-hosts.json" ]; do
root=$(dirname "$root")
done
data_file="$root/data/mesh-hosts.json"
[ -f "$data_file" ] || { echo "mesh-hosts-render: cannot locate data/mesh-hosts.json (from $self)" >&2; exit 1; }
command -v jq >/dev/null || { echo "mesh-hosts-render: jq not installed" >&2; exit 1; }
jq empty "$data_file" || { echo "mesh-hosts-render: invalid JSON in $data_file" >&2; exit 1; }
hide_homelan=$(jq -r '.dx.hide_homelan // false' "$data_file")
# Overlay: current LAN IPs discovered by the daemon (data/lan-state.json, a
# {name: ip} map) override the static `lan` seed, so records track DHCP drift.
overlay='{}'
state_file="$root/data/lan-state.json"
if [ -f "$state_file" ] && jq -e . "$state_file" >/dev/null 2>&1; then
overlay=$(cat "$state_file")
fi
# Vantage: can THIS node reach LAN IPs at all? A node with its own LAN leg (or
# the roaming laptop, whose tunnel carries the LAN /24) renders bare names and
# services at LAN IPs; a mesh-only node (yuzu) must use wg IPs instead.
short=$(hostname 2>/dev/null | cut -d. -f1)
if command -v ip >/dev/null 2>&1; then
local_ips=$(ip -o -4 addr show 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
else
local_ips=$(ifconfig 2>/dev/null | awk '/inet /{print $2}')
fi
ips_json=$(printf '%s\n' $local_ips | jq -R . | jq -s .)
reachlan=$(jq -r --arg h "$short" --argjson ips "$ips_json" '
[ .hosts[] | . as $x
| select( ($x.name == $h) or ($x.aliases | index($h))
or ($x.lan != null and ($ips | index($x.lan)))
or ($ips | index($x.wg)) )
| (($x.lan != null) or ($x.class == "laptop")) ] | first // false
' "$data_file")
render_block() {
printf '%s\n' "$BEGIN"
printf '# Auto-generated from net-tools/data/mesh-hosts.json + lan-state.json — re-run to update.\n'
printf '# bare/<host>.lan = current LAN IP (direct at home, tunnel when away) · <host>.wg = mesh IP\n'
if [ "$hide_homelan" = "true" ]; then
printf '# dx mode: homelan hidden (only cloud/DO hosts rendered). Data preserved for recovery.\n'
fi
# LAN records — current discovered IP (overlay) over the static seed. Bare
# names live here only when THIS node can reach LAN IPs (vantage).
jq -r --argjson ov "$overlay" --argjson reachlan "$reachlan" --argjson hide "$hide_homelan" '
.hosts[]
| select( if $hide then (.class == "cloud") else true end )
| . as $h
| (($ov[$h.name]) // $h.lan) as $lan
| select($lan != null)
| "\($lan)\t"
+ ((([$h.name] + ($h.aliases // [])) | map(. + ".lan")) | join(" "))
+ (if $reachlan then " " + (([$h.name] + ($h.aliases // [])) | join(" ")) else "" end)
' "$data_file"
# Mesh (.wg) records — explicit tunnel path. Bare names land here for
# LAN-less hosts always, and for ALL hosts when this node is mesh-only.
jq -r --argjson reachlan "$reachlan" --argjson hide "$hide_homelan" '
.hosts[]
| select( if $hide then (.class == "cloud") else true end )
| . as $h
| "\($h.wg)\t"
+ ((([$h.name] + ($h.aliases // [])) | map(. + ".wg")) | join(" "))
+ (if ($h.lan == null) or ($reachlan | not)
then " " + (([$h.name] + ($h.aliases // [])) | join(" ")) else "" end)
' "$data_file"
# Service vhosts — the hosting host'\''s current LAN IP, or its wg IP from a
# mesh-only vantage.
jq -r --argjson ov "$overlay" --argjson reachlan "$reachlan" --argjson hide "$hide_homelan" '
. as $d
| ($d.services // {}) | to_entries[]
| select(.key != "_note")
| .key as $hname
| ($d.hosts[] | select(.name == $hname) | select( if $hide then (.class == "cloud") else true end )) as $h
| (if $reachlan then (($ov[$hname]) // $h.lan) else $h.wg end) as $addr
| select($addr != null)
| "\($addr)\t\(.value | join(" "))"
' "$data_file"
printf '%s\n' "$END"
}
block=$(render_block)
if [ "$mode" = "print" ]; then
printf '%s\n' "$block"
exit 0
fi
# Every hostname our block manages (host names, .wg/.lan forms, aliases, services)
# — loose lines carrying any of these are stale hand-maintained duplicates and
# get adopted (removed; our block supersedes them).
managed=$(printf '%s\n' "$block" | awk '!/^#/ && NF >= 2 { for (i = 2; i <= NF; i++) print $i }' | sort -u | tr '\n' ' ')
# Compute the new /etc/hosts: drop our old block + the legacy setup-lan-dns
# block, then scrub managed names out of the loose remainder.
current=$(cat "$HOSTS_FILE" 2>/dev/null || true)
stripped=$(printf '%s\n' "$current" | awk -v b="$BEGIN" -v e="$END" -v lb="$LEGACY_BEGIN" -v le="$LEGACY_END" '
$0 == b || $0 == lb { skip = 1 }
skip != 1 { print }
$0 == e || $0 == le { skip = 0 }
')
stripped=$(printf '%s\n' "$stripped" | awk -v names="$managed" '
BEGIN { n = split(names, a, /[[:space:]]+/); for (i = 1; i <= n; i++) if (a[i] != "") set[a[i]] = 1 }
/^[[:space:]]*#/ || NF < 2 { print; next }
{
kept = ""; removed = 0
for (i = 2; i <= NF; i++) {
if ($i in set) removed++
else kept = kept " " $i
}
if (removed == 0) { print; next } # untouched
if (kept == "") next # every name managed → fully adopted
print $1 kept # partially adopted → keep the rest
}
')
# Trim leading + trailing blank lines from the stripped body (the separators our
# previous splice left behind — without this, blanks accumulate and the install
# is never a no-op).
stripped=$(printf '%s\n' "$stripped" | awk 'NF {f=1; p=NR} f {l[NR]=$0} END {for(i=1;i<=p;i++) if (i in l) print l[i]}')
# PREPEND our block so its records win /etc/hosts first-match resolution over any
# other (e.g. a stale setup-lan-dns block that still lists a drifted apricot.lan).
new=$(printf '%s\n\n%s\n' "$block" "$stripped")
if [ "$mode" = "diff" ]; then
if command -v diff >/dev/null 2>&1; then
printf '%s\n' "$new" | diff -u "$HOSTS_FILE" - || true
else
printf '%s\n' "$new"
fi
exit 0
fi
# --install
if printf '%s\n' "$new" | cmp -s - "$HOSTS_FILE"; then
echo "mesh-hosts-render: $HOSTS_FILE already up to date"
exit 0
fi
SUDO=
if [ "$(id -u)" -ne 0 ]; then
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
echo "mesh-hosts-render: --install needs root" >&2
exit 2
fi
fi
printf '%s\n' "$new" | $SUDO tee "$HOSTS_FILE" >/dev/null
echo "mesh-hosts-render: updated $HOSTS_FILE"