2026-06-09 19:53:08 -07:00
# net-tools
Mesh/LAN tooling for the four-host **wg1 mesh** + home LAN, built around one
source of truth ([`data/mesh-hosts.json` ](data/mesh-hosts.json )).
Components:
2026-06-28 10:45:47 -04:00
- **`bin/net` ** — **the one command** : `status · whoami · doctor · issues ·
sync · up · down · enroll phone · gui`. Imports the agent as a library, so
every surface shares one implementation. The renderers (`host-apply` ,
2026-06-29 21:38:25 -04:00
`mesh-hosts-render` , `wg-dns-sync` , `wg-render` , `fleet-status` ) remain as
internals/direct tools.
- **`bin/wg-render` ** — renders THIS host's `/etc/wireguard/wg1.conf` from the
source of truth (the piece that used to be hand-built). Multi-segment hub model
(see below). `--keygen` bootstraps a fresh host's key and prints the pubkey to
paste back as `wg_pubkey` ; `--apply` installs + `wg syncconf` (idempotent,
rolls back on failure); `--dry-run` /`--whoami` for inspection.
2026-06-28 10:45:47 -04:00
- **[`data/known-issues.json` ](data/known-issues.json )** — the **triage
registry**: features that are known-broken or intentionally parked. `net
issues` lists them; ` net doctor < host > ` annotates each host with its parked
features (`⚠ KNOWN-…` ) so a triaged problem is never re-investigated from
scratch. An optional per-issue `probe` (same shape as a host `identity` )
lets `doctor` flag an issue as *maybe-resolved* when it starts passing.
2026-06-10 02:20:23 -07:00
- **`gui/` ** — Mesh control, the for-dummies window (`net gui` ): plain-language
status per device; right-click for the power tools (copy address, ssh here,
diagnose path, `.wg` address). Every menu item is a `net` verb.
- **`tray/` ** — the macOS menu-bar fleet tray (tunnel control + live fleet view).
- **[`smart-lan-router/` ](smart-lan-router/ )** — the **fleet agent** : one
service, identical on every node, roles derived from each node's entry in the
source of truth. It pulls the repo (config + its own code), discovers hosts'
current IPs by MAC, converges OS hostnames, switches the laptop's home/away
route, and regenerates the local views. (The home gateway is a dumb Xfinity
box with no API; all intelligence lives in the fleet.)
2026-06-09 19:53:08 -07:00
Everything that needs a host address, MAC, or identity probe derives from one
file: [`data/mesh-hosts.json` ](data/mesh-hosts.json ). Never hardcode a mesh IP,
MAC, or identity URL anywhere else — add it here and regenerate.
## The four hosts — fruit family encodes machine class
| Class | Canonical | Old alias | LAN | WG mesh | Public |
|-------|-----------|-----------|-----|---------|--------|
2026-06-10 02:20:23 -07:00
| GPU compute (stone fruit) | **apricot** | — | *DHCP, discovered* | `10.9.0.2` | — |
2026-06-09 19:53:08 -07:00
| CPU / storage (pome) | **pear** | `black` | `10.0.0.11` | `10.9.0.4` | — |
| laptop (vegetable) | **fennel** | `plum` | *roams* | `10.9.0.3` | — |
| cloud hub (citrus) | **yuzu** | `vps` ,`quinn-vps` | — | `10.9.0.1` | `89.127.233.145` |
2026-06-10 02:20:23 -07:00
| phone (berry) | **strawberry** | `phone-quinn` | — | `10.9.0.5` | — |
2026-06-09 19:53:08 -07:00
2026-06-10 02:20:23 -07:00
LAN IPs are *live state* , not promises — agents discover them by MAC
(`data/lan-state.json` ); the table's fixed entries are just today's DHCP truth.
The rename is **alias-first** : the fruit name is canonical, the old name is a
permanent alias, every renderer emits **both** — `pear.wg` *and* `black.wg`
resolve, `ssh black` keeps working forever. OS hostnames are converged **by the
fleet itself**: `fleet.enforce_hostname: true` makes each agent rename its own
node (never run `hostnamectl` /`scutil` by hand). Old names are never retired —
the forge URL, NFS paths, and every `.git/config` keep resolving untouched.
Phones are hosts too — `class: phone` (berry family), `os: ios|android` . No
agent runs on them (`ssh_user: null` → no ssh stanza); they consume names via
the WireGuard app with `DNS=10.9.0.2` . Current: **strawberry** (alias
`phone-quinn` , ios, `10.9.0.5` ). Enroll new ones with `wg-phone-add` , then add
the entry.
2026-06-09 19:53:08 -07:00
2026-06-29 21:38:25 -04:00
## WireGuard segments (multi-hub)
A **segment** is a WireGuard hub plus its spokes. `mesh.segments` maps a segment
name to `{ hub, endpoint, dns_host, dns_listen }` ; each host carries `segment`
(which it belongs to) and `wg_pubkey` (its public key — the private key never
leaves the box). `bin/wg-render` reads this and emits the right `wg1.conf` : the
segment **hub** gets a `[Peer]` per spoke (+ `ip_forward` /MASQUERADE so it relays);
a **spoke** gets one `[Peer]` = its hub (Endpoint + `AllowedIPs = mesh cidr` ).
Today: **iceland** (hub `yuzu` ) and **nyc3** (hub `citron` ) are independent stars —
the DO/nyc3 droplets (lime/redroid/…) peer `citron` , not the Iceland box.
`wg-dns-sync` is segment-aware too: the segment's `dns_host` binds its own
`dns_listen` (citron serves nyc3 on `10.9.0.7` ; apricot still serves on `10.9.0.2` ).
Hosts with no `segment` fall back to the legacy single hub (`mesh.hub` ).
Bootstrap a new cloud host: provision (DO droplet, reverse-DNS name
`com.uvlava.<producer>.<role>` ), `ssh` in and run `wg-render --keygen` , paste the
printed pubkey into that host's `wg_pubkey` in `mesh-hosts.json` , then
`wg-render --apply` on the host and on its hub.
2026-06-09 20:06:00 -07:00
## Naming: one rule per suffix
- **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.
(The old `*.local` scheme is **retired** — platform moved to real `.com` domains,
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).
2026-06-09 19:53:08 -07:00
## Tools
| Tool | Runs on | What it does |
|------|---------|--------------|
| `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` . |
2026-06-10 02:20:23 -07:00
| `smart-lan-router/smart-lan-router.py` | **every node** | The fleet agent (launchd/systemd). Roles, derived per node: **pull** — git pull as the repo owner, restart self on code change; **hostname** — converge OS hostname to the canonical name (`fleet.enforce_hostname` ); **discover** — map declared MACs → current DHCP IPs via ARP/`ip neigh` , write `data/lan-state.json` ; **route** (laptop only) — HOME (gateway MAC match) → LAN `/24` direct (~5ms), AWAY → via wg; **render** — regenerate both views on change. `--status` to inspect. Supersedes the per-host `/32` pinner, `wg-route-watchdog` , and `setup-lan-dns` . |
| `bin/fleet-status` | anywhere | Terminal dashboard: one row per agent node (location, route, repo HEAD, snapshot age, discovered IPs), read from each node's `data/agent-status.json` over the fleet ssh names. `STALE` /`no status` = that agent needs attention. |
2026-06-09 19:53:08 -07:00
| `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` . |
2026-06-09 20:06:00 -07:00
| `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` . |
2026-06-28 10:45:47 -04:00
| `bin/forge-dns-render` | **laptop/dev machines** | DX-only: renders cloud Forgejo shortcuts (mcforge, ctforge, ...) from `~/.vault/*_forge_creds` into a managed block at the bottom of `/etc/hosts` . Used by `net sync` and per-project `./run forge:dns` . Adopts loose entries. `--print` /`--diff` /`--install` . |
2026-06-28 10:50:51 -04:00
| dx config in mesh-hosts.json | data-driven | `"dx": { "hide_homelan": true }` hides homelan hosts (apricot/pear/fennel + LAN names/services) from all generated /etc/hosts + ssh fleet blocks (only cloud/DO remain). Full data preserved for recovery (set false + `net sync` ). Used while DO-only. |
2026-06-10 02:20:23 -07:00
| `smart-lan-router/` | **fennel** | `com.lilith.smart-lan-router.plist` (launchd) + `install-agent.sh` (one installer: launchd or systemd) + `smart-lan-router.service.tmpl` . |
| [`tray/` ](tray/ ) | **fennel** (menu bar) | The fleet tray (absorbed from the old `wireguard-vpn-tray` repo). Icon = tunnel state (green/yellow/red); menu = live fleet view from `data/agent-status.json` : agent freshness, HOME/AWAY + route, discovered host IPs, repo HEAD. Connect/disconnect actions. Install: `bash tray/install-tray.sh` (as the user, no sudo). |
2026-06-09 19:53:08 -07:00
All tools locate `data/mesh-hosts.json` by resolving their own symlink chain and
walking up to the repo, so they work whether run from the repo or a PATH symlink.
2026-06-10 02:20:23 -07:00
## Install — same agent, every node
2026-06-09 19:53:08 -07:00
2026-06-28 15:22:53 -04:00
Current canonical (ct-forge on DO, under the applications org):
2026-06-09 19:53:08 -07:00
```sh
2026-06-28 15:22:53 -04:00
git clone ssh://git@ct -forge:2222/applications/net-tools.git ~/net-tools
2026-06-10 02:20:23 -07:00
cd ~/net-tools
./install.sh # symlink bin/* into ~/bin or ~/.local/bin
sudo smart-lan-router/install-agent.sh # ONE service: launchd on darwin, systemd on linux
2026-06-09 19:53:08 -07:00
```
2026-06-28 15:23:35 -04:00
(The repo has remotes:
- origin and ct-forge = ct-forge/applications (new live)
- black = historical forge.black.lan/lilith (retired)
Push to origin or ct-forge.)
2026-06-28 15:22:53 -04:00
```sh
# after changes
git push origin main
```
2026-06-10 02:20:23 -07:00
The agent self-derives its roles from this node's `mesh-hosts.json` entry —
nothing platform- or host-specific to configure:
| Node | Platform | Roles (derived) |
|------|----------|-----------------|
| fennel | osx (launchd) | pull · hostname · discover · render · **route** (laptop) |
| apricot | bluefin (systemd) | pull · hostname · discover · render |
| pear | ubuntu-family (systemd) | pull · hostname · discover · render |
| yuzu | debian (systemd) | pull · hostname · render (no LAN leg) |
| strawberry (ios) + future android | — | **no agent possible** — WireGuard app with `DNS=10.9.0.2` ; names served by apricot's mesh dnsmasq (`wg-dns-sync` ) |
| windows | — | non-goal until a Windows node exists (would need a hosts/ssh/route port) |
Every node renders its own vantage: LAN-capable nodes get bare names + services
at current LAN IPs; mesh-only nodes (yuzu) get them at wg IPs. The `pull` role
re-fetches this repo (as the repo owner, never root) and restarts the agent when
2026-06-28 15:22:53 -04:00
its own code changes — fleet updates propagate by pushing to ct-forge (origin).
2026-06-10 02:20:23 -07:00
2026-06-09 20:06:00 -07:00
## Changing things
2026-06-09 19:53:08 -07:00
2026-06-09 20:06:00 -07:00
| Want to… | Do |
|----------|----|
2026-06-10 02:20:23 -07:00
| add/rename a host, change a MAC, add a service vhost or phone | edit [`data/mesh-hosts.json` ](data/mesh-hosts.json ), let autocommit push — **every agent pulls, restarts on code change, and converges (incl. its OS hostname) within minutes** |
| react to a host changing DHCP IP | nothing — agents discover it by MAC and regenerate `/etc/hosts` + ssh automatically |
| rename a node's OS hostname | nothing by hand — `fleet.enforce_hostname` makes the node's own agent do it |
2026-06-28 10:50:51 -04:00
| force a regen now | `net sync` (mesh-hosts + forge-dns + ssh) or the individual `sudo ... --install` . Toggle dx.hide_homelan in data/mesh-hosts.json then sync to hide/show homelan. |
2026-06-09 20:06:00 -07:00
| apricot mesh DNS (phones) | `sudo wg-dns-sync` on apricot |
2026-06-10 02:20:23 -07:00
| enroll a phone | `wg-phone-add -d <device>` then add a `class: phone` entry |
2026-06-09 19:53:08 -07:00
2026-06-09 20:06:00 -07:00
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.
2026-06-09 19:53:08 -07:00
## Status
Consolidates previously-scattered tooling (the `session-tools` generators, the
`magic-civilization/scripts/lan` resolver scripts, and the loose `~/bin/smart-lan-router.py`
daemon) into one repo. Pending gated cutovers (apricot DNS, the fleet rename,
retiring originals) are in [`docs/topology.md` ](docs/topology.md#migration ).