feat(@tools/net-tools): clarify naming rules and auto-generated configs

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 20:06:00 -07:00
parent b8d41a9509
commit 98a0df2f41
3 changed files with 50 additions and 19 deletions

View file

@ -32,15 +32,29 @@ so `pear.wg` *and* `black.wg` resolve during the transition. Live infra (forge
URL, NFS, ssh) still uses old names until the gated cutovers land — see
[`docs/topology.md`](docs/topology.md#fleet-rename).
## Naming: two views, one rule
## Naming: one rule per suffix
The suffix is authoritative — a name is never ambiguous:
- **bare `<host>`** and **`<host>.lan`** → the host's **current LAN IP**
(discovered, tracks DHCP drift). Direct at home; when away the daemon routes
the LAN `/24` through the tunnel, so the same name still works. This is the
everyday handle: `ssh apricot`, `ping pear`.
- **`<host>.wg`** → mesh IP (`10.9.0.x`). The explicit tunnel path — use to force
the mesh or to reach hosts with no LAN leg (`fennel.wg`, `yuzu.wg`; their bare
names also point here).
- **service vhosts** (`quinn.apricot.lan`, `forge.black.lan`, …) → declared in
`mesh-hosts.json` `services`, rendered at the hosting host's current IP.
- **`<host>.wg`** → mesh IP (`10.9.0.x`). Works anywhere the tunnel is up.
- **`<host>.lan`** → LAN IP (`10.0.0.x`). Home network only.
(The old `*.local` scheme is **retired** — platform moved to real `.com` domains,
infra to `.lan`. net-tools carries no `.local` records.)
(The old `*.local` scheme is **retired** — the platform moved to real `.com`
domains and infra to `.lan`. net-tools carries no `.local` records.)
## The program owns the names — never hand-edit
`/etc/hosts` fleet/service records and the fleet block in `~/.ssh/config` are
**generated**. Hand-edits go stale on the next DHCP drift and are overwritten on
the next sync. To change anything: edit `data/mesh-hosts.json` (or just wait —
IP changes are discovered automatically) and let the renderers run. On install,
`mesh-hosts-render` also **adopts** loose hand-maintained lines for any name it
manages (it removes them; its block supersedes them).
## Tools
@ -49,7 +63,7 @@ domains and infra to `.lan`. net-tools carries no `.local` records.)
| `bin/host-apply` | **every host** | Renders *this device's* view of the fleet. Detects which host it is, then writes a managed ssh-config block (`~/.ssh/config`) with per-vantage `HostName`s: `public` > `.lan` (if this host reaches the LAN) > `.wg`. `--whoami`/`--ssh-print`/`--ssh-diff`/`--ssh-apply`. The hosts leg is `mesh-hosts-render`. |
| `smart-lan-router/smart-lan-router.py` | **fennel** (laptop) | LaunchDaemon, two jobs. **(1) Route:** detect HOME (default gateway's MAC == `lan.gateway_mac`) → route `10.0.0.0/24` via the LAN interface (direct ~5ms); AWAY → via the wg mesh. **(2) Name-sync:** at home, discover each LAN host's *current* IP by **MAC via ARP** (stable MAC, drifting DHCP IP), write `data/lan-state.json`, and regenerate `/etc/hosts` (`mesh-hosts-render`) + the console user's `~/.ssh/config` (`host-apply`). So `ssh apricot`/`apricot.lan` follow the host wherever DHCP puts it — no reservations. `--status` to inspect. Supersedes the old per-host `/32` pinner, the `wg-route-watchdog`, *and* `setup-lan-dns`. |
| `bin/wg-dns-sync` | **apricot** | Renders `mesh-hosts.json``/etc/dnsmasq.d/wg-mesh.conf` (host `.wg` + `.lan` records on `10.9.0.2:53`, for wg clients with `DNS=10.9.0.2`). Idempotent; `--dry-run`. |
| `bin/mesh-hosts-render` | any (esp. **fennel**) | Renders a static `/etc/hosts` block for roaming clients. `--print`/`--diff`/`--install`. |
| `bin/mesh-hosts-render` | **every host** | Renders the fleet `/etc/hosts` block (bare/`.lan` at current IPs, `.wg`, service vhosts) and splices it at the top of `/etc/hosts`, adopting any loose lines it supersedes. Idempotent. `--print`/`--diff`/`--install`. |
| `smart-lan-router/` | **fennel** | `com.lilith.smart-lan-router.plist` (launchd) + `install-smart-router.sh` (installs it, retires the old loose copies). |
All tools locate `data/mesh-hosts.json` by resolving their own symlink chain and
@ -62,14 +76,17 @@ walking up to the repo, so they work whether run from the repo or a PATH symlink
sudo smart-lan-router/install-smart-router.sh # install + start the LaunchDaemon (fennel only)
```
## Changing addresses / hosts
## Changing things
1. Edit [`data/mesh-hosts.json`](data/mesh-hosts.json).
2. apricot: `sudo wg-dns-sync` · roaming clients: `sudo mesh-hosts-render --install`.
3. The daemon re-reads the file each cycle — no restart needed.
| Want to… | Do |
|----------|----|
| add/rename a host, change a MAC, add a service vhost | edit [`data/mesh-hosts.json`](data/mesh-hosts.json) — the daemon re-reads it each cycle; renderers pick it up on the next sync |
| react to a host changing DHCP IP | nothing — the daemon discovers it by MAC and regenerates `/etc/hosts` + ssh automatically |
| force a regen now | `sudo bin/mesh-hosts-render --install` and `bin/host-apply --ssh-apply` |
| apricot mesh DNS (phones) | `sudo wg-dns-sync` on apricot |
Never hand-edit `/etc/dnsmasq.d/wg-mesh.conf` or the managed `/etc/hosts` block —
both are generated and overwritten on the next run.
Never hand-edit `/etc/dnsmasq.d/wg-mesh.conf`, the managed `/etc/hosts` records,
or the fleet block in `~/.ssh/config` — all generated, all overwritten.
## Status

View file

@ -15,6 +15,10 @@
# It writes a single managed block (Host <name> <aliases> → HostName/User) to the
# invoking user's ~/.ssh/config, placed at the TOP so it wins first-match over
# any hand-maintained stanzas. Old names are kept as Host aliases (alias-first).
# Each stanza sets `CheckHostIP no` (host keys are stable, IPs drift — trust is
# keyed on the name, so a DHCP move doesn't trip verification) and
# `StrictHostKeyChecking accept-new` (TOFU within the private fleet, so
# non-interactive/BatchMode hops to a freshly-moved host still work).
#
# Self is identified by matching the box's hostname/short-name or any local IPv4
# (incl. the wg IP) against hosts[].{name,aliases,lan,wg}.
@ -111,7 +115,7 @@ render_block() {
| ( $h.public
// (if $reachlan and $lan != null then $lan else null end)
// $h.wg ) as $addr
| "\nHost \(([$h.name] + $h.aliases) | join(" "))\n HostName \($addr)\n User \($h.ssh_user // "lilith")"
| "\nHost \(([$h.name] + $h.aliases) | join(" "))\n HostName \($addr)\n User \($h.ssh_user // "lilith")\n CheckHostIP no\n StrictHostKeyChecking accept-new"
' "$data_file"
printf '\n%s\n' "$END"
}

View file

@ -38,11 +38,11 @@ can *resolve* it):
**These records are consumed only by clients whose WireGuard config sets
`DNS=10.9.0.2` — i.e. phones.** The named hosts (apricot/pear/fennel) do *not*
point their resolver at `10.9.0.2`, so for them dnsmasq does not answer.
- **For the named hosts, `.wg`/`.lan` is delivered by the static `/etc/hosts`
block** from `mesh-hosts-render --install`. Run it on every host that must
resolve a peer's name. (Verified: before any install, `dscacheutil -q host -a
name apricot.wg` on fennel returns nothing.)
- **fennel** roams off-LAN where dnsmasq is unreachable, so the static
- **For the named hosts, names are delivered by the managed `/etc/hosts` block**
from `mesh-hosts-render --install` (bare + `.lan` at *current* IPs, `.wg`,
service vhosts). On fennel the daemon regenerates it automatically on drift;
run it once on every other host that must resolve peers by name.
- **fennel** roams off-LAN where dnsmasq is unreachable, so the managed
`/etc/hosts` block is its only resolution path then.
The old `*.local` platform scheme is **retired** (platform → `.com`, infra →
@ -91,6 +91,16 @@ LAN interface (~5ms). (Measured: apricot 351ms via tunnel → 5.6ms via en0.)
(direct); AWAY → via the wg mesh interface (so home stays reachable through the
tunnel). Re-asserted every cycle, because `wg-quick` re-adds the tunnel `/24`
on reconnect.
3. **Name-sync (HOME only)** — keep ssh + hosts in sync with reality. Each LAN
host's **MAC is stable while its DHCP IP drifts**, and ARP maps MAC↔IP. The
daemon reads the ARP table (rate-limited ping-sweep of the `/24` to populate
it when a host is missing), resolves every `hosts[]` entry with a `mac` to its
*current* IP, and on any change writes `data/lan-state.json` ({name: ip},
gitignored — volatile, per-device) and regenerates both views:
`mesh-hosts-render --install` (`/etc/hosts`) and, as the console user,
`host-apply --ssh-apply` (`~/.ssh/config`). Result: when apricot rebooted from
`.116` to `.118`, `ssh apricot` and `quinn.apricot.lan` followed automatically
— no DHCP reservations, no hand-edits.
**Why a subnet route, not per-host `/32` pins** (the old design): a `/32
-interface` route on macOS creates a *self-MAC* ARP entry that blackholes the