From 68c848dc56ad076a0c46671be1b8156a1422d08e Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 10 Jun 2026 02:20:23 -0700 Subject: [PATCH] =?UTF-8?q?feat(@tools/net-tools):=20=E2=9C=A8=20add=20tra?= =?UTF-8?q?y=20icon=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .gitignore | 2 + README.md | 82 ++++-- bin/fleet-status | 67 +++++ bin/host-apply | 5 +- bin/mesh-hosts-render | 48 +++- bin/net | 247 +++++++++++++++++++ data/mesh-hosts.json | 21 +- docs/topology.md | 152 ++++++------ gui/index.html | 126 ++++++++++ gui/mesh-gui.py | 105 ++++++++ smart-lan-router/install-agent.sh | 56 +++++ smart-lan-router/install-smart-router.sh | 62 ----- smart-lan-router/smart-lan-router.py | 115 ++++++++- tray/.gitignore | 9 + tray/README.md | 55 +++++ tray/com.natalie.wg-quick-wg1.plist | 27 ++ tray/com.wireguard.vpn-tray.plist | 20 ++ tray/generate_icons.py | 34 +++ tray/icons/green-16.png | Bin 0 -> 289 bytes tray/icons/green-16@2x.png | Bin 0 -> 562 bytes tray/icons/green-18.png | Bin 0 -> 323 bytes tray/icons/green-18@2x.png | Bin 0 -> 657 bytes tray/icons/green-24.png | Bin 0 -> 453 bytes tray/icons/green-24@2x.png | Bin 0 -> 848 bytes tray/icons/green-32.png | Bin 0 -> 562 bytes tray/icons/green-32@2x.png | Bin 0 -> 1091 bytes tray/icons/green-48.png | Bin 0 -> 848 bytes tray/icons/green-48@2x.png | Bin 0 -> 1662 bytes tray/icons/red-16.png | Bin 0 -> 297 bytes tray/icons/red-16@2x.png | Bin 0 -> 593 bytes tray/icons/red-18.png | Bin 0 -> 350 bytes tray/icons/red-18@2x.png | Bin 0 -> 716 bytes tray/icons/red-24.png | Bin 0 -> 491 bytes tray/icons/red-24@2x.png | Bin 0 -> 916 bytes tray/icons/red-32.png | Bin 0 -> 593 bytes tray/icons/red-32@2x.png | Bin 0 -> 1182 bytes tray/icons/red-48.png | Bin 0 -> 916 bytes tray/icons/red-48@2x.png | Bin 0 -> 1826 bytes tray/icons/vpn-green-18.png | Bin 0 -> 523 bytes tray/icons/vpn-green-18@2x.png | Bin 0 -> 1116 bytes tray/icons/vpn-red-18.png | Bin 0 -> 581 bytes tray/icons/vpn-red-18@2x.png | Bin 0 -> 1216 bytes tray/icons/vpn-shield.svg | 4 + tray/icons/vpn-yellow-18.png | Bin 0 -> 523 bytes tray/icons/vpn-yellow-18@2x.png | Bin 0 -> 1117 bytes tray/icons/yellow-16.png | Bin 0 -> 290 bytes tray/icons/yellow-16@2x.png | Bin 0 -> 563 bytes tray/icons/yellow-18.png | Bin 0 -> 332 bytes tray/icons/yellow-18@2x.png | Bin 0 -> 665 bytes tray/icons/yellow-24.png | Bin 0 -> 476 bytes tray/icons/yellow-24@2x.png | Bin 0 -> 865 bytes tray/icons/yellow-32.png | Bin 0 -> 563 bytes tray/icons/yellow-32@2x.png | Bin 0 -> 1123 bytes tray/icons/yellow-48.png | Bin 0 -> 865 bytes tray/icons/yellow-48@2x.png | Bin 0 -> 1694 bytes tray/install-tray.sh | 35 +++ tray/lilith_tray/__init__.py | 16 ++ tray/lilith_tray/backends/__init__.py | 1 + tray/lilith_tray/backends/ayatana_backend.py | 153 ++++++++++++ tray/lilith_tray/backends/null_backend.py | 54 ++++ tray/lilith_tray/backends/rumps_backend.py | 118 +++++++++ tray/lilith_tray/base.py | 127 ++++++++++ tray/lilith_tray/board.py | 134 ++++++++++ tray/lilith_tray/palette.py | 36 +++ tray/lilith_tray/types.py | 56 +++++ tray/requirements.txt | 12 + tray/vpn-toggle.applescript | 27 ++ tray/vpn-tray | 5 + tray/vpn_tray.py | 192 ++++++++++++++ 69 files changed, 2023 insertions(+), 180 deletions(-) create mode 100755 bin/fleet-status create mode 100755 bin/net create mode 100644 gui/index.html create mode 100755 gui/mesh-gui.py create mode 100755 smart-lan-router/install-agent.sh delete mode 100755 smart-lan-router/install-smart-router.sh create mode 100644 tray/.gitignore create mode 100644 tray/README.md create mode 100644 tray/com.natalie.wg-quick-wg1.plist create mode 100644 tray/com.wireguard.vpn-tray.plist create mode 100644 tray/generate_icons.py create mode 100644 tray/icons/green-16.png create mode 100644 tray/icons/green-16@2x.png create mode 100644 tray/icons/green-18.png create mode 100644 tray/icons/green-18@2x.png create mode 100644 tray/icons/green-24.png create mode 100644 tray/icons/green-24@2x.png create mode 100644 tray/icons/green-32.png create mode 100644 tray/icons/green-32@2x.png create mode 100644 tray/icons/green-48.png create mode 100644 tray/icons/green-48@2x.png create mode 100644 tray/icons/red-16.png create mode 100644 tray/icons/red-16@2x.png create mode 100644 tray/icons/red-18.png create mode 100644 tray/icons/red-18@2x.png create mode 100644 tray/icons/red-24.png create mode 100644 tray/icons/red-24@2x.png create mode 100644 tray/icons/red-32.png create mode 100644 tray/icons/red-32@2x.png create mode 100644 tray/icons/red-48.png create mode 100644 tray/icons/red-48@2x.png create mode 100644 tray/icons/vpn-green-18.png create mode 100644 tray/icons/vpn-green-18@2x.png create mode 100644 tray/icons/vpn-red-18.png create mode 100644 tray/icons/vpn-red-18@2x.png create mode 100644 tray/icons/vpn-shield.svg create mode 100644 tray/icons/vpn-yellow-18.png create mode 100644 tray/icons/vpn-yellow-18@2x.png create mode 100644 tray/icons/yellow-16.png create mode 100644 tray/icons/yellow-16@2x.png create mode 100644 tray/icons/yellow-18.png create mode 100644 tray/icons/yellow-18@2x.png create mode 100644 tray/icons/yellow-24.png create mode 100644 tray/icons/yellow-24@2x.png create mode 100644 tray/icons/yellow-32.png create mode 100644 tray/icons/yellow-32@2x.png create mode 100644 tray/icons/yellow-48.png create mode 100644 tray/icons/yellow-48@2x.png create mode 100755 tray/install-tray.sh create mode 100644 tray/lilith_tray/__init__.py create mode 100644 tray/lilith_tray/backends/__init__.py create mode 100644 tray/lilith_tray/backends/ayatana_backend.py create mode 100644 tray/lilith_tray/backends/null_backend.py create mode 100644 tray/lilith_tray/backends/rumps_backend.py create mode 100644 tray/lilith_tray/base.py create mode 100644 tray/lilith_tray/board.py create mode 100644 tray/lilith_tray/palette.py create mode 100644 tray/lilith_tray/types.py create mode 100644 tray/requirements.txt create mode 100644 tray/vpn-toggle.applescript create mode 100755 tray/vpn-tray create mode 100755 tray/vpn_tray.py diff --git a/.gitignore b/.gitignore index 2c8b1b0..85809e0 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 983cd0b..aa0ba76 100644 --- a/README.md +++ b/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 ` 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. diff --git a/bin/fleet-status b/bin/fleet-status new file mode 100755 index 0000000..6a35d96 --- /dev/null +++ b/bin/fleet-status @@ -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 diff --git a/bin/host-apply b/bin/host-apply index 4b68c8b..fecd138 100755 --- a/bin/host-apply +++ b/bin/host-apply @@ -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" } diff --git a/bin/mesh-hosts-render b/bin/mesh-hosts-render index 63e44f3..ece0fc2 100755 --- a/bin/mesh-hosts-render +++ b/bin/mesh-hosts-render @@ -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/.lan = current LAN IP (direct at home, tunnel when away) · .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" } diff --git a/bin/net b/bin/net new file mode 100755 index 0000000..d8727e0 --- /dev/null +++ b/bin/net @@ -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 [--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()) diff --git a/data/mesh-hosts.json b/data/mesh-hosts.json index 0b60979..c31f8c1 100644 --- a/data/mesh-hosts.json +++ b/data/mesh-hosts.json @@ -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", diff --git a/docs/topology.md b/docs/topology.md index dc10d7f..8bb58b2 100644 --- a/docs/topology.md +++ b/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. diff --git a/gui/index.html b/gui/index.html new file mode 100644 index 0000000..ddf6809 --- /dev/null +++ b/gui/index.html @@ -0,0 +1,126 @@ + + + + +Mesh control + + + + +
+ +
+ + + + diff --git a/gui/mesh-gui.py b/gui/mesh-gui.py new file mode 100755 index 0000000..01d40ae --- /dev/null +++ b/gui/mesh-gui.py @@ -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()) diff --git a/smart-lan-router/install-agent.sh b/smart-lan-router/install-agent.sh new file mode 100755 index 0000000..052af91 --- /dev/null +++ b/smart-lan-router/install-agent.sh @@ -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#/Users/[^<]*/smart-lan-router\.py#$AGENT_DIR/smart-lan-router.py#" \ + "$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" diff --git a/smart-lan-router/install-smart-router.sh b/smart-lan-router/install-smart-router.sh deleted file mode 100755 index 52a380d..0000000 --- a/smart-lan-router/install-smart-router.sh +++ /dev/null @@ -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." diff --git a/smart-lan-router/smart-lan-router.py b/smart-lan-router/smart-lan-router.py index e979da4..d35dffe 100755 --- a/smart-lan-router/smart-lan-router.py +++ b/smart-lan-router/smart-lan-router.py @@ -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 diff --git a/tray/.gitignore b/tray/.gitignore new file mode 100644 index 0000000..34e32de --- /dev/null +++ b/tray/.gitignore @@ -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 diff --git a/tray/README.md b/tray/README.md new file mode 100644 index 0000000..1d9414e --- /dev/null +++ b/tray/README.md @@ -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/`. diff --git a/tray/com.natalie.wg-quick-wg1.plist b/tray/com.natalie.wg-quick-wg1.plist new file mode 100644 index 0000000..fbc74d6 --- /dev/null +++ b/tray/com.natalie.wg-quick-wg1.plist @@ -0,0 +1,27 @@ + + + + + Label + com.natalie.wg-quick-wg1 + ProgramArguments + + /bin/bash + -c + /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 + + RunAtLoad + + KeepAlive + + StandardOutPath + /var/log/wg-quick-wg1.log + StandardErrorPath + /var/log/wg-quick-wg1.log + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + + + diff --git a/tray/com.wireguard.vpn-tray.plist b/tray/com.wireguard.vpn-tray.plist new file mode 100644 index 0000000..80efb70 --- /dev/null +++ b/tray/com.wireguard.vpn-tray.plist @@ -0,0 +1,20 @@ + + + + + Label + com.wireguard.vpn-tray + ProgramArguments + + /Users/natalie/Code/@projects/@tools/net-tools/tray/vpn-tray + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/vpn-tray.log + StandardErrorPath + /tmp/vpn-tray.err + + diff --git a/tray/generate_icons.py b/tray/generate_icons.py new file mode 100644 index 0000000..9b66dcf --- /dev/null +++ b/tray/generate_icons.py @@ -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 = ''' + + +''' + +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!") diff --git a/tray/icons/green-16.png b/tray/icons/green-16.png new file mode 100644 index 0000000000000000000000000000000000000000..37195dd8ca3d68b35868d65b9b8e79ab8cf25e75 GIT binary patch literal 289 zcmV++0p9+JP)ZO93?-!0|BtBeVH^sW{(2< n*rxNo{b&2Vat7JtvHx068n-y8oGiIV00000NkvXXu0mjfz+!w` literal 0 HcmV?d00001 diff --git a/tray/icons/green-16@2x.png b/tray/icons/green-16@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..65881b98292b9d63c6564d44279e3a21e2deab24 GIT binary patch literal 562 zcmV-20?qx2P)X<*7g&DXxsTkk@k06<<{qPCMNDOcA*VMcd9z-vo zhYdQd&)qyQ84*2)I8*+DH;i>(5(QUG}!fxrTiIcYB1TQbS)dwoGz zSwoy%E2`~lp){quL!7|&F1RTLkmWtoplH4acn)N|9h@#NCNI2;$&5KtttojIlLN&0 z(#dCgxSD1Ap??&320G+be}SArvgmDS|I=~)0>F8Du01PSGXMYp07*qoM6N<$f^t>z AZU6uP literal 0 HcmV?d00001 diff --git a/tray/icons/green-18.png b/tray/icons/green-18.png new file mode 100644 index 0000000000000000000000000000000000000000..10008e22fd837e90fdcfefad8bffe73ebbfe8fdb GIT binary patch literal 323 zcmV-J0lfZ+P)X(0*TBK+|z-m$Wc%vOFn@m8sH>RLJBBp zbA=d4P5>wHPiov~LEo`2cJQHmSMc z8<-(+S`V^h2=W2+Zd7sxog&fh8f5bUWIqic9?ZhRnU?$6ss#j>yQn&{Bmnts0r8{( z678KM9p485un}Hq{rf@r*#eYxaAY3H{yRAO^FiAVjI(GDyG*7Xeg0uB8+KC>tY1MIZqSQ-cH}I|Ew; zDUCWriijv_5fUu@7wA9(jD-OKDlmK(b+)R&)xE%`dgT{n7ZK6$Kw3(Iodz8R4go`+Z!7}~KpC`*jK3QUNGDT3 z0rX6dH!=@okW0^k0qIN}zLkLUfY}4Y9SgVt+s2S{)wP54+IILm3mjh~#G6MDJB?iY z)H_J0c7pW;*dGw4kynryK{8+c1`U7AOdPCR1Hb^J;LjYIPjnA*_RO&XVCs=0FjVv% zX{qRB3iNp&8@St8AP11K7w)3Z3h9*)FgSB)!CP!zAuT1L_BI5}%8Xzpkqhq|1rtupt7_Mkwy`0U28&z|>=IT~S1b z@Q({*|A>xZeQzAOK-LG~cLbUx$bD=DN!|vYMgUrHx4hmxR1V)GbM8+bkd~dtd1z6H z-E9wY8uHsAf@aXg12+fnk?TnI&FUZkDp}yc05$Wl zobwy#(Bv0)fbrPrfdkD;NF461$p?_!7f6f%_XET{1p8<^OlJ__oLD&tGTrA!uY#1I zastVH?l@)Fm(;0!P%VI*?d_+u-h)`iZv*!SYJ%0$^-gct>1Ud<^#9KW^N#m96FjI1 rrvjJvKzy@PsQyGI7W=y9dREClz%`b&kQ|yT00000NkvXXu0mjf`=c66 literal 0 HcmV?d00001 diff --git a/tray/icons/green-24.png b/tray/icons/green-24.png new file mode 100644 index 0000000000000000000000000000000000000000..374e3fd4cbc5bcc4fc561defad17ff7a15933240 GIT binary patch literal 453 zcmV;$0XqJPP)E9{42DaM9axhK9S!83$x&4 zfE2I*ME#ita07Y{dW`HnjV4gnfr%Vw9+(=i$QPgpUI}qqg9YlwA$kU~JRrE2OYk-j zw;mjH`d1!3{X;mw3dE{tHS)87*e=NGAHv%#!F@J3@O4DyLhpd-5CVWYH0P212fBCQ zWGsXO#KGC>qy{EZ7Q%hLj@bxQ#jkT)Ec%)49aIv5M`VK-6lVg0549NHb|{@HQWZFS zGiEEiBfFDyb|9j_t)*~GpE3lhNY5<_F1`_rANh?dEUyqb=@-b(v+0VVIY6?H{Q>~I z5@;<%_!Z1mf&<+Gaa+*XG_7}3v<6-pNjHBM7)c$xH6TCaoxA`!Xsq}C3mEQ~DcgQ; vSq!`r54bkfGIs3Wp9Qp#Knn>wVV3v=RP;wuqQX+ffB2ceR}M`1Q9#D*CD0%8mp zV|Z+})wADfp(+zPS${%$oPEJhS3bD z>>*$Z7>IhIkaA{Vdk9Hyg>DhLZ*}(ssQD7!buZSyx`d=YhDi=*K;7#C>jltKth$hF zfVzlGEC(e98&G$SgS-KbmP%bnHbHukv7BF$zX3hwmtq(;UaW$2BV)h35)uI&OjS@k zKnJLYehJ=BKxLD_^Kz&%;Ih{g>ux}Qq*f|?_w!kWwoYVV*M7od2sI8?k$Ch_`h3mp zgx5PTTwXmUs1diQa|o%-DM-AJLsE3CP9(LGA8~mCbys6l6p=)qBjQX_XJb-h!UcQ4 z?H%ZfM~e{-ZG{F@rp4!Hq#9^OWt#E>wkMD{7PBA&3N_k?(1Ccxm|z{uKMGn4RXuhC zo~yp2Ug&JMH#Mw=t$q}y(Hjwcn`2n|g;9Y!rBID5JX5e)^T8vn* z2V4wTPh%2efc4BCaT?I&zNNS%MPSjs62<#FAd@i&iimpP7WFW&J+oKts}^$5KI?YE z-GDB%-YB=K@L0Fp{)h2hO33(2U8}4((@4s*IufuJ+8)vsfr^m>x$c+XZ$Q#pkVpdG zORX#f8({VMRpkh^8kNycppw9PDb)qAu8X_D73y%yX_;7tL~l&PDF^CIn94Mf{s&}K z7h2&_G3o-C2I)a2LQmnQTTDs4w?&6hdnY+yM9+NDC}NXuwoaG()CkaH*vq265p7Sr z=J_B>w9T413DOU8ey{Y=?2);-x6WDsnG4#y2t&Cxx|=|?Lj8Un*!d0e6-oUl%k^s1 aFy}wCPOO;q>)!_e0000X<*7g&DXxsTkk@k06<<{qPCMNDOcA*VMcd9z-vo zhYdQd&)qyQ84*2)I8*+DH;i>(5(QUG}!fxrTiIcYB1TQbS)dwoGz zSwoy%E2`~lp){quL!7|&F1RTLkmWtoplH4acn)N|9h@#NCNI2;$&5KtttojIlLN&0 z(#dCgxSD1Ap??&320G+be}SArvgmDS|I=~)0>F8Du01PSGXMYp07*qoM6N<$f^t>z AZU6uP literal 0 HcmV?d00001 diff --git a/tray/icons/green-32@2x.png b/tray/icons/green-32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9c180adcd5559cef333230920bc0acca7874250e GIT binary patch literal 1091 zcmV-J1ibr+P)DV2FdK7g>1>WaiY zwec?SbrxCvkwZxBN}`^L;zf_TU!t2%M=%e%E6F3W{S;6YsC?W($pfqcxsc?3kl-FL zHUR7|NSIIdLik?0PVo+DP=)Gk53jebxC^~o;?@prMnHA2gOdk%75<5RWZMnUpaPt7 zp~^F-pTK)BuXgT(;#y}985={L373^2OY;cPQC+6?G(ET#m^Iav&l*Bz@VgEvf#w#VQq zw^~ZUn3K(bm2S0^f^jvQZBz6)!e+oj&pLdtU~04(Fz;4NDHwCIJqG`9ET#;8H#J%e zsN8fcrVNZZ*`~mZTPEl&RIcQxbjdo_mf~gmX}P#q>GdJOZMQ zSA8h+#P_yZg18-_&m0Q!OvRc@i~;{nx5`pFomm*ZZu`-0Kva-4?n;$EG>j{yX|opS zooLnMbSihCIT_a>-T*n-1gRIT$|E%-_%E(Q!mq=PAAti!sq%wP!^r-2m1GH*!#dPq z3#13Y(IjPb$qbPCEa8u{^T;prdKZ=F(M(MX$)`fumWZ zdEj%X4Wy|cO}8Z`qVRYqDoK0hxCD;})0WR>s}zk!LIXIR>H5OaQ5ZR!rFJ-PsqEVz z7fY-bod<@Hk*~4_$hRvj_3uD;13%}AlZ~H1cjif+V$0>n-Un?&WhqX%6TRW(OMHof z4@?Bhpn3$T0h)P|^m5p&V%-1$002ov JPDHLkV1hI4{!9P> literal 0 HcmV?d00001 diff --git a/tray/icons/green-48.png b/tray/icons/green-48.png new file mode 100644 index 0000000000000000000000000000000000000000..ec9b6da913709b57352428400644651553c91b10 GIT binary patch literal 848 zcmV-W1F!svP);wuqQX+ffB2ceR}M`1Q9#D*CD0%8mp zV|Z+})wADfp(+zPS${%$oPEJhS3bD z>>*$Z7>IhIkaA{Vdk9Hyg>DhLZ*}(ssQD7!buZSyx`d=YhDi=*K;7#C>jltKth$hF zfVzlGEC(e98&G$SgS-KbmP%bnHbHukv7BF$zX3hwmtq(;UaW$2BV)h35)uI&OjS@k zKnJLYehJ=BKxLD_^Kz&%;Ih{g>ux}Qq*f|?_w!kWwoYVV*M7od2sI8?k$Ch_`h3mp zgx5PTTwXmUs1diQa|o%-DM-AJLsE3CP9(LGA8~mCbys6l6p=)qBjQX_XJb-h!UcQ4 z?H%ZfM~e{-ZG{F@rp4!Hq#9^OWt#E>wkMD{7PBA&3N_k?(1Ccxm|z{uKMGn4RXuhC zo~yp2Ug&JMH#Mw=t$q}y(Hjwcn`2n|g;9Y!rBID5JX5e)^T8vn* z2V4wTPh%2efc4BCaT?I&zNNS%MPSjs62<#FAd@i&iimpP7WFW&J+oKts}^$5KI?YE z-GDB%-YB=K@L0Fp{)h2hO33(2U8}4((@4s*IufuJ+8)vsfr^m>x$c+XZ$Q#pkVpdG zORX#f8({VMRpkh^8kNycppw9PDb)qAu8X_D73y%yX_;7tL~l&PDF^CIn94Mf{s&}K z7h2&_G3o-C2I)a2LQmnQTTDs4w?&6hdnY+yM9+NDC}NXuwoaG()CkaH*vq265p7Sr z=J_B>w9T413DOU8ey{Y=?2);-x6WDsnG4#y2t&Cxx|=|?Lj8Un*!d0e6-oUl%k^s1 aFy}wCPOO;q>)!_e0000@_oWMP6v3&s$|V2C>!qb>|cVtfz< z5;qc+5(=e{)m`;LTV4WcT-mBAxKJ?h!9p-bFvNfw7V%2Mi+pp1DOld0+R!J83c|2hk$+H>@7x7ibnwH zoCVHO$UF=3Pz<>Va0J)|P9LiEm|_2+D69aIodDyPfh+^&Cmj8Ipcg87QH>W9j(v^7 z2q2vkL0$!#fx8Qm!*vEgwgQ_`ErUr1PgVfgFdnX~18D&6Of!niW8@V_{q$@Pbjl#lxqNTVisRWKICtQVGMK0Z$t9S)A;Fszs>!5fjeL zoC74AflGZUC_cbbFx>ARYNKQ5BwV=>*P(GdOEV0 z{{OSKJ?!XkGeA0?2EGg%v^;U133CB2xB#+YJX}5k+!uIkd2<4CbAtX!3vmb3HTkD6f{su7=p@~sMY`I4hT8}R@vfvG6_ar4>CZ121r*mjGhFh zhsi8O3_xxsDtkf4IKT{WmfPZMF%>Fb(KQYrfGiK0PzuQ`)3ww)1W8*hWR6BjDSfEf zs3zZ>sq?eY5J2V{$|i-VNBk^wPQnkENhX!}?t}FfK)PyxDJC;Y z8B^T7vA4%(2FNVK38fLwS>PkUXpP}yQi$@bq2D=RqS<89i1MtVj{x3-Em}koz|Mf^ zD9=Z+;(I>8qJ^IT=M&1KjFEr6zV%y%S^=WtSwlMmV#oVO?6(T~({MH^#Jh$*0#p=5 zKZVl9@w_dmaYLqKz+Mju%{f$alxsl%95iEUaKtlj^*ibSlM1J(M(77SA*HAWT z1yGHD0PP3RQpgZgz7u3XFacEa5U??5Rw-hAkS8PwcTc#gGvAZh zax=%Fswe26-~y=n5s;OE$CW>;!gSFJw<|)m>~Z^QTJrRxn!gA$Fv7MJ=QZHB@Z-yd zQ!w_8u!F-3pjrk&y$DDZL- zBatTFMl}cO3vnw9ZB5KI06We2Dh1RI$h~RAlL;qqt#Rvax36Yd^A+S4p|VEKedadj zs`?SgEd=_F`7Ay@f!tzK#V4NT-J+ng8)z`@yRdA7Tzzpj@4gXfUk$3W40?)Q2G*gP zH^<>T<`+10&I8#|!ae3rx$FFBOHD80eLzDs{QxqrL{~`tQ2B72#V6sH_|$=H10GB) zW;jlOtVFdW(ib)1*H~?-gyBYD4RBYYvHckYx}mDa)Vl$6DD;cmnt&Mv$>BO@-I+OV zQ_!0!CMfW$47;k~>QcAInmnFlWZ;0idm-0{$_^(O`zDH~50I>X07jSQyK~KnVS(TP zKZEQ7K0vjcFzi1R#UsFtNcS|i*^w*&^(aWK+YT}i1He(>u)D)s_JwURA1B2rz^#z( zX>hgHeW7z^0!aP?pf0!{&V!r*=V&n&+hVa;EEbE!Vkr{-11X$?7%Y%6wEzGB literal 0 HcmV?d00001 diff --git a/tray/icons/red-16.png b/tray/icons/red-16.png new file mode 100644 index 0000000000000000000000000000000000000000..826923b2176c672a3af99cea9ec053533618d48d GIT binary patch literal 297 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Y)RhkE)4%caKYZ?lYt`lJY5_^ zEPB^YI_P`YLBRF?@ze!(Jox(-sV-tR7YLMYVmc$)(h;Uzufttc>tsdXdDdv<1ctcnjFN034tKY^v+NNL!HD0k!SN zh`hc4IDsUO#Pi;sD-gYNFS5@wA%lS}2M33f#rP`_d07A)z``^)b?Twd2&n6>0d@iK z2%vQwXEjv-)s4d7DU4A&v|eApG{4W-Wb#UvO6Br!#~gRSvT7y30Tv0v+VEg3(C;so z06&e$jko|@D@_2d#|1>RM1c`&vIAdA!;5^53mC>pX#%Pv)>xog?EpVYz;~%sj$#7R zZvO^8mLSlcoSLS;1>1$=MRv{{PM=rD@c^d~xaz(H!JWxc(m?VI2n)c6Ky$hn6A<6; zp!zU(yy_DPf}`nj;z%Yz@GhDQ^2&1wf~`c>tgCVvN=2TE=cSH!=a1!$Mh(Mg0d6L= z>ORm+YI`Pu(fu6fKClkl0~S-3b%9+(w#D;4%yZxDzh)~dm+19w0XGr3dRo-4z%ffp fhtY=iKXc~~Cl{8zzsn4600000NkvXXu0mjf;C2yI literal 0 HcmV?d00001 diff --git a/tray/icons/red-18.png b/tray/icons/red-18.png new file mode 100644 index 0000000000000000000000000000000000000000..e52a80ab0610b516cdc482c420eb564db8b3fac1 GIT binary patch literal 350 zcmV-k0iphhP)0rtoGa4C-6tXwhjwJAI>Ep$H&wTstab>wi9G=Jb~FA z%&uDIm_ua7Jm0iJ7DX2T9srDfSjhq8lxe#90$ChSp!(7QsWFkDtjtwu)%zX@0F2;y zw94Y+OTTIv66YS<%NYp!XUQSq_HG zmKAio3-bZUBiHLUoZKUk8Gu6%W)HKQvQa_I^9{&pM{E!By7_=zvzKYQf=B@1xs@QF wpc*va8NU5SqUanz0`ja#*bcy=+>?KcPpM300Pnmu>;M1&07*qoM6N<$f`3Yr1poj5 literal 0 HcmV?d00001 diff --git a/tray/icons/red-18@2x.png b/tray/icons/red-18@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e955a44c96bfd82247a3b845de3a11d59e1138ac GIT binary patch literal 716 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 literal 0 HcmV?d00001 diff --git a/tray/icons/red-32@2x.png b/tray/icons/red-32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..31dd57cfce208318a472fb913bb000bb6cba5723 GIT binary patch literal 1182 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 Q4gdfE07*qoM6N<$f&@Nr(*OVf literal 0 HcmV?d00001 diff --git a/tray/icons/vpn-green-18.png b/tray/icons/vpn-green-18.png new file mode 100644 index 0000000000000000000000000000000000000000..bfbb6f92bf0f8b6078f1884e817b745835f28a95 GIT binary patch literal 523 zcmV+m0`&cfP)8Z}H4(3`v8>Ay^AekEd&04EEfc{^rK}+b) zUe`NEV!76xWm-V<=*`UEJA?8I*`km5+6I)~VnE#9MzkHeOw(el7rV&4X$N}Iyf@BB zr4OXJRz{kWu6`sxh*-(2o$RPQ!+tjHWwC%EY{{%YXxwAzCxvO^y;M5c=0p%9q+S-Q zQfYy%N95+S>!Eb*8-*&iVq~565!JJz+7Pwj=5skO%FV%z+GSC#i^^b2(;Z;vaF@8x zVv)^>sNn*Jq|qm9Bkcz=jXowFAnl+qL(E`1>0mp^zbVFgv){e0egjGfj_CJ4Bz^z@ N002ovPDHLkV1gYl`K|x} literal 0 HcmV?d00001 diff --git a/tray/icons/vpn-green-18@2x.png b/tray/icons/vpn-green-18@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fa4c5cad507b7213f348da03ac1938f0b4965830 GIT binary patch literal 1116 zcmV-i1f%hWa_W)`3MqnM#0VLfKV8FJQ*Ju`i zJg@~xe?K=snq3B(0$EWLUjls_Dmi4sVH2CX9?DOFJO-R!Q|e35K1h8Owa&tU!q>-H zv(v#VNNPAdKXO_l*V$Ac?OO@D9avNodKlOWV|m0cgn~@{V@Wu#19%2>an0xm$Z90@ zf!jH6s!HW%5C`V$rS1y9)aoyQ`L`OU_zmKZ2C04l*a7SanfgVh7SjP$Sr$okn`XKO zWYgu~-45Pjs3c5h@(win15A8@Z2YMzUef!4{b)8bRV+4;7OPzq$hCpq26~O@thfOl z&<2S%G&2lx0188h?*wTv>R$4qf!tb|>maihDh~xPk#+@WJ2XFSa^qQiAh-&orGa>B z(qeMw9_XCd|3K4O2#{9}AJ| z+O1Ce%*=t*&2==rk=n=*pz<${bFCA40=yYao2Ss^KD8aqs#^7cN(V9f7=Wc{_96hJ z^AFIIjRWaPlM7aBi5)0F>AUHH%nR{r>m`6?j`+8Y15F%s%H3%-0lo^`HPpN9_d0|R z2kqh@$mrDYdM2aLIvDsBN6W1-El}c3z`j_u9o=-P(f3PG{wzKady&V-jZE$`pxyQt z!P^xdNIFM=cdXY8^PUaHHXH&5!_sPO2uTkGuC51~Z2K*wD|9_ZJgF0#fe9otS-8oI|UNbyTBoCoh{2+UC^ z<%6l(lK|ajGyr&c@=(wa5C91uIBuPL;Mj`+aDNfT^1wcj3G-v0f$>X$#<3ogYaA%N z5LGe8F(|hbDxU)vV$NWzko4~An9RjSt^0I!e|8nH%hnkM?n2U$>o49xQTy%&{S(ib zGldGgYeyfjnkA5aXzoGQ{nm*5EI!Bdwt?3T+z%|Uy}twRftNwLj?6@M4nA1c3Wo_V=}xC=Uo6+yv{ zQY;;uoE)rEmu{k9D-N9m?QH3Lw6>_Ylt9reTEu{@lhQQr9fw|HY+l;pzg+G;=l{+* z_g>gTS}NtWRLbv_Cp`f{a2&@O0d4^vFoWYfkoo!AzCdbB6_HU?F9KhINgKKi^ds^L z)d`8Bxm1u=Dwk2c2b=@mBQnAI`i!isGy!yQFiSpv1=Z1xocj`nFa8A?^BNccZv3gH zzJHW#_B;U1<{MdB`qJ(io&t4=q6^sBJ5`q`np0y=pjttsi0XDElR2g3@@GUsn^W&J zI%yY2k_3npyUs<5BuS8FNeBIh{(?lBKz94oiF-TRv8?s%03}HSDA?VPU5anmwI9`Q z-5_`e3}_G>B92o(oh?{X-!A~ifVbTs*PXSkUzNqhkBF>wK}6PMVc`>=Hv}k2GTRPy z=BY6+fz!nCNnAIF>M$Y)EaWGfn~#a(U-b3W5cwcscqwf(fH9ze>yAqlZIH=K0}Y^o z>rTn?@^^B%ao`}W)>zN+ZCBUSm_&^k0nlpI(`wZN&~o{X8WXEALtRs+!YY;Ps6GOo z;CXl1+7d(_0ylwSiK6E_J*gj3!tfcYXMiKLTC0ex0*6sOn+DmJ)b|Vaxp&oXD`&V} Tzw2pv00000NkvXXu0mjfSmgwt literal 0 HcmV?d00001 diff --git a/tray/icons/vpn-red-18@2x.png b/tray/icons/vpn-red-18@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ebd6e4e5adfcfe148bd3cb236f06a11084478668 GIT binary patch literal 1216 zcmV;x1V8(UP)@@v_2FGu?ZD+mMo;<1%g3C zV$#5a%8MUSBSv}9s3AeoD3Pd%2;qSy8l#DdViq4Z-Px@*Sqzju0TMB`q{P&gz;3&p zIX=vEI^FJPQPjlybnZR({LgRao}GK|z`v}?wF)Q*@%RP+(&?mt{v$xz-X5j3^$}nv zaGy7P26&%*{($6iSFa0@#^Z}oeH3^axTC_fOTZy)d%yJc{r0y2X)M-`$a6q9&=hn5 zNzdL;qFDfXP<>Z2nZdsVNKMlOUIaFl%%224#c}pZHhUUC*RH(>$9W2P9JrxGDg(TU zWgV?nXJvsZ)W>vlmqYbE$z+Dg##dj~s&zI8NMo_hh#UZxl(BdG3>Oy;ZLV0ey-$o(ZSRUfr>b}k?iSp`6`_@ngqkNZ+C_(9-c znWbOi<*ETRyS*WnwX2*%2eE`+vKnQ?nbd8$kSqvjBy}iHA`kj_#0hXHP9^j=) z32OmO%z^+R7HdPK6Vq0rKLr`Hd3o!b9Ewc1=DRok~kjDS7= zV?{7Fs2R*v5lH0sK)_7_Pk!D&wmq{&sNNj#T&f3XB+y$N7{5GkAk|>(;($F;I<;I4 z4F~LHzSY!pf%%;Vm6n#7x3=oCfIVC~c`i^Wl*2v80Ibxmu7v=SOkP3tV&gb^QIg3k z0NT~H5Rnz0r$99NQ%xXmL;PVHkBJF0Abz4@AjtVvp%7StazN;v$y?@Omo0Vm7F%@&aX>FXQt`%_apfr#h(L{h1;^;r2$ zZ5VF>Q=Y&xYMO1nZ*=q<;6fFgU$}bJKX_>@b}#O67GMh7e!F7YN)Abqb%i zP`7Rkfwy!mEj_@wGNI=f9qsX#%8mH|hwXC6x6H4ZZFIA3HXI zW%U3l+$T8&q_C_W862GSHHL?GBeLG>{Y*apTD6q5uR&|pti&*Sffhg!*)Hkyky&$0 zvlT!rYqVs1J8;;`GY+g3%c@#`bzKxS7F&nNcesz;0d|OG9jX!Y7$P6xu0j#V*(BL) zvRX{tKmZz#KZxoP;6~gnWH0&r%hR>u&+F)j(A>Nmc)`=Z40MZSeO*tUhVJ$nkFUUS zzC`3sZ>S%UCnTLd0idSYfm^_ + + + diff --git a/tray/icons/vpn-yellow-18.png b/tray/icons/vpn-yellow-18.png new file mode 100644 index 0000000000000000000000000000000000000000..f35f992a000c4eb2ba6342a031ef2b989cbf989c GIT binary patch literal 523 zcmV+m0`&cfP)lu;DM@!xd@DXNW7laUYxflyGZMnkB5kjoUIP0NVF zFX1kNYS$u*zCg7e!F1k%gf=2bxu`4>!d42_%xU54VET^M*<85iIsg0IbI;{~h&eqm zr{^GDS4Y6CvypkI%O@pYBQq6s1D*6~51{U<NoZg6_zS|D|ad+2bfOHK8B3woi-9X-!p7lBKFIZPz@LET^h#P~LUXNX?G8LQ=-W7q~7ZY1o>w=aN;) zs&!N8vLBizCC9Z)bi6)9!&qcmQFj-jrX01c!Hvwl$TXv2bYDmtw)LkrBJ((Ej>cQZ zqTxwo)}vvpsHSirQ`HM47jepAOUjnCoK$itUH0;y+!VO0IVvrM8G(ld-zgIx7fNWPP+}v`xoZZ-)tv z6wEI@ttHplIY4onX@X@K*4bgN3X7dqA0}xkc7>f%)~hkVOs$?0jO^k*CVDiC^I3YZ zsz9APy16hEl^rs4H@$bIpTI#$;pUDqB0pC zAGbQ7%BE1~hje_%*o@6FYayO5*03~Cbf%LT1zXgrFxFW&6o{`7)XJ%iSw{p7>g)+Y zYp0qe9;NQ?P)D7uZnDjNDh(+w!PTPawwV;SS{=qaP)=G!xj=&9LYXj~^M(k9mm6L_ z&;^qJfx44(fr4C0@9@Vyd{(4$(>2q*iURRVO1~_n|Jetijx8d%Jf$Bm2k2NTVQ5M} z)oY-1ZcNo|P4y}Y6#bmiZ_Mih99GebX{!)joO)}ARLzQ1ucAOrc97pZN5}03L%7W! zIND>NoTEvuxZPmUsC=i>c6JMtY>3&!tdujM!|9tI1GUI!F zL2cgiqc22ua44}RyVfiDzV|v~je2a*b|+h~!5Z~pgywvW z>ny=7cF0)SDQ1&PAx!jZr||}}MJtj%Ck6L}v1P2k zZlSU!#jGUVecO&JWT(j=SN?d{{SftVm?Q10;w_UZf z76_hA>YozL4C8!$UV##*QTPtrldQ!jlzhfHIiss`13`&N7!zWl6GWPeTZr^`ctyiPJ jsiLUKm*pO_|15&L2oj9&LlepRxFVqUnNOE=5!>q@yfsx6JC(r7_%;)k4rVHH)Qa7C?SA8(@XiTp_og#-mQ~T#*Q%|xSx)!~_ zN6o_Uo7e)0mz7b|_Eaq)bAtHUgr4K4@Xgc_wPtt`~p(XN;s l_3Qk?WqAT>mAC8H8jI?fY?%_dwfmm||V>pk48N*gFE1C(3XMGWfHC4fc==5%7)4Udq zheZ9a1!B!LJi75)ZNllrrIU6PVfq9Lj>pSaUCHK-U$BbvH#1_W#(C8Ob#rPj`FkKld|a2T*@GRF3sZWBm9wb2~ljU*Ob$`ruW9O1tc z+ZkKS5f=<9`Mkp>)32;pbEvUnHdml*qyRV!IbfvGXbNl?E&$%h@lJu@Xkqy1Sf>E7 zK!Gh*>k51*OdxpQP2gQ&0-CI*z!!CVD*(P?&D#!vaM2IEEI{BzYt_`g6Gqdy4Wm0)&jG$J7~kI6ArKOE!S%l5gIl4}eEBj3AXGd}>w^9y zx*IB{x_j+3uRlfU?T}=bcbP>ggsLY>M$;0l*USl$o#1*pn7naWFop{l${4nSS;2Iu zc)pwK_I@;bWxqiu1t)MM?fxiQ_SI6lq5aRU^9xJ9c)Gn*BF6v#002ovPDHLkV1nww B0qXz& literal 0 HcmV?d00001 diff --git a/tray/icons/yellow-18.png b/tray/icons/yellow-18.png new file mode 100644 index 0000000000000000000000000000000000000000..65967a8cfd3904cfc0be8cf6064c7c76f0c3c7d0 GIT binary patch literal 332 zcmV-S0ki&zP)PZ}x1uoej zgGZo{8P!H4xNz@lc99WrZtv{^jS56bj}5E2VDI; z17nnt81jRVzH*810Cav?$ri^@4AXB=nPkxb@v*c@7I_`_Ef9bX!tGunoskf+Y=P8e zNu#mZmN$RB!(Qvupzd=aqVAV|70VEDUKB3`3UlaCH3zaN1u^5dRQ{xylh$% eGpY9EWc39ESVbNod+FN%0000G%x||i5yY}R)GRaK_>Y+5Rkqi zO_e-wrN=k2h|7%Fyh#M4You_P1+D=jJwSqSP#%!thMcC-H%ON`h{YUmzE4OrFR5}_ zvV89uq-z|;VHr4?5XR3(Tx_w~Xf-$L_8{Ud@~v0LIT*O5ABR z(R9hYPt)5N1>9{Mlv6Uv``}>n4pq5p00S_BkdF?AcF4QweKCNkpOPx$lI5E}5Z$3m z1~CAFMJ_lH1}W2qK_G2Fs#w4o;~>bWHcF?lLn|Bw_6`9w%4?1^gDmzB0aHtb0}aN1 zmIlb>_s|q6e)$jU#iWWwkC`&*(Vax|yvOP8V)GpmmTx^tQ31VLGm#KD`jvxTB*AAdYEM6}d7WMc7>yjViF$uF zxGY5PGl_#jI7L|D7{b2V0Dr`0JLN^4(2e6^^QXye93fp4NVa_<}IG$7ZRr{=*{r~n_`?C0-l(tQz zZ4<-Hi9Z52O*-K==7cH9$!ykF$&O^zH_LI^$Ds^TQqyt|4{&=*-x}C*AI+iRU0rp~onqj)MA?SE z!%E=~I;W*99L6O#^N(VQ<|=^a)Mj_s+1wDODlb9Ki8XQU$!WEezFHy?@>J{PJG3%5(g9r(4D}obt@?rNBC99Pgu-0!d>S^|HmDOk8 zJ?k@w3qE17G(J3xOFr}&fEi0zFM)q)*OOwP#~{Lywj096Kfu?u&BwgM9_Fw9%o@(! ztl1MPp S%Q0vG0000nOVeLK5|{xd zCC1u}X&1(#LPN1`*Hh z`31XE;sM%;^1!Qs>l0A(BoLn0M3ybWCAU|s)_}7~t?cl>U&t!!bjUE-(hK@2l;2_N zMLa+|N4cuIpf`|D59*CkU9fBlskfX0K7=C4mK+_@!%AVO{0S(ogsI7jD1)V;as$G- zu=H4?xKJ9>t%0s^)cB%ITaf`ZORL9Wq#kHd3p5u7il0CeN5WQQjTCDvokIJ2jf`Vd=3Z%d^r@nE|KVmkdpmIg6#4C?5O3Snc&#BHY)7^#-LfAwY^s35Xl8s6)pUIH`HUbss%?4QpOe)ky>9kWN?h9!; zrO451Qkll)JRtFWp%ulWs^obyPKIvjF@J^o{lt_&7z5h<^w*F=7h$pbmtaF@17(zJjls;Nqnx=sy!ZZ;kD}B5$p*A177icCz r8(H=O5aka!zDbOqwYXn{237D6O8dADttZnP00000NkvXXu0mjfs%4Y8 literal 0 HcmV?d00001 diff --git a/tray/icons/yellow-32.png b/tray/icons/yellow-32.png new file mode 100644 index 0000000000000000000000000000000000000000..4f5a10507ebfe72bcc695ec714426dd858f6d65e GIT binary patch literal 563 zcmV-30?hr1P)wfmm||V>pk48N*gFE1C(3XMGWfHC4fc==5%7)4Udq zheZ9a1!B!LJi75)ZNllrrIU6PVfq9Lj>pSaUCHK-U$BbvH#1_W#(C8Ob#rPj`FkKld|a2T*@GRF3sZWBm9wb2~ljU*Ob$`ruW9O1tc z+ZkKS5f=<9`Mkp>)32;pbEvUnHdml*qyRV!IbfvGXbNl?E&$%h@lJu@Xkqy1Sf>E7 zK!Gh*>k51*OdxpQP2gQ&0-CI*z!!CVD*(P?&D#!vaM2IEEI{BzYt_`g6Gqdy4Wm0)&jG$J7~kI6ArKOE!S%l5gIl4}eEBj3AXGd}>w^9y zx*IB{x_j+3uRlfU?T}=bcbP>ggsLY>M$;0l*USl$o#1*pn7naWFop{l${4nSS;2Iu zc)pwK_I@;bWxqiu1t)MM?fxiQ_SI6lq5aRU^9xJ9c)Gn*BF6v#002ovPDHLkV1nww B0qXz& literal 0 HcmV?d00001 diff --git a/tray/icons/yellow-32@2x.png b/tray/icons/yellow-32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a098e5c65a858be902bb536327f87ae59a337546 GIT binary patch literal 1123 zcmV-p1f2VcP)^3Y19X7|O1(7p!kC!& zK}iXw@m?u}KcFq;4=@5+s`wF2jFNbVErg`ITdz#k#HJ9edeaET7K0eNuQx-&#W}O{ zby& zz${9QfJ=1qlL+ckpA!RA38>Hm^aFjsx|E^*1TLUVu$#*w)N_`auL1f!D_O??%KN~x zdB#S#3XE}sae0|L`Gw5^RfH-BQHFu1^N1P2AHXq6oX_L($ZUXC=tMaU?9Uo2Ayd?O zNA@z8B}gU%w9IS38DLG8I2l+%7?cv9rwtIKIY<@JGN*vA3rrqhHOgl?&8TX+KQr+` zI>}?Sb2hxgKR77o_?IKn%iSc0B^m*$2s{;e$Ofj_Zu-G&z_Rzv4fnLsV`?@VAZE9knmS#kW}5+>uJw4w zl9Sm01un`%?^VZvs>EzdgKqb!-4Z3Zm5 zRZ|GYoNUwJuddY;z;#ojZ5sT=wHoh;IVYO|HP?Cy;Ac~#*?=}KyH?|!fGJb6afzsk zP+{RAS`jW#Vx#GGx^V=A9WQv1=7(=>wFFTQLZ7(P;~BB%V%;nZi<@Rqo^vM54|A00 zOgsX@f}}C0(tMzdMwQY;uLW)}9@gYEC(1k>Ohk2wG$4GVay)loz7U3_i~pj##6Zhj z1YXKbm=AyFtUgi*vV!$Y+4Vsie8*Q%dwFsUS_XB_@J$n?aOwspPrkV{S8$w0t&I zaiJ4UlP9SW0|Don63WmN+T- zfi_;ul03PV%a?f*VFcKeM@;+4m`51On)p5P{a~WT3Kls;z>tGgnOVeLK5|{xd zCC1u}X&1(#LPN1`*Hh z`31XE;sM%;^1!Qs>l0A(BoLn0M3ybWCAU|s)_}7~t?cl>U&t!!bjUE-(hK@2l;2_N zMLa+|N4cuIpf`|D59*CkU9fBlskfX0K7=C4mK+_@!%AVO{0S(ogsI7jD1)V;as$G- zu=H4?xKJ9>t%0s^)cB%ITaf`ZORL9Wq#kHd3p5u7il0CeN5WQQjTCDvokIJ2jf`Vd=3Z%d^r@nE|KVmkdpmIg6#4C?5O3Snc&#BHY)7^#-LfAwY^s35Xl8s6)pUIH`HUbss%?4QpOe)ky>9kWN?h9!; zrO451Qkll)JRtFWp%ulWs^obyPKIvjF@J^o{lt_&7z5h<^w*F=7h$pbmtaF@17(zJjls;Nqnx=sy!ZZ;kD}B5$p*A177icCz r8(H=O5aka!zDbOqwYXn{237D6O8dADttZnP00000NkvXXu0mjfs%4Y8 literal 0 HcmV?d00001 diff --git a/tray/icons/yellow-48@2x.png b/tray/icons/yellow-48@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d6243955b0e841d19fc6714d62b0c134bfcdc20e GIT binary patch literal 1694 zcmV;P24VS$P)}!q z$Z-~r?&9xX>E)2z!5^j0ISgeffbOP_9J7EXglWJ8U_?YWr+`BU`|#LFFRkS$O4$gY z-!Ku6g?QYDFgbzT6gU8EL)a*J4q5hZL`enER)#W&W}q3!ryRZ=rG>$?$W+dz9Qz+i zB7lC)F#7Qr@H}vRDRQ`qPLy?A;&qwHskDQqDuA{zh#Hv9_xTfX~i@`_z7jHG_XCLa0#OYMxwk0+@EwT%WTDCu{6?^WROG!&<>{K zu^G4_$v6pe9A$ybV0Tgha!EPX0xR)otKi}Tj74bGc2*=6!1@f(Ez~fUbqI@X`z|pX z$g@NQ&!$tW0_YE1LpK{y9<=GVjQEHi7TCN>vL=9TX8^T)0^DWGXPL5tF6PQ?F4%Bp z@c8njBe*W?CXWC65|LF709+!%Fim2-q1rbPWGE&389ta8Qg zlbaaGvrz{{ngP0(;auVuV0hF(8R0Yo7$?*CGwgt9GvE@-T=9Jw&PASxG9dH}(ESYI zJU^q1HcBl!bkf5(X`(xfarha~!y;GwP>f_SkA^jlvIH$M%O`^<&0$SLEkRmf0?NTy zNo9>1Cdu^RnuVJIp8NDi<}#0jF$=o{&5f5$CYck)C~yhV-PBRTiBfN4tU#20)JZ)j z{fvTVK#nF?d?SQQ%nC9Jvjhb?ar`e$K?cDBT-l%RFhRIA$Rx}R7;h(=45GpNV0{J9 z-P8fKR+GvWwOU}LpMn1j=*L9c8D$R7e-`))pw!vUCv)WdYZxTJFx&ZL5Z@XGeF-}O z;u7#7_7@=M1h9bbQLMzy2ROpvFM!9H)H11F{_^`{&A2o$h{+FIV8@nw)vpa60^zZW}AIieu!584qygnL2T z;%AgGJU$L%7FK{3w+uxj$NS;TLLdzs20CIVl@)}xuuTEO-;P$^iIY>tXbEc=UVy>0 zAm!J0vkW=OIo=Cv99jUG%2_<#FitBwtPXoR2E#*oM}g~%QmcNFJ)|e~oCekyrIrPR zSE3Xjqi4chybSI(bsrq2lhsiNMHZmgDQZd7aaE79%%qE!(XI$-UdlYQ+PZCTr~Aj1@w|*ew^;Qu{LH&BOSmCaeP!b zD<#jKI1a3w{nB>U0?Tapsu+YfB_FLXMBi>Kph}JoKB(e`t^Z{+c^klSi<;UIC263QE~cTp z1kvu9r^G3Q7wO`bw2Ds|yvS`8Fs2kaT*XP0^$cWf)Q2fHDDA5ZuXJKuSnRPjBhAnV zdw>>d*eLa!OELDpD4RY&+Rk`jVX-^clmwPX5BMHs8_K)V$Pvr_jVK!du15DVs@Uvk z7I0hfb+*0-9r~aX;b3uleJjFF(-!kdQkDYrh3;h(7nwkgF$hD^;tzn(jnIw98I%*$ oaIhSU?Ql384u`|xaFhvu15N6Hd(Y;`-v9sr07*qoM6N<$f;0F6%m4rY literal 0 HcmV?d00001 diff --git a/tray/install-tray.sh b/tray/install-tray.sh new file mode 100755 index 0000000..03c448b --- /dev/null +++ b/tray/install-tray.sh @@ -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#[^<]*/tray/vpn-tray#$TRAY_DIR/vpn-tray#; s#/Users/[^<]*/.wireguard/vpn-tray#$TRAY_DIR/vpn-tray#" \ + "$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)" diff --git a/tray/lilith_tray/__init__.py b/tray/lilith_tray/__init__.py new file mode 100644 index 0000000..2050ea3 --- /dev/null +++ b/tray/lilith_tray/__init__.py @@ -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", +] diff --git a/tray/lilith_tray/backends/__init__.py b/tray/lilith_tray/backends/__init__.py new file mode 100644 index 0000000..6982c5e --- /dev/null +++ b/tray/lilith_tray/backends/__init__.py @@ -0,0 +1 @@ +"""Platform-specific tray backends.""" diff --git a/tray/lilith_tray/backends/ayatana_backend.py b/tray/lilith_tray/backends/ayatana_backend.py new file mode 100644 index 0000000..3782cbb --- /dev/null +++ b/tray/lilith_tray/backends/ayatana_backend.py @@ -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 diff --git a/tray/lilith_tray/backends/null_backend.py b/tray/lilith_tray/backends/null_backend.py new file mode 100644 index 0000000..fd5fd3c --- /dev/null +++ b/tray/lilith_tray/backends/null_backend.py @@ -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 diff --git a/tray/lilith_tray/backends/rumps_backend.py b/tray/lilith_tray/backends/rumps_backend.py new file mode 100644 index 0000000..6f110f3 --- /dev/null +++ b/tray/lilith_tray/backends/rumps_backend.py @@ -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) diff --git a/tray/lilith_tray/base.py b/tray/lilith_tray/base.py new file mode 100644 index 0000000..61b5fcb --- /dev/null +++ b/tray/lilith_tray/base.py @@ -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() diff --git a/tray/lilith_tray/board.py b/tray/lilith_tray/board.py new file mode 100644 index 0000000..dbab5c1 --- /dev/null +++ b/tray/lilith_tray/board.py @@ -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"{section.title}") + 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() diff --git a/tray/lilith_tray/palette.py b/tray/lilith_tray/palette.py new file mode 100644 index 0000000..1e512b5 --- /dev/null +++ b/tray/lilith_tray/palette.py @@ -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 diff --git a/tray/lilith_tray/types.py b/tray/lilith_tray/types.py new file mode 100644 index 0000000..31161c7 --- /dev/null +++ b/tray/lilith_tray/types.py @@ -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 diff --git a/tray/requirements.txt b/tray/requirements.txt new file mode 100644 index 0000000..419cb14 --- /dev/null +++ b/tray/requirements.txt @@ -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 diff --git a/tray/vpn-toggle.applescript b/tray/vpn-toggle.applescript new file mode 100644 index 0000000..f4b8f58 --- /dev/null +++ b/tray/vpn-toggle.applescript @@ -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 diff --git a/tray/vpn-tray b/tray/vpn-tray new file mode 100755 index 0000000..1509c8f --- /dev/null +++ b/tray/vpn-tray @@ -0,0 +1,5 @@ +#!/bin/bash +# WireGuard VPN Tray launcher +cd "$(dirname "$0")" +source .venv/bin/activate +exec python vpn_tray.py "$@" diff --git a/tray/vpn_tray.py b/tray/vpn_tray.py new file mode 100755 index 0000000..a24a23b --- /dev/null +++ b/tray/vpn_tray.py @@ -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()