feat(@tools/net-tools): add tray icon system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-10 02:20:23 -07:00
parent af54b6742d
commit 68c848dc56
69 changed files with 2023 additions and 180 deletions

2
.gitignore vendored
View file

@ -6,3 +6,5 @@ __pycache__/
*.pyc
# Volatile discovered state (current LAN IPs) — written by the daemon, not source.
data/lan-state.json
data/agent-status.json
tray/.venv/

View file

@ -4,14 +4,20 @@ 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:
- **`bin/`** — renderers that project the source of truth onto each device:
`host-apply` (ssh config), `mesh-hosts-render` (`/etc/hosts`), `wg-dns-sync`
(apricot's mesh dnsmasq).
- **[`smart-lan-router/`](smart-lan-router/)** — the policy-routing daemon that
makes the LAN "smart": the laptop automatically uses the 5ms LAN path to home
hosts when home, and the WireGuard tunnel when away — identity-gated so it
never routes to a stranger at the same RFC1918 IP. (The home gateway is a dumb
Xfinity box with no API; the intelligence lives here, on the client.)
- **`bin/net`** — **the one command**: `status · whoami · doctor · sync · up ·
down · enroll phone · gui`. Imports the agent as a library, so every surface
shares one implementation. The renderers (`host-apply`, `mesh-hosts-render`,
`wg-dns-sync`, `fleet-status`) remain as internals/direct tools.
- **`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.)
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,
@ -21,16 +27,27 @@ MAC, or identity URL anywhere else — add it here and regenerate.
| Class | Canonical | Old alias | LAN | WG mesh | Public |
|-------|-----------|-----------|-----|---------|--------|
| GPU compute (stone fruit) | **apricot** | — | `10.0.0.116` | `10.9.0.2` | — |
| GPU compute (stone fruit) | **apricot** | — | *DHCP, discovered* | `10.9.0.2` | — |
| 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` |
| phone (berry) | **strawberry** | `phone-quinn` | — | `10.9.0.5` | — |
Names are mid-migration (**alias-first**): the source of truth declares the fruit
name canonical with the old name as an alias, and every renderer emits **both**,
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).
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.
## Naming: one rule per suffix
@ -61,29 +78,52 @@ manages (it removes them; its block supersedes them).
| 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`. |
| `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`. |
| `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. |
| `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` | **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). |
| `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). |
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.
## Install
## Install — same agent, every node
```sh
./install.sh # symlink bin/* into ~/bin or ~/.local/bin
sudo smart-lan-router/install-smart-router.sh # install + start the LaunchDaemon (fennel only)
git clone ssh://git@forge.black.lan:2222/lilith/net-tools.git ~/net-tools
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
```
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
its own code changes — fleet updates propagate by pushing to the forge.
## Changing things
| 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 |
| 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 |
| 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 |
| enroll a phone | `wg-phone-add -d <device>` then add a `class: phone` entry |
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.

67
bin/fleet-status Executable file
View file

@ -0,0 +1,67 @@
#!/bin/sh
# fleet-status — one-screen dashboard of every agent node, in the terminal.
#
# For each agent host in mesh-hosts.json (ssh_user != null): read its
# data/agent-status.json (locally for this node, over ssh for the rest — using
# the fleet ssh names the agents themselves maintain) and render a table:
#
# NODE LOC ROUTE HEAD AGE HOSTNAME DISCOVERED
# fennel HOME en0 af54b67 4s fennel apricot=10.0.0.118 ...
#
# AGE is seconds since the agent's last cycle — STALE (>90s) means the agent is
# down or wedged on that node. "no status" = agent not yet running new code
# (e.g. waiting on its next pull).
#
# Read-only; safe from anywhere on the mesh.
set -eu
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 "fleet-status: cannot locate data/mesh-hosts.json" >&2; exit 1; }
command -v jq >/dev/null || { echo "fleet-status: jq not installed" >&2; exit 1; }
short=$(hostname 2>/dev/null | cut -d. -f1)
now=$(date +%s)
printf '%-11s %-5s %-7s %-9s %-7s %-11s %s\n' NODE LOC ROUTE HEAD AGE HOSTNAME DISCOVERED
jq -r '.hosts[] | select(.ssh_user != null) | .name' "$data_file" | while read -r node; do
is_self=0
[ "$node" = "$short" ] && is_self=1
# Also self if any alias matches our short hostname.
if [ "$is_self" -eq 0 ]; then
if jq -e --arg n "$node" --arg h "$short" \
'.hosts[] | select(.name == $n) | .aliases | index($h)' "$data_file" >/dev/null 2>&1; then
is_self=1
fi
fi
if [ "$is_self" -eq 1 ]; then
raw=$(cat "$root/data/agent-status.json" 2>/dev/null || true)
else
raw=$(ssh -n -o ConnectTimeout=5 -o BatchMode=yes "$node" \
'cat ~/net-tools/data/agent-status.json 2>/dev/null' 2>/dev/null || true)
fi
if [ -z "$raw" ] || ! printf '%s' "$raw" | jq -e . >/dev/null 2>&1; then
printf '%-11s %s\n' "$node" "— no status (agent down, unreachable, or awaiting pull)"
continue
fi
printf '%s' "$raw" | jq -r --argjson now "$now" '
( $now - .ts ) as $age
| [ .self,
(.location // "-"),
(.lan_route_via // "-"),
(.head // "-"),
(if $age > 90 then "STALE" else "\($age)s" end),
(.hostname | split(".")[0]),
( .discovered | to_entries | map("\(.key)=\(.value)") | join(" ") | if . == "" then "-" else . end )
] | @tsv
' | awk -F'\t' '{printf "%-11s %-5s %-7s %-9s %-7s %-11s %s\n", $1,$2,$3,$4,$5,$6,$7}'
done

View file

@ -110,12 +110,15 @@ render_block() {
jq -r --arg s "$self" --argjson reachlan "$reachlan" --argjson ov "$overlay" '
.hosts[]
| select(.name != $s)
| select(.ssh_user != null)
| . as $h
| (($ov[$h.name]) // $h.lan) as $lan
| ( $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")\n CheckHostIP no\n StrictHostKeyChecking accept-new"
| "\nHost \(([$h.name] + $h.aliases) | join(" "))\n HostName \($addr)\n User \($h.ssh_user // "lilith")"
+ (if $h.ssh_identity then "\n IdentityFile \($h.ssh_identity)" else "" end)
+ "\n CheckHostIP no\n StrictHostKeyChecking accept-new"
' "$data_file"
printf '\n%s\n' "$END"
}

View file

@ -79,38 +79,60 @@ 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'
# LAN records — current discovered IP (overlay) over the static seed.
# Bare names + .lan names live here so the default path is the fast one.
jq -r --argjson ov "$overlay" '
# 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" '
.hosts[]
| . as $h
| (($ov[$h.name]) // $h.lan) as $lan
| select($lan != null)
| "\($lan)\t" + ((([$h.name] + ($h.aliases // [])) | map(. + ".lan", .)) | join(" "))
| "\($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. LAN-less hosts also get their
# bare name here (the mesh IP is their only address).
jq -r '
# 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" '
.hosts[]
| . as $h
| "\($h.wg)\t"
+ ((([$h.name] + ($h.aliases // [])) | map(. + ".wg")) | join(" "))
+ (if $h.lan == null then " " + (([$h.name] + ($h.aliases // [])) | join(" ")) else "" end)
+ (if ($h.lan == null) or ($reachlan | not)
then " " + (([$h.name] + ($h.aliases // [])) | join(" ")) else "" end)
' "$data_file"
# Service vhosts — resolve to the hosting host'\''s CURRENT LAN IP.
jq -r --argjson ov "$overlay" '
# 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" '
. as $d
| ($d.services // {}) | to_entries[]
| select(.key != "_note")
| .key as $hname
| ($d.hosts[] | select(.name == $hname)) as $h
| (($ov[$hname]) // $h.lan) as $lan
| select($lan != null)
| "\($lan)\t\(.value | join(" "))"
| (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"
}

247
bin/net Executable file
View file

@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""net — the one command for the mesh.
Every verb is a thin face over the same machinery the fleet agent runs (this
script imports smart-lan-router.py as a module — shims, identity, and config
are written exactly once). The GUI and tray call these same verbs, so no
surface can disagree with another.
net status fleet table (every agent's last snapshot)
net whoami which host this is, roles, vantage
net doctor [host] probe lan/wg/identity per path, name the chokepoint
net sync force-converge this node's /etc/hosts + ssh now
net up | net down bring the wg tunnel up / down
net enroll phone NAME --os ios|android [--wg 10.9.0.N]
wg peer + QR (wg-phone-add) + declared entry
net gui open the Mesh control window (darwin)
"""
from __future__ import annotations
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import time
# --- locate the repo + import the agent as a library ---------------------------
SELF = os.path.abspath(__file__)
while os.path.islink(SELF):
link = os.readlink(SELF)
SELF = link if os.path.isabs(link) else os.path.join(os.path.dirname(SELF), link)
ROOT = os.path.dirname(SELF)
while ROOT != "/" and not os.path.isfile(os.path.join(ROOT, "data", "mesh-hosts.json")):
ROOT = os.path.dirname(ROOT)
AGENT_PY = os.path.join(ROOT, "smart-lan-router", "smart-lan-router.py")
_spec = importlib.util.spec_from_file_location("slr", AGENT_PY)
slr = importlib.util.module_from_spec(_spec)
sys.modules["slr"] = slr
_spec.loader.exec_module(slr)
def data() -> dict:
return slr.load_json(slr.find_data_file())
def overlay() -> dict:
p = os.path.join(ROOT, "data", "lan-state.json")
try:
return slr.load_json(p)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def host_entry(d: dict, name: str) -> dict | None:
for h in d.get("hosts", []):
if h["name"] == name or name in (h.get("aliases") or []):
return h
return None
def ping_ms(ip: str, timeout_s: int = 2) -> float | None:
ping = shutil.which("ping") or "/sbin/ping"
flag = "-t" if slr.PLATFORM == "darwin" else "-W"
rc, out, _ = slr._run([ping, "-c", "1", flag, str(timeout_s), ip], timeout_s + 2)
if rc != 0:
return None
import re
m = re.search(r"time=([\d.]+)", out)
return float(m.group(1)) if m else 0.0
# --- verbs ---------------------------------------------------------------------
def cmd_whoami(_args: list[str]) -> int:
ctx = slr.build_ctx(slr.find_data_file())
cfg = slr.load_config(slr.find_data_file())
home, gw, gwif = slr.is_home(cfg)
print(f"host : {ctx['self_name'] or 'UNKNOWN — not in mesh-hosts.json'}")
print(f"platform : {slr.PLATFORM}")
print(f"roles : {', '.join(sorted(ctx['roles']))}")
print(f"location : {'HOME' if home else 'AWAY'} (gw {gw} on {gwif})")
print(f"vantage : {'LAN-capable' if (ctx['self_lan'] is not None or 'route' in ctx['roles']) else 'mesh-only'}")
return 0
def cmd_status(_args: list[str]) -> int:
os.execv(os.path.join(ROOT, "bin", "fleet-status"), ["fleet-status"])
def cmd_sync(_args: list[str]) -> int:
rc1 = subprocess.run(["sudo", os.path.join(ROOT, "bin", "mesh-hosts-render"), "--install"]).returncode
rc2 = subprocess.run([os.path.join(ROOT, "bin", "host-apply"), "--ssh-apply"]).returncode
return rc1 or rc2
def cmd_doctor(args: list[str]) -> int:
d = data()
ov = overlay()
me = slr.identify_self(d)
my_name = me["name"] if me else None
targets = []
if args:
h = host_entry(d, args[0])
if not h:
print(f"doctor: unknown host '{args[0]}'", file=sys.stderr)
return 1
targets = [h]
else:
targets = [h for h in d["hosts"] if h["name"] != my_name and h.get("ssh_user") is not None]
worst = 0
for h in targets:
name = h["name"]
lan_ip = ov.get(name) or h.get("lan")
wg_ip = h.get("wg")
print(f"\n■ {name}" + (f" (aliases: {', '.join(h['aliases'])})" if h.get("aliases") else ""))
lan_ms = ping_ms(lan_ip) if lan_ip else None
wg_ms = ping_ms(wg_ip) if wg_ip else None
if lan_ip:
print(f" lan {lan_ip:<14} {'%.1f ms' % lan_ms if lan_ms is not None else 'UNREACHABLE'}")
if wg_ip:
print(f" wg {wg_ip:<14} {'%.1f ms' % wg_ms if wg_ms is not None else 'UNREACHABLE'}")
ident = h.get("identity")
ident_ok = None
if ident and lan_ip:
url = ident["url"].replace("{ip}", lan_ip)
rc, out, _ = slr._run(["/usr/bin/curl", "-s", "--max-time", "4", url], 6)
ident_ok = rc == 0 and all(m in out for m in ident.get("markers", []))
print(f" svc {url.split('/')[2]:<14} {'OK' if ident_ok else 'no answer'}")
# verdict
if lan_ip and lan_ms is not None:
print(f" → healthy: direct LAN path ({lan_ms:.1f} ms)")
elif wg_ms is not None:
if lan_ip:
print(f" → LAN path dead but mesh alive — use {name}.wg; check the host's LAN link/switch")
worst = max(worst, 1)
else:
print(f" → reachable via mesh ({wg_ms:.1f} ms) — normal for this host")
else:
print(f" → DOWN on every path — host offline, or this node's tunnel is down")
worst = max(worst, 2)
print()
return worst
def _wg_conf() -> str:
cand = [os.path.expanduser("~/.wireguard/wg1.conf"), "/etc/wireguard/wg1.conf"]
for c in cand:
if os.path.exists(c):
return c
return cand[0]
def cmd_up(_args: list[str]) -> int:
return subprocess.run(["sudo", shutil.which("wg-quick") or "wg-quick", "up", _wg_conf()]).returncode
def cmd_down(_args: list[str]) -> int:
return subprocess.run(["sudo", shutil.which("wg-quick") or "wg-quick", "down", _wg_conf()]).returncode
def cmd_enroll(args: list[str]) -> int:
if not args or args[0] != "phone" or len(args) < 2:
print("usage: net enroll phone <name> [--os ios|android] [--wg 10.9.0.N]", file=sys.stderr)
return 1
name = args[1]
osname = "ios"
wg_ip = None
rest = args[2:]
while rest:
if rest[0] == "--os" and len(rest) > 1:
osname, rest = rest[1], rest[2:]
elif rest[0] == "--wg" and len(rest) > 1:
wg_ip, rest = rest[1], rest[2:]
else:
print(f"enroll: unknown arg {rest[0]}", file=sys.stderr)
return 1
wpa = shutil.which("wg-phone-add") or os.path.expanduser(
"~/Code/@scripts/session-tools/bin/wg-phone-add")
if not os.path.exists(wpa):
print("enroll: wg-phone-add not found (session-tools)", file=sys.stderr)
return 1
cmd = [wpa, "-d", name] + (["-i", wg_ip] if wg_ip else [])
if subprocess.run(cmd).returncode != 0:
return 1
# read the address wg-phone-add allocated
addr_file = os.path.expanduser(f"~/.config/wg-mesh/clients/{name}/address")
try:
with open(addr_file, encoding="utf-8") as fh:
wg_ip = fh.read().strip()
except OSError:
print(f"enroll: peer created but {addr_file} unreadable — add the JSON entry manually", file=sys.stderr)
return 1
df = slr.find_data_file()
d = slr.load_json(df)
if host_entry(d, name):
print(f"enroll: {name} already declared")
return 0
d["hosts"].append({
"name": name, "aliases": [], "class": "phone",
"role": f"phone ({osname}) — wg mesh client via WireGuard app (DNS=10.9.0.2); no agent, no sshd",
"os": osname, "ssh_user": None, "wg": wg_ip,
"lan": None, "public": None, "mac": None, "identity": None,
})
with open(df, "w", encoding="utf-8") as fh:
json.dump(d, fh, indent=2, ensure_ascii=False)
fh.write("\n")
subprocess.run(["git", "-C", ROOT, "add", "data/mesh-hosts.json"], capture_output=True)
print(f"enroll: {name} ({osname}) declared at {wg_ip} — staged; fleet converges after the next autocommit+push")
return 0
def cmd_gui(_args: list[str]) -> int:
if slr.PLATFORM != "darwin":
print("gui: darwin-only for now (use `net status` / the web dashboard)", file=sys.stderr)
return 1
py = os.path.join(ROOT, "tray", ".venv", "bin", "python")
if not os.path.exists(py):
print("gui: tray venv missing — run tray/install-tray.sh first", file=sys.stderr)
return 1
return subprocess.run([py, os.path.join(ROOT, "gui", "mesh-gui.py")]).returncode
VERBS = {
"status": cmd_status, "whoami": cmd_whoami, "sync": cmd_sync,
"doctor": cmd_doctor, "up": cmd_up, "down": cmd_down,
"enroll": cmd_enroll, "gui": cmd_gui,
}
def main() -> int:
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"):
print(__doc__.strip())
return 0
verb = sys.argv[1]
fn = VERBS.get(verb)
if fn is None:
print(f"net: unknown verb '{verb}' (try: {', '.join(VERBS)})", file=sys.stderr)
return 1
return fn(sys.argv[2:])
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,7 +1,9 @@
{
"_purpose": "Single source of truth for the wg1 mesh + LAN: the four hosts, their addresses on each path, the MAC + L7 identity probe the smart-lan-router daemon uses, and the DNS records apricot's dnsmasq serves. Everything that needs a host address derives from here — never hardcode mesh IPs, MACs, or identity URLs elsewhere.",
"_schema": {
"hosts[].name": "Canonical name = fruit family encodes machine class (gpu=stone fruit, cpu=pome, cloud=citrus, laptop=vegetable).",
"hosts[].name": "Canonical name = fruit family encodes machine class (gpu=stone fruit, cpu=pome, cloud=citrus, laptop=vegetable, phone=berry).",
"fleet.enforce_hostname": "true => every agent converges its node's OS hostname to its canonical name (scutil on darwin, hostnamectl on linux). The FLEET renames hosts — never run hostnamectl by hand.",
"phones": "class=phone (berry family): no agent possible (ios/android run nothing); they are DNS clients — WireGuard app with DNS=10.9.0.2, names served by apricot's mesh dnsmasq (wg-dns-sync). ssh_user null => no ssh stanza rendered. os distinguishes ios/android. Enroll with wg-phone-add, then add the entry here. If the phone's per-SSID Wi-Fi MAC is pinned (iOS 'Private Wi-Fi Address: Fixed'), add mac to get home-LAN discovery too.",
"hosts[].aliases": "Old names, kept working during the alias-first rename. Renderers emit a record for name AND every alias.",
"hosts[].class": "gpu | cpu | cloud | laptop.",
"hosts[].wg/lan/public": "wg = mesh IP (10.9.0.0/24); lan = home LAN IP (10.0.0.0/24, null if roaming/no LAN leg); public = internet IP (null if none).",
@ -12,6 +14,9 @@
"daemon_targets": "smart-lan-router.py routes hosts where lan AND identity are both set, excluding the host it runs on."
},
"_consumers": ["bin/wg-dns-sync", "bin/mesh-hosts-render", "smart-lan-router/smart-lan-router.py"],
"fleet": {
"enforce_hostname": true
},
"mesh": {
"interface": "wg1",
"cidr": "10.9.0.0/24",
@ -68,6 +73,19 @@
"mac": "74:a6:cd:d4:b0:39",
"identity": null
},
{
"name": "strawberry",
"aliases": ["phone-quinn"],
"class": "phone",
"role": "Quinn's iPhone — wg mesh client via WireGuard app (DNS=10.9.0.2); no agent, no sshd",
"os": "ios",
"ssh_user": null,
"wg": "10.9.0.5",
"lan": null,
"public": null,
"mac": null,
"identity": null
},
{
"name": "yuzu",
"aliases": ["vps", "quinn-vps"],
@ -75,6 +93,7 @@
"role": "1984 Hosting (Iceland) — WireGuard mesh hub, quinn production",
"os": "linux",
"ssh_user": "root",
"ssh_identity": "~/.ssh/id_ed25519_1984",
"wg": "10.9.0.1",
"lan": null,
"public": "89.127.233.145",

View file

@ -4,22 +4,22 @@
```
┌─────────────────────────────────────────────┐
│ vps (quinn-vps) — 1984 Hosting, Iceland │
yuzu (vps, quinn-vps) — 1984, Iceland
│ WireGuard hub wg 10.9.0.1 │
│ public 89.127.233.145:51820 │
└───────────────┬─────────────────────────────┘
│ wg1 tunnel (AllowedIPs 10.9.0.0/24, 10.0.0.0/24)
┌──────────────────────────────────────┐
┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐
│ apricot │ │ black │ │ plum
│ wg 10.9.0.2 │ │ wg 10.9.0.4 │ │ wg 10.9.0.3
│ lan 10.0.0. │─────│ lan 10.0.0. │ │ macOS,
116 │ LAN │ 11 │ │ roams
│ mesh DNS │ │ LAN DNS │ │ (DHCP)
└─────────────┘ └─────────────┘ └─────────────┘
apricot + black share the home LAN (10.0.0.0/24);
plum joins it only when physically home, else routes via the hub.
│ wg1 (AllowedIPs 10.9.0.0/24, 10.0.0.0/24)
┌───────────────┬───┴─────────────────────────────┐
┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐ ┌─────┴───────┐
│ apricot │ │ pear (black)│ │ fennel │ │ strawberry
│ wg 10.9.0.2 │ │ wg 10.9.0.4 │ │ (plum) │ │ (phone-
│ lan: DHCP, │─│ lan 10.0.0. │ │ wg 10.9.0.3 │ │ quinn) ios
discovered │L│ 11 │ │ macOS, │ │ wg 10.9.0.5
│ mesh DNS │A│ LAN DNS │ │ roams │ │ DNS client
└─────────────┘N└─────────────┘ └─────────────┘ └─────────────┘
apricot + pear share the home LAN (10.0.0.0/24); fennel joins it
when physically home; phones ride the tunnel with DNS=10.9.0.2.
```
- **Mesh `10.9.0.0/24`** — full WireGuard overlay via the Iceland hub. Every host
@ -40,8 +40,7 @@ can *resolve* it):
point their resolver at `10.9.0.2`, so for them dnsmasq does not answer.
- **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.
service vhosts). Every node's agent regenerates it automatically on drift.
- **fennel** roams off-LAN where dnsmasq is unreachable, so the managed
`/etc/hosts` block is its only resolution path then.
@ -57,14 +56,14 @@ The old `*.local` platform scheme is **retired** (platform → `.com`, infra →
| **fennel** | `.lan` ✦ · `.wg` ⚑ | `.lan` ✦ · `.wg` ⚑ | — | `.wg` only |
| **yuzu** | `.wg` only | `.wg` only | `fennel.wg` only | — |
✦ preferred when co-located on the home LAN · ⚑ plum falls back to `.wg` when it
roams · **plum and vps are only ever reachable inbound via `.wg`** (plum has no
stable LAN IP; vps has no LAN leg).
✦ preferred when co-located on the home LAN · ⚑ fennel falls back to `.wg` when
it roams · **fennel and yuzu are only ever reachable inbound via `.wg`** (fennel
has no stable LAN IP; yuzu has no LAN leg) · strawberry is reachable at
`strawberry.wg` (10.9.0.5) when its tunnel is up, but runs no services.
`.wg` in this matrix is resolved via each host's static `/etc/hosts` block
(`mesh-hosts-render --install`), **not** via dnsmasq — see DNS responsibilities
above. The dnsmasq `.wg` records are the phones-only path. So the matrix holds
only once the static block is installed on apricot, black, and plum.
`.wg` in this matrix resolves via each node's managed `/etc/hosts` block, which
every agent maintains — the dnsmasq `.wg` records are the phones-only path
(see DNS responsibilities above).
## Hub IP note
@ -73,9 +72,21 @@ plum's live `wg1.conf` endpoint is `89.127.233.145:51820`. An older
Iceland hub — treat that as stale/secondary unless confirmed against the hub's
own WireGuard config. `mesh-hosts.json` records only the live `.145`.
## Smart routing daemon (fennel)
## The fleet agent
`smart-lan-router/smart-lan-router.py` runs as a root LaunchDaemon on the laptop.
`smart-lan-router/smart-lan-router.py` runs as a root service on **every node**
(launchd on darwin, systemd on linux — `install-agent.sh` picks). One codebase;
each node derives its roles from its own `mesh-hosts.json` entry:
| Role | Who | What |
|------|-----|------|
| pull | all | `git pull` as the repo owner (never root); exit-and-restart when its own code changes — pushing to the forge updates the fleet |
| hostname | all (`fleet.enforce_hostname`) | converge the OS hostname to the canonical name — the fleet renames hosts, humans don't |
| discover | LAN nodes | declared MAC → current DHCP IP via ARP/`ip neigh``data/lan-state.json` (each LAN node discovers independently) |
| route | laptop, darwin | the home/away subnet switch below |
| render | all | regenerate `/etc/hosts` + ssh config on any change, at this node's vantage (mesh-only nodes resolve everything via `.wg` IPs) |
The original laptop problem the route role solves:
**The problem it solves:** the wg config's `AllowedIPs` includes `10.0.0.0/24`, so
the tunnel installs a route capturing the *entire* home LAN. While home, traffic
@ -91,16 +102,17 @@ 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.
3. **Name-sync (discover role)** — keep ssh + hosts in sync with reality. Each
LAN host's **MAC is stable while its DHCP IP drifts**, and the neighbour
table (ARP / `ip neigh`) maps MAC↔IP. The agent reads it (rate-limited
ping-sweep of the `/24` 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 node's render user (its `ssh_user`), `host-apply --ssh-apply`
(`~/.ssh/config`). Proven live: 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
@ -121,15 +133,20 @@ branch preserves the watchdog's original purpose). The watchdog was retired
## Fleet rename
Names follow **fruit family = machine class** (apricot=GPU, pear=CPU/storage,
yuzu=cloud, fennel=laptop), executed **alias-first**: `mesh-hosts.json` sets the
fruit name canonical with the old name in `aliases[]`, and every renderer emits
both (`pear.wg`+`black.wg`, `forge.pear.lan`+`forge.black.lan`). Nothing
breaks on day one. Irreversible cutovers are separately gated: OS hostname
(`hostnamectl`/`scutil` — also fixes plum's stale `plum.voyager.nasty.sh`), the
Forgejo URL, black's NFS export host, ssh stanzas, and the reference sweep
(memory, CLAUDE.md, MCP ssh-by-name). Never retire an old name until every
consumer resolves the new one. `apricot` is unchanged.
Names follow **fruit family = machine class** (apricot=GPU stone fruit,
pear=CPU/storage pome, yuzu=cloud citrus, fennel=laptop vegetable,
strawberry=phone berry), executed **alias-first**: the fruit name is canonical,
the old name lives in `aliases[]` forever, and every renderer emits both —
`pear.wg`+`black.wg`, `forge.pear.lan`+`forge.black.lan`, `ssh black` keeps
working. Old names are **never retired**; nothing that says "black" ever breaks.
**OS hostnames converge automatically**: with `fleet.enforce_hostname: true`,
each node's agent renames its own OS (`scutil` ×3 / `hostnamectl`) to the
canonical name on its next cycle — this is how the relic FQDNs
(`plum.voyager.nasty.sh`, `0.vps.1984.uvlava.com`) die. Never run the rename by
hand. String-identity consumers stay untouched on purpose: the Forgejo runner
label stays `black` (workflows reference it), the forge URL and NFS exports keep
their old names as permanent aliases.
## Migration
@ -143,35 +160,26 @@ This repo replaces tooling scattered across four places:
| `setup-lan-dns.sh` (not in ~/Code — drifted) | `bin/mesh-hosts-render` | ✅ replaced |
| `bin/host-apply` (per-device ssh view) | new | ✅ here |
| `~/bin/smart-lan-router.py` (loose) | `smart-lan-router/smart-lan-router.py` (JSON-driven, self-heal) | ✅ here + fixed |
| `~/{install-smart-router.sh,com.lilith…plist}` (loose) | `smart-lan-router/` | ✅ here |
| `~/{install-agent.sh,com.lilith…plist}` (loose) | `smart-lan-router/` | ✅ here |
**Pending — gated on greenlight (these touch live DNS on apricot):**
**Done (2026-06-09):** agent installed + verified on all four nodes (launchd on
fennel; systemd on pear/apricot/yuzu); all three remote nodes are real git
clones of `origin/main` (repo public on the LAN-only forge for credential-less
pulls); `mesh-hosts-render --install` + `host-apply --ssh-apply` live on all
four; fennel's hostname converged; the old `wg-route-watchdog`, `setup-lan-dns`
block, `/etc/resolver/*.lan` files, loose `~/bin/smart-lan-router.py`, and the
stale self-MAC ARP entry are all retired.
1. Re-clone/pull this repo on **apricot** and run `./install.sh`.
2. Run `sudo wg-dns-sync` on apricot from this repo; verify dnsmasq still serves
(`dig @10.9.0.2 quinn.apricot.lan`, `dig @10.9.0.2 apricot.wg`).
3. Update the two session-tools consumers that call the old path by absolute
reference — `bin/apricot-doctor` (`"$repo/bin/wg-dns-sync"`) and
`bin/quinn-phone-bootstrap` (`ssh apricot 'cd …/session-tools && sudo
bin/wg-dns-sync'`) — to the new repo path.
4. Run `sudo mesh-hosts-render --install` on **apricot, black, and plum** (every
host that must resolve a peer's `.wg` name — dnsmasq only answers `.wg` for
phones with `DNS=10.9.0.2`). Then on plum retire the old `setup-lan-dns.sh`
static block and `/etc/resolver/{apricot,black}.lan`.
5. **fennel (laptop):** `sudo smart-lan-router/install-smart-router.sh` reinstalls the
LaunchDaemon pointed at the repo path and retires the loose `~/bin/smart-lan-router.py`,
`~/install-smart-router.sh`, `~/com.lilith.smart-lan-router.plist`. Verify
`route -n get 10.0.0.11``interface: en0` (not utun*).
6. Only after apricot is verified on the new path: remove the originals from
`session-tools` and `magic-civilization/scripts/lan`, and push.
7. **Fleet rename cutovers** (each independently, after the above): ssh stanzas →
OS hostname → Forgejo `forge.pear.lan` vhost → NFS export → reference sweep.
See [Fleet rename](#fleet-rename).
**Still pending:**
Do not delete the originals in the same change that adds this repo — every host
still running the old path needs to re-install first.
> **Blocked right now:** the laptop's LAN to pear/apricot is degraded by the very
> stale self-MAC ARP entry the daemon now self-heals (`10.0.0.11 → fennel's own
> MAC, permanent`). Clear it (`sudo arp -d 10.0.0.11`) and reinstall the daemon
> (step 5) to restore the LAN fast-path before attempting any remote cutover.
1. **apricot mesh-DNS cutover** — run `sudo bin/wg-dns-sync` on apricot from
this repo (serves phones the `.wg`/`.lan` names); verify
`dig @10.9.0.2 apricot.wg`. Then update the two session-tools consumers that
call the old absolute path (`bin/apricot-doctor`, `bin/quinn-phone-bootstrap`)
and delete the originals from `session-tools/{data,bin}`.
2. **pear/yuzu hostname convergence** — automatic on the next pull cycle after
the `fleet.enforce_hostname` commit lands on the forge (the agents do it;
watch for `hostname converged: black → pear` in the journal).
3. **yuzu → home ssh auth** — yuzu reaches pear/apricot by name but its key is
not authorized there. Deliberate: internet-facing node, least-privilege.
Grant only if actually needed.

126
gui/index.html Normal file
View file

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Mesh control</title>
<style>
:root { color-scheme: light dark; }
body { font-family: -apple-system, "Helvetica Neue", sans-serif; margin: 0;
background: Canvas; color: CanvasText; user-select: none; }
#banner { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
#banner.home { background: #e6f4ea; color: #1e6b34; }
#banner.away { background: #e8f0fe; color: #1a56b0; }
#banner.unknown { background: #fef3e0; color: #8a5a00; }
#banner b { display: block; font-size: 15px; }
#banner small { font-size: 12px; opacity: .85; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 16px;
border-bottom: 0.5px solid color-mix(in srgb, CanvasText 12%, transparent); }
.row:hover { background: color-mix(in srgb, CanvasText 5%, transparent); }
.dot { width: 10px; height: 10px; border-radius: 50%; background: #999; flex: none; }
.dot.ok { background: #2da44e; } .dot.bad { background: #d1242f; } .dot.idle { background: #bbb; }
.meta { flex: 1; min-width: 0; }
.meta b { font-size: 14px; font-weight: 600; }
.meta b small { font-weight: 400; opacity: .55; font-size: 11px; }
.meta span { display: block; font-size: 12px; opacity: .65; }
.ip { font-family: ui-monospace, monospace; font-size: 12px; opacity: .5; }
#foot { display: flex; justify-content: space-between; align-items: center;
padding: 10px 16px; font-size: 12px; opacity: .7; }
#menu { position: fixed; display: none; min-width: 220px; background: Canvas;
border: 0.5px solid color-mix(in srgb, CanvasText 30%, transparent);
border-radius: 8px; padding: 4px 0; font-size: 13px; z-index: 10;
box-shadow: 0 8px 24px rgba(0,0,0,.18); }
#menu p { margin: 0; padding: 7px 14px; cursor: default; }
#menu p:hover { background: color-mix(in srgb, CanvasText 8%, transparent); }
#menu .hdr { font-size: 11px; opacity: .5; cursor: default; }
#menu .hdr:hover { background: none; }
#out { white-space: pre-wrap; font-family: ui-monospace, monospace; font-size: 11.5px;
padding: 10px 16px; display: none; border-top: 0.5px solid
color-mix(in srgb, CanvasText 12%, transparent); max-height: 180px; overflow-y: auto; }
</style>
</head>
<body>
<div id="banner" class="unknown"><div><b id="loc">Checking where you are…</b><small id="locsub"></small></div></div>
<div id="hosts"></div>
<div id="foot"><span>right-click a device for the power tools</span><span id="agent"></span></div>
<div id="out"></div>
<div id="menu"></div>
<script>
let MENU = document.getElementById('menu');
let fleet = null;
function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html !== undefined) e.innerHTML = html; return e; }
async function refresh() {
fleet = await window.pywebview.api.fleet();
const b = document.getElementById('banner');
if (fleet.location === 'HOME') {
b.className = 'home';
document.getElementById('loc').textContent = "You're home — fast lane is on";
document.getElementById('locsub').textContent = 'devices talk directly (via ' + (fleet.route || '?') + '), not via Iceland';
} else if (fleet.location === 'AWAY') {
b.className = 'away';
document.getElementById('loc').textContent = "You're away — secure tunnel to home";
document.getElementById('locsub').textContent = 'everything still works, a bit slower (via Iceland)';
} else {
b.className = 'unknown';
document.getElementById('loc').textContent = 'Agent not reporting';
document.getElementById('locsub').textContent = 'run: sudo smart-lan-router/install-agent.sh';
}
const age = fleet.agent_ts ? Math.round(Date.now() / 1000 - fleet.agent_ts) : null;
document.getElementById('agent').textContent =
age === null ? 'agent: no status' : (age > 90 ? 'agent: STALE ' + age + 's' : 'agent: ok (' + age + 's)');
const wrap = document.getElementById('hosts');
wrap.textContent = '';
for (const h of fleet.hosts) {
const row = el('div', 'row');
const dot = el('span', 'dot' + (h.phone ? ' idle' : ''));
const meta = el('div', 'meta');
const alias = h.aliases.length ? ' <small>(' + h.aliases[0] + ')</small>' : '';
meta.appendChild(el('b', null, h.name + alias));
const sub = el('span', null, h.friendly + (h.phone ? ' · tunnel client' : ' · checking…'));
meta.appendChild(sub);
row.appendChild(dot); row.appendChild(meta);
row.appendChild(el('span', 'ip', h.ip || ''));
row.oncontextmenu = (ev) => { ev.preventDefault(); openMenu(ev, h); };
wrap.appendChild(row);
if (!h.phone && h.ip) probeRow(h, dot, sub);
}
}
async function probeRow(h, dot, sub) {
const r = await window.pywebview.api.probe(h.ip);
if (r.ok) { dot.className = 'dot ok'; sub.textContent = h.friendly + ' · online · ' + (r.ms < 30 ? 'fast (' + r.ms + ' ms)' : r.ms + ' ms'); }
else { dot.className = 'dot bad'; sub.textContent = h.friendly + ' · not answering'; }
}
function item(label, fn) { const p = el('p', null, label); p.onclick = () => { hideMenu(); fn(); }; return p; }
function openMenu(ev, h) {
MENU.textContent = '';
MENU.appendChild(el('p', 'hdr', h.name + ' — advanced'));
MENU.appendChild(item('Copy address', () => window.pywebview.api.copy(h.ip)));
if (!h.phone) {
MENU.appendChild(item('Open terminal here (ssh)', () => window.pywebview.api.ssh_terminal(h.name)));
MENU.appendChild(item('Diagnose path…', () => runDoctor(h.name)));
if (h.wg) MENU.appendChild(item('Copy tunnel address (.wg)', () => window.pywebview.api.copy(h.wg)));
}
MENU.style.display = 'block';
MENU.style.left = Math.min(ev.clientX, window.innerWidth - 240) + 'px';
MENU.style.top = Math.min(ev.clientY, window.innerHeight - 160) + 'px';
}
function hideMenu() { MENU.style.display = 'none'; }
document.addEventListener('click', hideMenu);
async function runDoctor(name) {
const out = document.getElementById('out');
out.style.display = 'block';
out.textContent = 'diagnosing ' + name + '…';
out.textContent = await window.pywebview.api.doctor(name);
}
window.addEventListener('pywebviewready', () => { refresh(); setInterval(refresh, 30000); });
</script>
</body>
</html>

105
gui/mesh-gui.py Executable file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Mesh control — the for-dummies GUI (v0, read-only + safe verbs).
A pywebview window over the same data planes everything else uses: the declared
truth (mesh-hosts.json), the discovered overlay (lan-state.json), and the
agent's snapshot (agent-status.json). Every action in the right-click menu is a
`net` verb the GUI invents no behavior.
Run via `net gui` (uses the tray venv, which carries pywebview).
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
AGENT_PY = os.path.join(ROOT, "smart-lan-router", "smart-lan-router.py")
_spec = importlib.util.spec_from_file_location("slr", AGENT_PY)
slr = importlib.util.module_from_spec(_spec)
sys.modules["slr"] = slr
_spec.loader.exec_module(slr)
FRIENDLY_ROLE = {
"gpu": "the AI box", "cpu": "storage + forge", "cloud": "cloud hub, Iceland",
"laptop": "this laptop", "phone": "phone",
}
def _load(path: str) -> dict:
try:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError):
return {}
class Api:
"""Exposed to the page as window.pywebview.api.*"""
def fleet(self) -> dict:
d = _load(os.path.join(ROOT, "data", "mesh-hosts.json"))
ov = _load(os.path.join(ROOT, "data", "lan-state.json"))
status = _load(os.path.join(ROOT, "data", "agent-status.json"))
me = slr.identify_self(d)
my = me["name"] if me else None
hosts = []
for h in d.get("hosts", []):
if h["name"] == my:
continue
hosts.append({
"name": h["name"],
"aliases": h.get("aliases") or [],
"klass": h.get("class"),
"friendly": FRIENDLY_ROLE.get(h.get("class", ""), h.get("class", "")),
"ip": ov.get(h["name"]) or h.get("lan") or h.get("wg"),
"wg": h.get("wg"),
"phone": h.get("class") == "phone",
})
return {"self": my, "location": status.get("location"),
"route": status.get("lan_route_via"), "agent_ts": status.get("ts"),
"hosts": hosts}
def probe(self, ip: str) -> dict:
ping = shutil.which("ping") or "/sbin/ping"
flag = "-t" if slr.PLATFORM == "darwin" else "-W"
rc, out, _ = slr._run([ping, "-c", "1", flag, "2", ip], 5)
if rc != 0:
return {"ok": False}
m = re.search(r"time=([\d.]+)", out)
return {"ok": True, "ms": round(float(m.group(1)), 1) if m else 0}
def copy(self, text: str) -> bool:
p = subprocess.run(["pbcopy"], input=text.encode())
return p.returncode == 0
def ssh_terminal(self, host: str) -> bool:
script = f'tell application "Terminal" to do script "ssh {host}"'
subprocess.Popen(["osascript", "-e", 'tell application "Terminal" to activate',
"-e", script])
return True
def doctor(self, host: str) -> str:
p = subprocess.run([os.path.join(ROOT, "bin", "net"), "doctor", host],
capture_output=True, text=True, timeout=60)
return p.stdout or p.stderr or "(no output)"
def main() -> int:
import webview
api = Api()
html = os.path.join(ROOT, "gui", "index.html")
webview.create_window("Mesh control", html, js_api=api,
width=620, height=560, resizable=True)
webview.start()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,56 @@
#!/bin/bash
# install-agent.sh — install the ONE net-tools agent on this node.
# Same script everywhere; picks the service manager by platform:
# darwin -> launchd (/Library/LaunchDaemons/com.lilith.smart-lan-router.plist)
# linux -> systemd (/etc/systemd/system/smart-lan-router.service, rendered
# from smart-lan-router.service.tmpl with this repo's path)
# Idempotent — safe to re-run after a repo move or to restart on new code.
set -euo pipefail
if [ "$EUID" -ne 0 ]; then
echo "Usage: sudo $0" >&2
exit 1
fi
AGENT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(dirname "$AGENT_DIR")"
LABEL="com.lilith.smart-lan-router"
echo "==> repo: $REPO_DIR platform: $(uname -s)"
case "$(uname -s)" in
Darwin)
PLIST_SRC="$AGENT_DIR/$LABEL.plist"
PLIST_DST="/Library/LaunchDaemons/$LABEL.plist"
# Keep the shipped plist's ProgramArguments honest about where the repo is.
/usr/bin/sed "s#<string>/Users/[^<]*/smart-lan-router\.py</string>#<string>$AGENT_DIR/smart-lan-router.py</string>#" \
"$PLIST_SRC" > /tmp/$LABEL.plist.$$
install -o root -g wheel -m 644 "/tmp/$LABEL.plist.$$" "$PLIST_DST"
rm -f "/tmp/$LABEL.plist.$$"
launchctl bootout system "$PLIST_DST" 2>/dev/null || true
launchctl bootstrap system "$PLIST_DST"
launchctl enable "system/$LABEL"
echo "==> launchd: $(launchctl print "system/$LABEL" 2>/dev/null | grep -m1 'state =' || echo '?')"
;;
Linux)
UNIT_DST="/etc/systemd/system/smart-lan-router.service"
sed "s#@REPO@#$REPO_DIR#g" "$AGENT_DIR/smart-lan-router.service.tmpl" > "$UNIT_DST"
chmod 644 "$UNIT_DST"
systemctl daemon-reload
systemctl enable --now smart-lan-router.service
systemctl restart smart-lan-router.service
echo "==> systemd: $(systemctl is-active smart-lan-router.service)"
;;
*)
echo "unsupported platform $(uname -s) — see README platform matrix" >&2
exit 1
;;
esac
echo "==> log tail:"
sleep 3
case "$(uname -s)" in
Darwin) tail -5 /var/log/lilith-smart-lan-router.log 2>/dev/null || true ;;
Linux) journalctl -u smart-lan-router.service -n 5 --no-pager 2>/dev/null || true ;;
esac
echo "==> done. inspect any time: python3 $AGENT_DIR/smart-lan-router.py --status"

