feat(@tools/net-tools): ✨ add tray icon system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
82
README.md
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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())
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
152
docs/topology.md
|
|
@ -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
|
|
@ -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
|
|
@ -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())
|
||||
56
smart-lan-router/install-agent.sh
Executable 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"
|
||||
|
|
@ -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."
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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/`.
|
||||
27
tray/com.natalie.wg-quick-wg1.plist
Normal 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>
|
||||
20
tray/com.wireguard.vpn-tray.plist
Normal 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
|
|
@ -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
|
After Width: | Height: | Size: 289 B |
BIN
tray/icons/green-16@2x.png
Normal file
|
After Width: | Height: | Size: 562 B |
BIN
tray/icons/green-18.png
Normal file
|
After Width: | Height: | Size: 323 B |
BIN
tray/icons/green-18@2x.png
Normal file
|
After Width: | Height: | Size: 657 B |
BIN
tray/icons/green-24.png
Normal file
|
After Width: | Height: | Size: 453 B |
BIN
tray/icons/green-24@2x.png
Normal file
|
After Width: | Height: | Size: 848 B |
BIN
tray/icons/green-32.png
Normal file
|
After Width: | Height: | Size: 562 B |
BIN
tray/icons/green-32@2x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
tray/icons/green-48.png
Normal file
|
After Width: | Height: | Size: 848 B |
BIN
tray/icons/green-48@2x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
tray/icons/red-16.png
Normal file
|
After Width: | Height: | Size: 297 B |
BIN
tray/icons/red-16@2x.png
Normal file
|
After Width: | Height: | Size: 593 B |
BIN
tray/icons/red-18.png
Normal file
|
After Width: | Height: | Size: 350 B |
BIN
tray/icons/red-18@2x.png
Normal file
|
After Width: | Height: | Size: 716 B |
BIN
tray/icons/red-24.png
Normal file
|
After Width: | Height: | Size: 491 B |
BIN
tray/icons/red-24@2x.png
Normal file
|
After Width: | Height: | Size: 916 B |
BIN
tray/icons/red-32.png
Normal file
|
After Width: | Height: | Size: 593 B |
BIN
tray/icons/red-32@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
tray/icons/red-48.png
Normal file
|
After Width: | Height: | Size: 916 B |
BIN
tray/icons/red-48@2x.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
tray/icons/vpn-green-18.png
Normal file
|
After Width: | Height: | Size: 523 B |
BIN
tray/icons/vpn-green-18@2x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
tray/icons/vpn-red-18.png
Normal file
|
After Width: | Height: | Size: 581 B |
BIN
tray/icons/vpn-red-18@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
4
tray/icons/vpn-shield.svg
Normal 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 |
BIN
tray/icons/vpn-yellow-18.png
Normal file
|
After Width: | Height: | Size: 523 B |
BIN
tray/icons/vpn-yellow-18@2x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
tray/icons/yellow-16.png
Normal file
|
After Width: | Height: | Size: 290 B |
BIN
tray/icons/yellow-16@2x.png
Normal file
|
After Width: | Height: | Size: 563 B |
BIN
tray/icons/yellow-18.png
Normal file
|
After Width: | Height: | Size: 332 B |
BIN
tray/icons/yellow-18@2x.png
Normal file
|
After Width: | Height: | Size: 665 B |
BIN
tray/icons/yellow-24.png
Normal file
|
After Width: | Height: | Size: 476 B |
BIN
tray/icons/yellow-24@2x.png
Normal file
|
After Width: | Height: | Size: 865 B |
BIN
tray/icons/yellow-32.png
Normal file
|
After Width: | Height: | Size: 563 B |
BIN
tray/icons/yellow-32@2x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
tray/icons/yellow-48.png
Normal file
|
After Width: | Height: | Size: 865 B |
BIN
tray/icons/yellow-48@2x.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
35
tray/install-tray.sh
Executable 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)"
|
||||
16
tray/lilith_tray/__init__.py
Normal 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",
|
||||
]
|
||||
1
tray/lilith_tray/backends/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Platform-specific tray backends."""
|
||||
153
tray/lilith_tray/backends/ayatana_backend.py
Normal 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
|
||||
54
tray/lilith_tray/backends/null_backend.py
Normal 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
|
||||
118
tray/lilith_tray/backends/rumps_backend.py
Normal 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
|
|
@ -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
|
|
@ -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()
|
||||
36
tray/lilith_tray/palette.py
Normal 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
|
|
@ -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
|
|
@ -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
|
||||
27
tray/vpn-toggle.applescript
Normal 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
|
|
@ -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
|
|
@ -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()
|
||||