View file

@ -1,62 +0,0 @@
#!/bin/bash
set -e
if [ "$EUID" -ne 0 ]; then
echo "Usage: sudo $0" >&2
echo "This script must be run as root." >&2
exit 1
fi
# Source of truth: the plist shipped in this repo (alongside this script).
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
PLIST_SRC="$REPO_DIR/com.lilith.smart-lan-router.plist"
PLIST_DST="/Library/LaunchDaemons/com.lilith.smart-lan-router.plist"
OLD_PLIST="/Library/LaunchDaemons/com.lilith.direct-lan-routes.plist"
STATE_FILE="/var/db/lilith-smart-lan-router.json"
LOG_FILE="/var/log/lilith-smart-lan-router.log"
echo "==> Removing earlier broken direct-lan-routes hack"
launchctl bootout system "$OLD_PLIST" 2>/dev/null || true
rm -f "$OLD_PLIST"
rm -f /Users/natalie/com.lilith.direct-lan-routes.plist \
/Users/natalie/bin/ensure-lan-routes.sh \
/Users/natalie/fix-lan.sh \
/Users/natalie/unfix-lan.sh
echo "==> Retiring the loose pre-repo copies (now canonical in this repo)"
rm -f /Users/natalie/bin/smart-lan-router.py \
/Users/natalie/com.lilith.smart-lan-router.plist \
/Users/natalie/install-smart-router.sh
echo "==> Cleaning up leftover manual host routes"
route -n delete -host 10.0.0.11 2>/dev/null || true
route -n delete -host 10.0.0.116 2>/dev/null || true
echo "==> Installing new smart-lan-router LaunchDaemon"
install -o root -g wheel -m 644 "$PLIST_SRC" /Library/LaunchDaemons/
launchctl bootout system "$PLIST_DST" 2>/dev/null || true
launchctl bootstrap system "$PLIST_DST"
launchctl enable system/com.lilith.smart-lan-router
echo "==> Preparing /var/db and /var/log artifacts"
mkdir -p /var/db
touch "$STATE_FILE"
chown root:wheel "$STATE_FILE"
chmod 644 "$STATE_FILE"
touch "$LOG_FILE"
chown root:wheel "$LOG_FILE"
echo "==> Verifying daemon state"
launchctl print system/com.lilith.smart-lan-router | grep -E 'state|last exit code' || true
echo "==> Waiting 35s for first probe cycle..."
sleep 35
echo "==> Recent log output:"
tail -10 "$LOG_FILE" || true
echo "==> Route state (want: en0, not utun*):"
route -n get 10.0.0.11 2>&1 | awk '/interface:/{print " pear (black) via:", $2}'
route -n get 10.0.0.116 2>&1 | awk '/interface:/{print " apricot via:", $2}'
echo "==> Done."

View file

@ -271,9 +271,10 @@ def identify_self(data: dict) -> dict | None:
def render_user(repo_root: str, self_host: dict | None) -> str | None:
"""Whose ~/.ssh/config to maintain: the host's declared ssh_user, else the
repo owner, else (darwin) the console user. Never root."""
if self_host and self_host.get("ssh_user") and self_host["ssh_user"] != "root":
"""Whose ~/.ssh/config to maintain: the host's DECLARED ssh_user (even root —
on a root-operated VPS that is the real login user), else the repo owner,
else (darwin) the console user those fallbacks never guess root."""
if self_host and self_host.get("ssh_user"):
return self_host["ssh_user"]
try:
owner = pwd.getpwuid(os.stat(repo_root).st_uid).pw_name
@ -289,6 +290,34 @@ def render_user(repo_root: str, self_host: dict | None) -> str | None:
return None
# ---------------------------------------------------------------------------
# Hostname convergence — the FLEET renames hosts, never a human with hostnamectl
# ---------------------------------------------------------------------------
def enforce_hostname(name: str) -> None:
"""Converge this node's OS hostname to its canonical mesh-hosts.json name.
Gated by fleet.enforce_hostname in the source of truth. Idempotent; kills
relic FQDNs (plum.voyager.nasty.sh, 0.vps.1984.uvlava.com) as a side effect."""
current = socket.gethostname().split(".")[0].lower()
if current == name:
return
if PLATFORM == "darwin":
ok = True
for key in ("HostName", "LocalHostName", "ComputerName"):
rc, _, err = _run(["/usr/sbin/scutil", "--set", key, name])
if rc != 0:
logger.error("scutil --set %s failed: %s", key, err.strip())
ok = False
if ok:
logger.info("hostname converged: %s%s (scutil ×3)", current, name)
else:
rc, _, err = _run([_bin("hostnamectl", "/usr/bin/hostnamectl"), "set-hostname", name])
if rc == 0:
logger.info("hostname converged: %s%s (hostnamectl)", current, name)
else:
logger.error("hostnamectl set-hostname %s failed: %s", name, err.strip())
# ---------------------------------------------------------------------------
# Pull — propagate declared truth + this agent's own code
# ---------------------------------------------------------------------------
@ -355,9 +384,17 @@ def discover(cfg: Config, hosts: list[tuple[str, str]], ctx: dict) -> dict[str,
def render_views(repo_root: str, user: str | None) -> None:
_run([os.path.join(repo_root, "bin", "mesh-hosts-render"), "--install"])
if user:
_run(["/usr/bin/sudo", "-u", user, "-H",
os.path.join(repo_root, "bin", "host-apply"), "--ssh-apply"])
if not user:
return
ha = [os.path.join(repo_root, "bin", "host-apply"), "--ssh-apply"]
try:
current = pwd.getpwuid(os.geteuid()).pw_name
except KeyError:
current = None
if user == current:
_run(ha) # already that user (e.g. root on a root-operated VPS — no sudo there)
else:
_run(["/usr/bin/sudo", "-u", user, "-H", *ha])
def sync_names(repo_root: str, discovered: dict[str, str], user: str | None) -> bool:
@ -382,6 +419,45 @@ def sync_names(repo_root: str, discovered: dict[str, str], user: str | None) ->
return True
def repo_head(repo_root: str) -> str | None:
git = _bin("git", "/usr/bin/git")
rc, out, _ = _run([git, "-c", f"safe.directory={repo_root}", "-C", repo_root,
"rev-parse", "--short", "HEAD"], 10)
return out.strip() if rc == 0 and out.strip() else None
def write_status(cfg: Config, ctx: dict) -> None:
"""Snapshot this agent's view to data/agent-status.json each cycle — the
feed for the tray, `fleet-status`, and any future web dashboard."""
state_path = os.path.join(ctx["repo_root"], "data", "lan-state.json")
discovered = {}
if os.path.isfile(state_path):
try:
discovered = load_json(state_path)
except (json.JSONDecodeError, OSError):
pass
status = {
"ts": int(time.time()),
"self": ctx["self_name"],
"hostname": socket.gethostname(),
"platform": PLATFORM,
"roles": sorted(ctx["roles"]),
"location": ctx.get("location"),
"lan_route_via": subnet_route_iface(cfg.lan_cidr),
"head": repo_head(ctx["repo_root"]),
"discovered": discovered,
}
path = os.path.join(ctx["repo_root"], "data", "agent-status.json")
try:
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(status, fh, indent=2, sort_keys=True)
os.replace(tmp, path)
os.chmod(path, 0o644)
except OSError as e:
logger.warning("status write failed: %s", e)
# ---------------------------------------------------------------------------
# Reconcile — one cycle of the unified agent
# ---------------------------------------------------------------------------
@ -399,7 +475,12 @@ def reconcile(cfg: Config, data: dict, ctx: dict) -> bool:
render_views(ctx["repo_root"], ctx["render_user"])
return True
# 1b. hostname convergence (fleet-gated, idempotent)
if "hostname" in ctx["roles"] and ctx["self_name"]:
enforce_hostname(ctx["self_name"])
home, gw, gwif = is_home(cfg)
ctx["location"] = "HOME" if home else "AWAY"
roles = ctx["roles"]
# 2. route switch (laptop role only)
@ -417,10 +498,17 @@ def reconcile(cfg: Config, data: dict, ctx: dict) -> bool:
logger.warning("away and no wg interface up — leaving %s untouched", cfg.lan_cidr)
ctx["last_state"] = state
# 3. discover + render (any node that can see the home LAN right now)
lan_visible = home or (ctx["self_lan"] is not None and ctx["self_lan"] in local_ipv4s())
if "discover" in roles and lan_visible and ctx["lan_hosts"]:
found = discover(cfg, ctx["lan_hosts"], ctx)
# 3. discover + render (any node that can see the home LAN right now).
# A node also discovers ITSELF from its own interfaces — ARP only sees
# peers, and without this a host's own vhosts render at the stale declared
# seed (bit apricot: its /etc/hosts said .116 while it sat on .118).
my_lan_ip = next((ip for ip in local_ipv4s()
if ipaddress.ip_address(ip) in ipaddress.ip_network(cfg.lan_cidr, strict=False)), None)
lan_visible = home or my_lan_ip is not None
if "discover" in roles and lan_visible:
found = discover(cfg, ctx["lan_hosts"], ctx) if ctx["lan_hosts"] else {}
if ctx["self_name"] and my_lan_ip:
found[ctx["self_name"]] = my_lan_ip
if found:
sync_names(ctx["repo_root"], found, ctx["render_user"])
@ -442,6 +530,8 @@ def build_ctx(data_file: str) -> dict:
roles.add("discover")
if me.get("class") == "laptop" and PLATFORM == "darwin":
roles.add("route")
if data.get("fleet", {}).get("enforce_hostname"):
roles.add("hostname")
ru = render_user(repo_root, me)
return {
"repo_root": repo_root, "self_name": name,
@ -510,9 +600,14 @@ def main(argv: list[str] | None = None) -> int:
logger.exception("config reload failed — using last-good")
try:
if reconcile(last_cfg, last_data, ctx):
write_status(last_cfg, ctx)
return 0 # HEAD moved: exit; launchd/systemd restart with new code
except Exception:
logger.exception("reconcile failed")
try:
write_status(last_cfg, ctx)
except Exception:
logger.exception("status write failed")
if args.once or stop[0]:
return 0
slept = 0

9
tray/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
# Python
.venv/
__pycache__/
*.pyc
# Secrets — WireGuard keys and tunnel configs must never be committed.
# They stay in ~/.wireguard/, never in this repo.
*.key
wg1.conf

55
tray/README.md Normal file
View file

@ -0,0 +1,55 @@
# wireguard-vpn-tray
macOS menu-bar app showing live WireGuard mesh (`wg1`) status as a colored
hexagon icon plus a **Status** menu item.
- 🟢 **green** — tunnel up and the mesh hub (`10.9.0.1`) is reachable
- 🟡 **yellow** — tunnel up but the hub is not yet reachable (connecting)
- 🔴 **red** — no tunnel interface present
Built on the in-house `lilith_tray` framework (vendored here under
`lilith_tray/`) via its macOS `rumps` backend.
## Interface detection
macOS assigns the WireGuard `utun` device **dynamically** — the number changes
between boots. The app therefore identifies the tunnel by the address it
carries (the `utun` with a `10.9.0.x` `inet` address), never by a hardcoded
name. The tray icon and the **Status** label are both derived from a single
status computation in `poll_status()`, so they can never disagree.
## Layout
| Path | Purpose |
|------|---------|
| `vpn_tray.py` | the tray app |
| `vpn-tray` | launcher — activates the venv and runs the app |
| `lilith_tray/` | vendored tray framework |
| `icons/` | colored status icons |
| `generate_icons.py` | regenerates the icon set |
| `vpn-toggle.applescript` | standalone connect/disconnect applet |
| `com.wireguard.vpn-tray.plist` | launchd agent for the tray |
| `com.natalie.wg-quick-wg1.plist` | launchd agent bringing `wg1` up at boot |
| `requirements.txt` | Python dependencies |
The bundled launchd plists carry absolute paths for the current install
(`~/.wireguard/`); adjust them if the app is installed elsewhere.
## Setup
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```
Install the launchd agent:
```sh
cp com.wireguard.vpn-tray.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.wireguard.vpn-tray.plist
```
## Secrets
WireGuard keys (`*.key`) and the tunnel config (`wg1.conf`) are intentionally
**not** in this repo — see `.gitignore`. They live in `~/.wireguard/`.

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.natalie.wg-quick-wg1</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>/opt/homebrew/bin/wg-quick down /Users/natalie/.wireguard/wg1.conf 2>/dev/null; exec /opt/homebrew/bin/wg-quick up /Users/natalie/.wireguard/wg1.conf</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>/var/log/wg-quick-wg1.log</string>
<key>StandardErrorPath</key>
<string>/var/log/wg-quick-wg1.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.wireguard.vpn-tray</string>
<key>ProgramArguments</key>
<array>
<string>/Users/natalie/Code/@projects/@tools/net-tools/tray/vpn-tray</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/vpn-tray.log</string>
<key>StandardErrorPath</key>
<string>/tmp/vpn-tray.err</string>
</dict>
</plist>

34
tray/generate_icons.py Normal file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Generate VPN shield icons in different colors."""
import cairosvg
from pathlib import Path
COLORS = {
"green": "#00FF88",
"red": "#FF3C3C",
"yellow": "#FFC800",
}
TEMPLATE = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="{color}" d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2l7 3.11V11c0 4.83-3.23 9.13-7 10.73-3.77-1.6-7-5.9-7-10.73V6.11L12 3z"/>
<path fill="{color}" d="M12 5.5L7 7.7V11c0 3.5 2.33 6.65 5 7.93 2.67-1.28 5-4.43 5-7.93V7.7l-5-2.2z" opacity="0.5"/>
</svg>'''
icons_dir = Path(__file__).parent / "icons"
for name, color in COLORS.items():
svg_content = TEMPLATE.format(color=color)
# Generate 18pt and 18pt@2x versions
for size, suffix in [(18, ""), (36, "@2x")]:
output_path = icons_dir / f"vpn-{name}-18{suffix}.png"
cairosvg.svg2png(
bytestring=svg_content.encode(),
write_to=str(output_path),
output_width=size,
output_height=size,
)
print(f"Generated: {output_path.name}")
print("Done!")

BIN
tray/icons/green-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

BIN
tray/icons/green-16@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

BIN
tray/icons/green-18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 B

BIN
tray/icons/green-18@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 B

BIN
tray/icons/green-24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

BIN
tray/icons/green-24@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

BIN
tray/icons/green-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

BIN
tray/icons/green-32@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
tray/icons/green-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

BIN
tray/icons/green-48@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
tray/icons/red-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

BIN
tray/icons/red-16@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

BIN
tray/icons/red-18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

BIN
tray/icons/red-18@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

BIN
tray/icons/red-24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

BIN
tray/icons/red-24@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 B

BIN
tray/icons/red-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

BIN
tray/icons/red-32@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
tray/icons/red-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 B

BIN
tray/icons/red-48@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
tray/icons/vpn-green-18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
tray/icons/vpn-red-18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path fill="#PLACEHOLDER" d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 3.9l5 2.22V11c0 3.88-2.55 7.36-5 8.9-2.45-1.54-5-5.02-5-8.9V7.12l5-2.22z"/>
<path fill="#PLACEHOLDER" d="M12 6.5L8 8.3V11c0 2.76 1.7 5.16 4 6.32 2.3-1.16 4-3.56 4-6.32V8.3l-4-1.8z" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
tray/icons/yellow-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

BIN
tray/icons/yellow-16@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

BIN
tray/icons/yellow-18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

BIN
tray/icons/yellow-18@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

BIN
tray/icons/yellow-24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

BIN
tray/icons/yellow-24@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

BIN
tray/icons/yellow-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

BIN
tray/icons/yellow-32@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
tray/icons/yellow-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

BIN
tray/icons/yellow-48@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

35
tray/install-tray.sh Executable file
View file

@ -0,0 +1,35 @@
#!/bin/bash
# install-tray.sh — install the net-tools fleet tray (darwin, user scope).
# Run as the console USER (no sudo): installs a launchd gui agent that runs
# tray/vpn-tray from this repo. Idempotent.
set -euo pipefail
if [ "$(uname -s)" != "Darwin" ]; then
echo "tray is darwin-only (menu bar app)" >&2
exit 1
fi
if [ "$EUID" -eq 0 ]; then
echo "run as the console user, not root (gui launchd domain)" >&2
exit 1
fi
TRAY_DIR="$(cd "$(dirname "$0")" && pwd)"
LABEL="com.wireguard.vpn-tray"
DST="$HOME/Library/LaunchAgents/$LABEL.plist"
if [ ! -x "$TRAY_DIR/.venv/bin/python" ]; then
echo "==> bootstrapping venv"
python3 -m venv "$TRAY_DIR/.venv"
"$TRAY_DIR/.venv/bin/pip" install -q -r "$TRAY_DIR/requirements.txt"
fi
# Keep the shipped plist honest about where this repo actually is.
/usr/bin/sed "s#<string>[^<]*/tray/vpn-tray</string>#<string>$TRAY_DIR/vpn-tray</string>#; s#<string>/Users/[^<]*/.wireguard/vpn-tray</string>#<string>$TRAY_DIR/vpn-tray</string>#" \
"$TRAY_DIR/$LABEL.plist" > "$DST"
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$DST"
launchctl kickstart "gui/$(id -u)/$LABEL"
sleep 2
echo "==> $(launchctl list | grep "$LABEL" || echo 'NOT RUNNING')"
echo "==> running from: $(ps -Ao command | grep '[v]pn_tray.py' | head -1)"

View file

@ -0,0 +1,16 @@
"""lilith-tray: Cross-platform system tray abstraction."""
from lilith_tray.base import TrayApp, run_all
from lilith_tray.board import BoardButton, BoardSection, show_board
from lilith_tray.types import TrayConfig, TrayIcon, TrayMenuItem
__all__ = [
"BoardButton",
"BoardSection",
"TrayApp",
"TrayConfig",
"TrayIcon",
"TrayMenuItem",
"run_all",
"show_board",
]

View file

@ -0,0 +1 @@
"""Platform-specific tray backends."""

View file

@ -0,0 +1,153 @@
"""Linux backend using AyatanaAppIndicator3 + Gtk."""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING
import gi
gi.require_version("Gtk", "3.0")
try:
gi.require_version("AyatanaAppIndicator3", "0.1")
from gi.repository import AyatanaAppIndicator3
except ValueError:
try:
gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3 as AyatanaAppIndicator3
except ValueError:
raise RuntimeError(
"No AppIndicator library found. "
"Install libayatana-appindicator3 (Ubuntu/Debian) "
"or libappindicator-gtk3 (Fedora: dnf install libappindicator-gtk3)."
) from None
from gi.repository import GLib, Gtk
from lilith_tray.types import TrayIcon, TrayMenuItem
if TYPE_CHECKING:
from lilith_tray.base import TrayApp
class AyatanaBackend:
"""Linux tray backend using AyatanaAppIndicator3 + Gtk."""
def __init__(self, app: TrayApp) -> None:
self._app = app
config = app._config
initial_icon = config.icons.get(config.initial_icon)
icon_dir = str(initial_icon.path.parent) if initial_icon else ""
icon_stem = initial_icon.path.stem if initial_icon else "indicator"
self._indicator = AyatanaAppIndicator3.Indicator.new(
config.name,
icon_stem,
AyatanaAppIndicator3.IndicatorCategory.APPLICATION_STATUS,
)
if icon_dir:
self._indicator.set_icon_theme_path(icon_dir)
self._indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.ACTIVE)
self._indicator.set_title(config.name)
if config.label is not None:
self._indicator.set_label(config.label, "")
self._status_items: dict[str, Gtk.MenuItem] = {}
self._poll_interval = config.poll_interval
self._menu = self._build_menu(config.menu)
self._indicator.set_menu(self._menu)
def _build_menu(self, items: list[TrayMenuItem]) -> Gtk.Menu:
menu = Gtk.Menu()
for item in items:
if item.is_separator:
menu.append(Gtk.SeparatorMenuItem())
elif item.is_quit:
mi = Gtk.MenuItem(label=item.title)
mi.connect("activate", lambda *_: Gtk.main_quit())
menu.append(mi)
elif item.callback:
cb = item.callback
mi = Gtk.MenuItem(label=item.title)
mi.connect("activate", lambda *_, c=cb: c())
menu.append(mi)
else:
mi = Gtk.MenuItem(label=item.title)
mi.set_sensitive(False)
menu.append(mi)
menu.show_all()
return menu
def _do_poll(self) -> None:
"""Run poll on the Gtk main thread via GLib.idle_add."""
icon_key = self._app.poll_status()
labels = self._app.get_status_labels()
if icon_key != self._app._prev_icon:
if icon_key in self._app._icons:
icon = self._app._icons[icon_key]
self._indicator.set_icon_theme_path(str(icon.path.parent))
self._indicator.set_icon_full(icon.path.stem, self._app._config.name)
self._app._prev_icon = icon_key
self._app._current_icon = icon_key
self._app._status_labels = labels
self._update_status_labels(labels)
def _periodic_update(self) -> None:
"""Background thread: schedule polls on the Gtk main loop."""
while True:
time.sleep(self._poll_interval)
GLib.idle_add(self._do_poll)
def start(self) -> None:
"""Initial poll + start background polling thread. No main loop."""
self._do_poll()
threading.Thread(target=self._periodic_update, daemon=True).start()
def run_loop(self) -> None:
"""Enter the Gtk main loop (blocks until Gtk.main_quit())."""
Gtk.main()
def set_icon(self, icon: TrayIcon) -> None:
self._indicator.set_icon_theme_path(str(icon.path.parent))
self._indicator.set_icon_full(icon.path.stem, self._app._config.name)
def set_status_labels(self, labels: dict[str, str]) -> None:
GLib.idle_add(self._update_status_labels, labels)
def _update_status_labels(self, labels: dict[str, str]) -> None:
for key, value in labels.items():
title = f"{key}: {value}"
if key in self._status_items:
self._status_items[key].set_label(title)
else:
item = Gtk.MenuItem(label=title)
item.set_sensitive(False)
self._menu.prepend(item)
item.show()
self._status_items[key] = item
# Remove stale
stale = set(self._status_items.keys()) - set(labels.keys())
for key in stale:
item = self._status_items.pop(key)
self._menu.remove(item)
def notify(self, title: str, message: str) -> None:
try:
gi.require_version("Notify", "0.7")
from gi.repository import Notify
if not Notify.is_initted():
Notify.init(self._app._config.name)
n = Notify.Notification.new(title, message, None)
n.show()
except (ValueError, ImportError):
pass

View file

@ -0,0 +1,54 @@
"""Null backend — no visible tray icon, keeps process alive for polling."""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING
from lilith_tray.types import TrayIcon
if TYPE_CHECKING:
from lilith_tray.base import TrayApp
class NullBackend:
"""No-op tray backend used when no platform indicator library is available.
The tray icon is invisible but the app's poll_status / get_status_labels
callbacks still run on the configured poll_interval so application logic
(health checks, subprocess management, etc.) keeps working.
"""
def __init__(self, app: TrayApp) -> None:
self._app = app
self._poll_interval = app._config.poll_interval
self._stop = threading.Event()
def _do_poll(self) -> None:
icon_key = self._app.poll_status()
labels = self._app.get_status_labels()
self._app._current_icon = icon_key
self._app._prev_icon = icon_key
self._app._status_labels = labels
def _periodic_update(self) -> None:
while not self._stop.is_set():
time.sleep(self._poll_interval)
self._do_poll()
def start(self) -> None:
self._do_poll()
threading.Thread(target=self._periodic_update, daemon=True).start()
def run_loop(self) -> None:
self._stop.wait()
def set_icon(self, icon: TrayIcon) -> None:
pass
def set_status_labels(self, labels: dict[str, str]) -> None:
self._app._status_labels = labels
def notify(self, title: str, message: str) -> None:
pass

View file

@ -0,0 +1,118 @@
"""macOS backend using rumps."""
from __future__ import annotations
from typing import TYPE_CHECKING
import rumps
from lilith_tray.types import TrayIcon, TrayMenuItem
if TYPE_CHECKING:
from lilith_tray.base import TrayApp, _Backend
class RumpsBackend:
"""macOS tray backend using rumps.App."""
def __init__(self, app: TrayApp) -> None:
self._app = app
config = app._config
initial_icon = config.icons.get(config.initial_icon)
self._rumps_app = rumps.App(
config.name,
icon=str(initial_icon.path) if initial_icon else None,
quit_button=None,
)
self._status_items: dict[str, rumps.MenuItem] = {}
self._ctrl_marker: str | None = None
self._menu_items: list[rumps.MenuItem | None] = []
self._build_menu(config.menu)
self._timer = rumps.Timer(self._on_poll, config.poll_interval)
self._timer.start()
# Initial poll
self._on_poll(None)
def _build_menu(self, items: list[TrayMenuItem]) -> None:
menu_entries: list[rumps.MenuItem | None] = []
first_action_title: str | None = None
for item in items:
if item.is_separator:
menu_entries.append(None)
elif item.is_quit:
mi = rumps.MenuItem(item.title, callback=lambda _: rumps.quit_application())
menu_entries.append(mi)
elif item.callback:
cb = item.callback
mi = rumps.MenuItem(item.title, callback=lambda _, c=cb: c())
menu_entries.append(mi)
if first_action_title is None:
first_action_title = item.title
else:
mi = rumps.MenuItem(item.title)
mi._menuitem.setEnabled_(False)
menu_entries.append(mi)
self._ctrl_marker = first_action_title
self._rumps_app.menu = menu_entries
def _on_poll(self, _timer: rumps.Timer | None) -> None:
icon_key = self._app.poll_status()
labels = self._app.get_status_labels()
# Icon change with transition detection
if icon_key != self._app._prev_icon:
if icon_key in self._app._icons:
self._rumps_app.icon = str(self._app._icons[icon_key].path)
self._app._prev_icon = icon_key
self._app._current_icon = icon_key
self._app._status_labels = labels
self._update_status_labels(labels)
def start(self) -> None:
"""No-op: setup and timer already happen in __init__."""
def run_loop(self) -> None:
"""Enter the rumps (NSApplication) main loop."""
self._rumps_app.run()
def set_icon(self, icon: TrayIcon) -> None:
self._rumps_app.icon = str(icon.path)
def set_status_labels(self, labels: dict[str, str]) -> None:
self._update_status_labels(labels)
def _update_status_labels(self, labels: dict[str, str]) -> None:
new_keys = set(labels.keys())
old_keys = set(self._status_items.keys())
# Remove stale
for key in old_keys - new_keys:
item = self._status_items.pop(key)
try:
del self._rumps_app.menu[item.title]
except KeyError:
pass
# Update or add
insert_before = self._ctrl_marker
for key, value in labels.items():
title = f"{key}: {value}"
if key in self._status_items:
self._status_items[key].title = title
else:
item = rumps.MenuItem(title)
item._menuitem.setEnabled_(False)
if insert_before:
self._rumps_app.menu.insert_before(insert_before, item)
self._status_items[key] = item
def notify(self, title: str, message: str) -> None:
rumps.notification(title, "", message, sound=False)

127
tray/lilith_tray/base.py Normal file
View file

@ -0,0 +1,127 @@
"""Abstract TrayApp base class with platform auto-detection."""
from __future__ import annotations
import sys
from abc import ABC, abstractmethod
from lilith_tray.types import TrayConfig, TrayIcon, TrayMenuItem
class TrayApp(ABC):
"""Cross-platform tray application base class.
Subclass and implement poll_status() and get_status_labels().
Call run() to start the tray with the appropriate backend.
"""
def __init__(self, config: TrayConfig) -> None:
self._config = config
self._icons = dict(config.icons)
self._current_icon: str = config.initial_icon
self._status_labels: dict[str, str] = {}
self._prev_icon: str | None = None
self._backend: _Backend | None = None
@abstractmethod
def poll_status(self) -> str:
"""Poll current status and return an icon key from self._icons.
Called periodically at config.poll_interval seconds.
"""
@abstractmethod
def get_status_labels(self) -> dict[str, str]:
"""Return label key-value pairs for display in the menu.
Called after each poll_status(). Values appear as disabled menu items.
"""
def set_icon(self, key: str) -> None:
"""Change the tray icon to the one registered under key."""
if key in self._icons and self._backend:
self._current_icon = key
self._backend.set_icon(self._icons[key])
def set_status_labels(self, labels: dict[str, str]) -> None:
"""Update the status label items in the menu."""
self._status_labels = labels
if self._backend:
self._backend.set_status_labels(labels)
def notify(self, title: str, message: str) -> None:
"""Show a desktop notification."""
if self._backend:
self._backend.notify(title, message)
def run(self) -> None:
"""Start the tray app using the appropriate platform backend."""
self._backend = _create_backend(self)
self._backend.start()
self._backend.run_loop()
class _Backend(ABC):
"""Internal backend interface."""
@abstractmethod
def start(self) -> None:
"""Set up indicator, do initial poll, start polling thread. No main loop."""
@abstractmethod
def run_loop(self) -> None:
"""Enter the platform main loop (blocks until quit)."""
@abstractmethod
def set_icon(self, icon: TrayIcon) -> None: ...
@abstractmethod
def set_status_labels(self, labels: dict[str, str]) -> None: ...
@abstractmethod
def notify(self, title: str, message: str) -> None: ...
def _create_backend(app: TrayApp) -> _Backend:
"""Auto-detect platform and instantiate the correct backend."""
if sys.platform == "darwin":
from lilith_tray.backends.rumps_backend import RumpsBackend
return RumpsBackend(app)
else:
try:
from lilith_tray.backends.ayatana_backend import AyatanaBackend
return AyatanaBackend(app)
except RuntimeError:
import warnings
warnings.warn(
"No AppIndicator library found — tray icon will be invisible. "
"Install libappindicator-gtk3 for a visible system tray.",
stacklevel=2,
)
from lilith_tray.backends.null_backend import NullBackend
return NullBackend(app)
def run_all(apps: list[TrayApp]) -> None:
"""Run multiple TrayApp instances sharing a single main loop.
All indicators are set up and their polling threads started before the
main loop is entered once. On Linux each app becomes an independent
AppIndicator3.Indicator; on macOS this raises NotImplementedError because
rumps wraps the NSApplication singleton.
"""
if sys.platform == "darwin":
raise NotImplementedError(
"run_all is not supported on macOS — rumps wraps a NSApplication singleton. "
"Run each TrayApp in its own process instead."
)
for app in apps:
app._backend = _create_backend(app)
app._backend.start()
apps[0]._backend.run_loop()

134
tray/lilith_tray/board.py Normal file
View file

@ -0,0 +1,134 @@
"""Reusable board window — a grid of labeled buttons, GTK3-based.
Used by tray apps for animation boards, sound boards, etc.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
try:
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk, Gtk
GTK_AVAILABLE = True
except (ImportError, ValueError):
GTK_AVAILABLE = False
@dataclass(frozen=True)
class BoardButton:
"""A single button on the board."""
label: str
button_id: str
@dataclass(frozen=True)
class BoardSection:
"""A labeled group of buttons."""
title: str
buttons: list[BoardButton] = field(default_factory=list)
columns: int = 3
_instances: dict[str, object] = {}
def show_board(
title: str,
sections: list[BoardSection],
on_click: Callable[[str], None],
) -> None:
"""Show a board window. Reuses existing window if one with the same title exists.
Args:
title: Window title (also used as singleton key).
sections: List of button sections to display.
on_click: Called with button_id when any button is clicked.
"""
if not GTK_AVAILABLE:
return
existing = _instances.get(title)
if existing is not None and not existing._destroyed:
existing.present()
return
window = _BoardWindow(title, sections, on_click)
_instances[title] = window
window.show_all()
class _BoardWindow(Gtk.Window if GTK_AVAILABLE else object):
"""GTK3 board window with categorized button grid."""
_destroyed: bool = False
def __init__(
self,
title: str,
sections: list[BoardSection],
on_click: Callable[[str], None],
) -> None:
if not GTK_AVAILABLE:
return
super().__init__(title=title)
self._title_key = title
self._on_click = on_click
self.set_default_size(340, -1)
self.set_keep_above(True)
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.set_resizable(False)
self.connect("delete-event", self._on_delete)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(12)
box.set_margin_bottom(12)
box.set_margin_start(12)
box.set_margin_end(12)
self.add(box)
for section in sections:
self._add_section(box, section)
def _add_section(self, parent: Gtk.Box, section: BoardSection) -> None:
label = Gtk.Label()
label.set_markup(f"<b>{section.title}</b>")
label.set_halign(Gtk.Align.START)
parent.pack_start(label, False, False, 0)
grid = Gtk.Grid()
grid.set_column_spacing(6)
grid.set_row_spacing(6)
grid.set_column_homogeneous(True)
parent.pack_start(grid, False, False, 0)
for i, btn_cfg in enumerate(section.buttons):
row = i // section.columns
col = i % section.columns
button = Gtk.Button(label=btn_cfg.label)
button.connect("clicked", self._make_handler(btn_cfg.button_id))
grid.attach(button, col, row, 1, 1)
def _make_handler(self, button_id: str) -> Callable:
def handler(_widget: object) -> None:
self._on_click(button_id)
return handler
def _on_delete(self, _widget: object, _event: object) -> bool:
self.hide()
return True # Prevent destruction
def destroy(self) -> None:
self._destroyed = True
_instances.pop(self._title_key, None)
if GTK_AVAILABLE:
super().destroy()

View file

@ -0,0 +1,36 @@
"""Load icon palettes from @lilith/tray-resources JSON files."""
from __future__ import annotations
import json
from pathlib import Path
from lilith_tray.types import TrayIcon
def load_palette(json_path: str | Path) -> dict[str, TrayIcon]:
"""Load a palette from a JSON file mapping icon keys to file paths.
Expected JSON format:
{
"green": "/path/to/green.png",
"red": "/path/to/red.png",
"yellow": "/path/to/yellow.png"
}
Relative paths in the JSON are resolved relative to the JSON file's directory.
"""
json_path = Path(json_path)
base_dir = json_path.parent
with open(json_path) as f:
data: dict[str, str] = json.load(f)
palette: dict[str, TrayIcon] = {}
for key, icon_path in data.items():
p = Path(icon_path)
if not p.is_absolute():
p = base_dir / p
palette[key] = TrayIcon(path=p)
return palette

56
tray/lilith_tray/types.py Normal file
View file

@ -0,0 +1,56 @@
"""Tray data types."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
@dataclass(frozen=True)
class TrayIcon:
"""A tray icon backed by a file path (PNG recommended)."""
path: Path
@classmethod
def from_file(cls, path: str | Path) -> TrayIcon:
"""Create a TrayIcon from a file path."""
return cls(path=Path(path))
@dataclass(frozen=True)
class TrayMenuItem:
"""A single menu item for the tray context menu."""
title: str
callback: Callable[[], None] | None = None
is_separator: bool = False
is_quit: bool = False
@classmethod
def action(cls, title: str, callback: Callable[[], None]) -> TrayMenuItem:
"""Create a clickable menu item."""
return cls(title=title, callback=callback)
@classmethod
def separator(cls) -> TrayMenuItem:
"""Create a separator line."""
return cls(title="---", is_separator=True)
@classmethod
def quit(cls, title: str = "Quit") -> TrayMenuItem:
"""Create a quit menu item."""
return cls(title=title, is_quit=True)
@dataclass
class TrayConfig:
"""Configuration for a TrayApp."""
name: str
icons: dict[str, TrayIcon] = field(default_factory=dict)
initial_icon: str = ""
menu: list[TrayMenuItem] = field(default_factory=list)
poll_interval: int = 10
label: str | None = None

12
tray/requirements.txt Normal file
View file

@ -0,0 +1,12 @@
cairocffi==1.7.1
CairoSVG==2.9.0
cffi==2.0.0
cssselect2==0.9.0
defusedxml==0.7.1
pillow==12.2.0
pycparser==3.0
pyobjc-core==12.1
pyobjc-framework-Cocoa==12.1
rumps==0.4.0
tinycss2==1.5.1
webencodings==0.5.1

View file

@ -0,0 +1,27 @@
-- VPN Toggle: detects WireGuard state on plum and flips it.
-- Up if no utun has 10.9.0.3; otherwise down. Notifies result.
set wgConf to "/Users/natalie/.wireguard/wg1.conf"
set wgQuick to "/opt/homebrew/bin/wg-quick"
set isUp to false
try
do shell script "/sbin/ifconfig | /usr/bin/grep -q 'inet 10.9.0.3'"
set isUp to true
end try
if isUp then
try
do shell script (quoted form of wgQuick) & " down " & (quoted form of wgConf) & " 2>&1" with administrator privileges
display notification "WireGuard wg1 is down." with title "VPN Toggle" subtitle "Disconnected"
on error errMsg
display notification errMsg with title "VPN Toggle" subtitle "Failed to disconnect"
end try
else
try
do shell script (quoted form of wgQuick) & " up " & (quoted form of wgConf) & " 2>&1" with administrator privileges
display notification "WireGuard wg1 is up." with title "VPN Toggle" subtitle "Connected"
on error errMsg
display notification errMsg with title "VPN Toggle" subtitle "Failed to connect"
end try
end if

5
tray/vpn-tray Executable file
View file

@ -0,0 +1,5 @@
#!/bin/bash
# WireGuard VPN Tray launcher
cd "$(dirname "$0")"
source .venv/bin/activate
exec python vpn_tray.py "$@"

192
tray/vpn_tray.py Executable file
View file

@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""WireGuard VPN + net-tools fleet system tray app.
Tunnel state drives the icon (as before); the menu additionally shows the
net-tools fleet view, read from the agent's per-cycle snapshot
(net-tools/data/agent-status.json) location HOME/AWAY, the LAN route, every
discovered host's current IP, and agent freshness.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import time
from pathlib import Path
# Add local lilith_tray to path
sys.path.insert(0, str(Path(__file__).parent))
from lilith_tray import TrayApp, TrayConfig, TrayIcon, TrayMenuItem
# The wg1 mesh lives on 10.9.0.0/24. macOS assigns the WireGuard utun number
# dynamically (utun4 on one boot, utun6 on another), so the tunnel interface is
# identified by the address it carries, never by a hardcoded name.
MESH_PREFIX = "10.9.0."
# Mesh hub (yuzu/quinn-vps) — answering pings proves the tunnel is actually
# carrying traffic, not merely configured.
MESH_HUB = "10.9.0.1"
# The net-tools agent's per-cycle snapshot (written by smart-lan-router.py).
AGENT_STATUS = Path(__file__).resolve().parent.parent / "data" / "agent-status.json"
AGENT_STALE_SEC = 90
class VPNTray(TrayApp):
"""WireGuard VPN tray application."""
WG_CONF = Path.home() / ".wireguard" / "wg1.conf"
def __init__(self) -> None:
icons_dir = Path(__file__).parent / "icons"
config = TrayConfig(
name="WireGuard VPN",
icons={
"connected": TrayIcon.from_file(icons_dir / "vpn-green-18@2x.png"),
"disconnected": TrayIcon.from_file(icons_dir / "vpn-red-18@2x.png"),
"connecting": TrayIcon.from_file(icons_dir / "vpn-yellow-18@2x.png"),
},
initial_icon="disconnected",
menu=[
TrayMenuItem.action("Connect", self._connect),
TrayMenuItem.action("Disconnect", self._disconnect),
TrayMenuItem.separator(),
TrayMenuItem.quit("Quit"),
],
poll_interval=5,
)
# Single source of truth: poll_status() refreshes these, get_status_labels()
# reads them. The backend always calls poll_status() first, so the icon and
# the "Status" label can never disagree.
self._state: str = "disconnected"
self._ip: str | None = None
super().__init__(config)
def _wg_interface(self) -> tuple[str, str] | None:
"""Return (interface, ip) of the live WireGuard mesh tunnel, or None.
The tunnel is whichever interface carries a 10.9.0.x address on an
``inet`` line the utun number itself is assigned dynamically by macOS
and must not be hardcoded.
"""
try:
result = subprocess.run(
["ifconfig"],
capture_output=True,
text=True,
timeout=5,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return None
if result.returncode != 0:
return None
current: str | None = None
for line in result.stdout.splitlines():
header = re.match(r"^(\w+):\s+flags=", line)
if header:
current = header.group(1)
continue
stripped = line.strip()
if current and stripped.startswith("inet ") and MESH_PREFIX in stripped:
ip = stripped.split()[1]
if ip.startswith(MESH_PREFIX):
return current, ip
return None
def _can_reach_vpn(self) -> bool:
"""Check if we can reach the VPN server."""
try:
result = subprocess.run(
["ping", "-c", "1", "-W", "2", MESH_HUB],
capture_output=True,
timeout=5,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def poll_status(self) -> str:
"""Refresh VPN state and return the matching icon key.
Both the tray icon and the menu labels derive from the state computed
here, so they always agree:
- no tunnel interface -> "disconnected" (red)
- tunnel up, hub unreachable -> "connecting" (yellow)
- tunnel up, hub reachable -> "connected" (green)
"""
interface = self._wg_interface()
if interface is None:
self._state = "disconnected"
self._ip = None
else:
_, self._ip = interface
self._state = "connected" if self._can_reach_vpn() else "connecting"
return self._state
def _agent_status(self) -> dict | None:
"""The net-tools agent's last snapshot, or None if absent/unparseable."""
try:
with open(AGENT_STATUS, encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError):
return None
def get_status_labels(self) -> dict[str, str]:
"""Menu labels: tunnel state (from the last poll) + the fleet view
(from the net-tools agent snapshot)."""
if self._state == "connected":
labels = {"Status": "Connected"}
if self._ip:
labels["IP"] = self._ip
elif self._state == "connecting":
labels = {"Status": "Connecting..."}
else:
labels = {"Status": "Disconnected"}
agent = self._agent_status()
if agent is None:
labels["Agent"] = "no status"
return labels
age = int(time.time()) - int(agent.get("ts", 0))
labels["Agent"] = f"stale {age}s" if age > AGENT_STALE_SEC else f"ok ({age}s ago)"
if agent.get("location"):
via = agent.get("lan_route_via") or "?"
labels["Mode"] = f"{agent['location']} via {via}"
for name, ip in sorted((agent.get("discovered") or {}).items()):
labels[name] = ip
if agent.get("head"):
labels["Repo"] = agent["head"]
return labels
def _connect(self) -> None:
"""Connect to VPN."""
if self._wg_interface() is not None:
self.notify("VPN", "Already connected")
return
self.set_icon("connecting")
# Use osascript for GUI password prompt
script = f'''do shell script "wg-quick up {self.WG_CONF}" with administrator privileges'''
subprocess.run(["osascript", "-e", script], capture_output=True)
def _disconnect(self) -> None:
"""Disconnect from VPN."""
if self._wg_interface() is None:
self.notify("VPN", "Already disconnected")
return
# Use osascript for GUI password prompt. wg-quick down takes the config
# path and resolves the real (dynamic) utun name itself.
script = f'''do shell script "wg-quick down {self.WG_CONF}" with administrator privileges'''
subprocess.run(["osascript", "-e", script], capture_output=True)
def main() -> None:
app = VPNTray()
app.run()
if __name__ == "__main__":
main()