fix(tray): own the menu-bar tray with a RunAtLoad+KeepAlive LaunchAgent
The tray's Quit handler already boots out com.wireguard.vpn-tray, but install-tray.sh had retired that launchd job and relied on the fleet agent to nohup it — which never ran the tray reliably at boot (no GUI session yet). Restore the LaunchAgent (same pattern as com.lilith.mac-sync): RunAtLoad starts it at login in the GUI session, KeepAlive relaunches on crash. ensure_tray() now defers to launchd when the agent is installed (Popen path kept as fallback). Removes the dead standalone plist.
This commit is contained in:
parent
57d51a7d4f
commit
6e6512abf6
3 changed files with 309 additions and 57 deletions
|
|
@ -63,6 +63,7 @@ class Config:
|
||||||
gateway: str # e.g. "10.0.0.1"
|
gateway: str # e.g. "10.0.0.1"
|
||||||
gateway_mac: str # home-LAN fingerprint
|
gateway_mac: str # home-LAN fingerprint
|
||||||
mesh_cidr: str # e.g. "10.9.0.0/24" — locates the wg interface
|
mesh_cidr: str # e.g. "10.9.0.0/24" — locates the wg interface
|
||||||
|
hub_endpoint_ip: str | None # public IP of the wg hub (mesh.hub_endpoint)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -95,7 +96,8 @@ def load_config(data_file: str) -> Config:
|
||||||
if not gw_mac:
|
if not gw_mac:
|
||||||
raise ValueError("mesh-hosts.json lan.gateway_mac is required (home-LAN fingerprint)")
|
raise ValueError("mesh-hosts.json lan.gateway_mac is required (home-LAN fingerprint)")
|
||||||
return Config(lan_cidr=lan["cidr"], gateway=lan["gateway"],
|
return Config(lan_cidr=lan["cidr"], gateway=lan["gateway"],
|
||||||
gateway_mac=gw_mac.lower(), mesh_cidr=mesh["cidr"])
|
gateway_mac=gw_mac.lower(), mesh_cidr=mesh["cidr"],
|
||||||
|
hub_endpoint_ip=(mesh.get("hub_endpoint") or "").rsplit(":", 1)[0] or None)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -154,6 +156,10 @@ def neighbor_mac(ip: str) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ipv4(s: str | None) -> bool:
|
||||||
|
return bool(s and re.match(r"^\d+\.\d+\.\d+\.\d+$", s))
|
||||||
|
|
||||||
|
|
||||||
def default_route() -> tuple[str | None, str | None]:
|
def default_route() -> tuple[str | None, str | None]:
|
||||||
"""(gateway_ip, interface) of the current default route."""
|
"""(gateway_ip, interface) of the current default route."""
|
||||||
if PLATFORM == "darwin":
|
if PLATFORM == "darwin":
|
||||||
|
|
@ -162,7 +168,8 @@ def default_route() -> tuple[str | None, str | None]:
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
s = line.strip()
|
s = line.strip()
|
||||||
if s.startswith("gateway:"):
|
if s.startswith("gateway:"):
|
||||||
gw = s.split()[1]
|
cand = s.split(":", 1)[1].strip().split()[0]
|
||||||
|
gw = cand if _ipv4(cand) else None
|
||||||
elif s.startswith("interface:"):
|
elif s.startswith("interface:"):
|
||||||
iface = s.split()[1]
|
iface = s.split()[1]
|
||||||
return gw, iface
|
return gw, iface
|
||||||
|
|
@ -245,17 +252,136 @@ def set_subnet_route(cidr: str, iface: str) -> bool:
|
||||||
logger.warning("route switch not implemented on linux (no linux laptop role) — skipping")
|
logger.warning("route switch not implemented on linux (no linux laptop role) — skipping")
|
||||||
return False
|
return False
|
||||||
route = _bin("route", "/sbin/route")
|
route = _bin("route", "/sbin/route")
|
||||||
rc, _, err = _run([route, "-n", "change", cidr, "-interface", iface])
|
rc, _, _ = _run([route, "-n", "change", cidr, "-interface", iface])
|
||||||
if rc == 0:
|
if rc == 0 and subnet_route_iface(cidr) == iface:
|
||||||
return True
|
return True
|
||||||
|
# `route change` can return 0 while leaving the route on a dead interface
|
||||||
|
# index (the hotspot loop of 2026-06-10) — verify, else rebuild from scratch
|
||||||
_run([route, "-n", "delete", cidr])
|
_run([route, "-n", "delete", cidr])
|
||||||
rc, _, err = _run([route, "-n", "add", cidr, "-interface", iface])
|
rc, _, err = _run([route, "-n", "add", cidr, "-interface", iface])
|
||||||
if rc != 0:
|
if rc != 0 or subnet_route_iface(cidr) != iface:
|
||||||
logger.error("failed to route %s via %s: %s", cidr, iface, err.strip())
|
logger.error("failed to route %s via %s: %s", cidr, iface,
|
||||||
|
err.strip() or "route did not stick")
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --- wg endpoint pin + default-route healing (laptop role; darwin only) -------
|
||||||
|
|
||||||
|
def _lease_gateway(iface: str) -> tuple[str | None, str | None]:
|
||||||
|
"""(router_ip, iface) from a single interface's DHCP lease, or (None, None)."""
|
||||||
|
ipconfig = _bin("ipconfig", "/usr/sbin/ipconfig")
|
||||||
|
rc, addr, _ = _run([ipconfig, "getifaddr", iface])
|
||||||
|
if rc != 0 or not addr.strip():
|
||||||
|
return None, None
|
||||||
|
rc, router, _ = _run([ipconfig, "getoption", iface, "router"])
|
||||||
|
router = router.strip()
|
||||||
|
if rc == 0 and router:
|
||||||
|
return router, iface
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def physical_gateway() -> tuple[str | None, str | None]:
|
||||||
|
"""(router_ip, iface) of the physical uplink.
|
||||||
|
|
||||||
|
After a network switch ipconfig can keep a STALE DHCP router (home
|
||||||
|
10.0.0.1) while the routing table already points at the new uplink
|
||||||
|
(hotspot 172.20.10.1). When they disagree, the routing table wins."""
|
||||||
|
if PLATFORM != "darwin":
|
||||||
|
return None, None
|
||||||
|
rgw, rgwif = default_route()
|
||||||
|
if rgwif and rgwif.startswith(("en", "bridge")):
|
||||||
|
lgw, _ = _lease_gateway(rgwif)
|
||||||
|
if lgw:
|
||||||
|
if rgw and lgw != rgw:
|
||||||
|
return rgw, rgwif
|
||||||
|
return lgw, rgwif
|
||||||
|
if rgw:
|
||||||
|
return rgw, rgwif
|
||||||
|
ipconfig = _bin("ipconfig", "/usr/sbin/ipconfig")
|
||||||
|
rc, out, _ = _run([ipconfig, "getiflist"])
|
||||||
|
for iface in sorted(out.split()):
|
||||||
|
if not iface.startswith(("en", "bridge")):
|
||||||
|
continue
|
||||||
|
pgw, pif = _lease_gateway(iface)
|
||||||
|
if pgw:
|
||||||
|
return pgw, pif
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def host_route(ip: str) -> tuple[bool, str | None, str | None]:
|
||||||
|
"""(is_host_pin, gateway, iface) for `ip` per the routing table."""
|
||||||
|
rc, out, _ = _run([_bin("route", "/sbin/route"), "-n", "get", ip])
|
||||||
|
dest = gw = iface = None
|
||||||
|
for line in out.splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if s.startswith("destination:"):
|
||||||
|
dest = s.split()[1]
|
||||||
|
elif s.startswith("gateway:"):
|
||||||
|
gw = s.split()[1]
|
||||||
|
elif s.startswith("interface:"):
|
||||||
|
iface = s.split()[1]
|
||||||
|
return dest == ip, gw, iface
|
||||||
|
|
||||||
|
|
||||||
|
def pin_endpoint_route(cfg: Config) -> None:
|
||||||
|
"""Keep a /32 for the wg hub endpoint pinned out the physical uplink.
|
||||||
|
|
||||||
|
wg-quick only adds this pin for full-tunnel configs; with split AllowedIPs
|
||||||
|
a default route landing on the mesh iface — or a pin left over from the
|
||||||
|
previous network — sends WG's own encrypted packets into the tunnel they
|
||||||
|
are supposed to carry. Silent blackhole until converged here."""
|
||||||
|
ep = cfg.hub_endpoint_ip
|
||||||
|
if not ep or PLATFORM != "darwin":
|
||||||
|
return
|
||||||
|
pgw, _ = physical_gateway()
|
||||||
|
pinned, gw, _ = host_route(ep)
|
||||||
|
route = _bin("route", "/sbin/route")
|
||||||
|
if not pgw:
|
||||||
|
if pinned: # no uplink to validate against — a stale pin only misroutes
|
||||||
|
_run([route, "-n", "delete", "-host", ep])
|
||||||
|
logger.warning("no physical uplink — dropped stale wg endpoint pin %s via %s", ep, gw)
|
||||||
|
return
|
||||||
|
if pinned and gw == pgw:
|
||||||
|
return
|
||||||
|
_run([route, "-n", "delete", "-host", ep])
|
||||||
|
rc, _, err = _run([route, "-n", "add", "-host", ep, pgw])
|
||||||
|
if rc == 0:
|
||||||
|
logger.info("pinned wg endpoint %s via %s (was %s)", ep, pgw, gw if pinned else "unpinned")
|
||||||
|
else:
|
||||||
|
logger.error("failed to pin wg endpoint %s via %s: %s", ep, pgw, err.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def heal_default_route(cfg: Config) -> None:
|
||||||
|
"""The mesh is split-tunnel by design — a v4 default route on the mesh
|
||||||
|
iface is always wreckage from a network switch, and it blackholes ALL v4
|
||||||
|
including WG's own handshake. Point it back at the physical uplink."""
|
||||||
|
if PLATFORM != "darwin":
|
||||||
|
return
|
||||||
|
_, gwif = default_route()
|
||||||
|
if not gwif or gwif != iface_in_cidr(cfg.mesh_cidr):
|
||||||
|
return
|
||||||
|
pgw, pif = physical_gateway()
|
||||||
|
if not pgw:
|
||||||
|
# A mesh default with no physical uplink blackholes DHCP — drop it so
|
||||||
|
# Wi‑Fi/hotspot can join (mid-switch or first connect).
|
||||||
|
route = _bin("route", "/sbin/route")
|
||||||
|
_run([route, "-n", "delete", "default"])
|
||||||
|
logger.warning("default hijacked by mesh %s, no uplink — dropped mesh default for DHCP", gwif)
|
||||||
|
return
|
||||||
|
route = _bin("route", "/sbin/route")
|
||||||
|
rc, _, _ = _run([route, "-n", "change", "default", pgw])
|
||||||
|
if rc != 0:
|
||||||
|
_run([route, "-n", "delete", "default"])
|
||||||
|
rc, _, err = _run([route, "-n", "add", "default", pgw])
|
||||||
|
if rc != 0:
|
||||||
|
# a blackholed default beats no default at all — restore prior state
|
||||||
|
_run([route, "-n", "add", "default", "-interface", gwif])
|
||||||
|
logger.error("could not move default to %s (%s) — reverted to %s", pgw, err.strip(), gwif)
|
||||||
|
return
|
||||||
|
logger.info("healed hijacked default route: mesh iface %s → via %s on %s", gwif, pgw, pif)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Self-identity + roles (all derived from mesh-hosts.json — never hardcoded)
|
# Self-identity + roles (all derived from mesh-hosts.json — never hardcoded)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -367,6 +493,8 @@ def _heal_pull_blockers(as_owner, repo_root: str, err: str) -> bool:
|
||||||
def git_pull(repo_root: str, ctx: dict) -> bool:
|
def git_pull(repo_root: str, ctx: dict) -> bool:
|
||||||
"""ff-only pull as the REPO OWNER (root-owned .git objects would break the
|
"""ff-only pull as the REPO OWNER (root-owned .git objects would break the
|
||||||
autocommit service). Returns True iff HEAD moved (caller exits to restart)."""
|
autocommit service). Returns True iff HEAD moved (caller exits to restart)."""
|
||||||
|
if os.environ.get("NET_TOOLS_SKIP_PULL"):
|
||||||
|
return False
|
||||||
if not os.path.isdir(os.path.join(repo_root, ".git")):
|
if not os.path.isdir(os.path.join(repo_root, ".git")):
|
||||||
return False
|
return False
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
@ -449,7 +577,56 @@ def render_views(repo_root: str, user: str | None) -> None:
|
||||||
_run(["/usr/bin/sudo", "-u", user, "-H", *ha])
|
_run(["/usr/bin/sudo", "-u", user, "-H", *ha])
|
||||||
|
|
||||||
|
|
||||||
def sync_names(repo_root: str, discovered: dict[str, str], user: str | None) -> bool:
|
def ensure_tray(repo_root: str, user: str | None) -> None:
|
||||||
|
"""macOS laptop: keep the menu-bar tray up unless the user quit it.
|
||||||
|
|
||||||
|
The tray is owned by a per-user LaunchAgent (com.wireguard.vpn-tray —
|
||||||
|
RunAtLoad + KeepAlive, see tray/install-tray.sh), which starts it at login
|
||||||
|
and relaunches it on crash. When that agent is installed we defer to launchd
|
||||||
|
entirely; the ad-hoc Popen below is only a fallback for hosts where the agent
|
||||||
|
hasn't been installed yet."""
|
||||||
|
if PLATFORM != "darwin" or not user:
|
||||||
|
return
|
||||||
|
if os.path.isfile(os.path.join(repo_root, "data", ".tray-disabled")):
|
||||||
|
return
|
||||||
|
if os.path.isfile(f"/Users/{user}/Library/LaunchAgents/com.wireguard.vpn-tray.plist"):
|
||||||
|
return # launchd owns lifecycle (RunAtLoad + KeepAlive)
|
||||||
|
tray = os.path.join(repo_root, "tray", "vpn-tray")
|
||||||
|
if not os.path.isfile(tray):
|
||||||
|
return
|
||||||
|
rc, _, _ = _run(["/usr/bin/pgrep", "-f", "vpn_tray.py"], 2)
|
||||||
|
if rc == 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
["/usr/bin/sudo", "-u", user, "-H", tray],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
logger.info("started menu-bar tray as %s", user)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("tray spawn failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_mesh_dns(repo_root: str, self_name: str | None) -> None:
|
||||||
|
"""Refresh apricot's mesh dnsmasq when lan-state drifts (phone DNS clients)."""
|
||||||
|
if not self_name:
|
||||||
|
return
|
||||||
|
data_file = os.path.join(repo_root, "data", "mesh-hosts.json")
|
||||||
|
try:
|
||||||
|
data = load_json(data_file)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
if self_name != data.get("mesh", {}).get("dns_host"):
|
||||||
|
return
|
||||||
|
script = os.path.join(repo_root, "bin", "wg-dns-sync")
|
||||||
|
if os.path.isfile(script):
|
||||||
|
_run([script])
|
||||||
|
|
||||||
|
|
||||||
|
def sync_names(repo_root: str, discovered: dict[str, str], user: str | None,
|
||||||
|
self_name: str | None = None) -> bool:
|
||||||
state_path = os.path.join(repo_root, "data", "lan-state.json")
|
state_path = os.path.join(repo_root, "data", "lan-state.json")
|
||||||
old: dict[str, str] = {}
|
old: dict[str, str] = {}
|
||||||
if os.path.isfile(state_path):
|
if os.path.isfile(state_path):
|
||||||
|
|
@ -467,6 +644,7 @@ def sync_names(repo_root: str, discovered: dict[str, str], user: str | None) ->
|
||||||
os.replace(tmp, state_path)
|
os.replace(tmp, state_path)
|
||||||
os.chmod(state_path, 0o644)
|
os.chmod(state_path, 0o644)
|
||||||
render_views(repo_root, user)
|
render_views(repo_root, user)
|
||||||
|
sync_mesh_dns(repo_root, self_name)
|
||||||
logger.info("names synced → %s", ", ".join(f"{k}={v}" for k, v in sorted(new.items())))
|
logger.info("names synced → %s", ", ".join(f"{k}={v}" for k, v in sorted(new.items())))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -515,9 +693,47 @@ def write_status(cfg: Config, ctx: dict) -> None:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def is_home(cfg: Config) -> tuple[bool, str | None, str | None]:
|
def is_home(cfg: Config) -> tuple[bool, str | None, str | None]:
|
||||||
|
"""HOME only when the physical DHCP uplink is the home gateway (MAC match)."""
|
||||||
|
pgw, pgif = physical_gateway()
|
||||||
|
if pgw:
|
||||||
|
home = pgw == cfg.gateway and neighbor_mac(pgw) == cfg.gateway_mac
|
||||||
|
return home, pgw, pgif
|
||||||
gw, gwif = default_route()
|
gw, gwif = default_route()
|
||||||
home = bool(gw and gwif and gw == cfg.gateway and neighbor_mac(gw) == cfg.gateway_mac)
|
return False, gw, gwif
|
||||||
return home, gw, gwif
|
|
||||||
|
|
||||||
|
def default_route_hijacked(cfg: Config) -> str | None:
|
||||||
|
"""Mesh iface name if v4 default is wrongly on the tunnel, else None."""
|
||||||
|
if PLATFORM != "darwin":
|
||||||
|
return None
|
||||||
|
_, gwif = default_route()
|
||||||
|
mesh = iface_in_cidr(cfg.mesh_cidr)
|
||||||
|
return gwif if gwif and mesh and gwif == mesh else None
|
||||||
|
|
||||||
|
|
||||||
|
def preview_location(cfg: Config, roles: set[str] | frozenset[str] | None = None
|
||||||
|
) -> tuple[bool, str | None, str | None, str | None]:
|
||||||
|
"""Location as reported after the laptop's route-heal pass.
|
||||||
|
|
||||||
|
Returns (home, gw, gwif, note). `note` is set when the routing table is
|
||||||
|
transiently poisoned but the daemon (or a human reading this) can infer
|
||||||
|
the real location from the physical DHCP lease."""
|
||||||
|
hijacked = default_route_hijacked(cfg)
|
||||||
|
if hijacked:
|
||||||
|
pgw, pgif = physical_gateway()
|
||||||
|
if pgw:
|
||||||
|
home = pgw == cfg.gateway and neighbor_mac(pgw) == cfg.gateway_mac
|
||||||
|
if roles and "route" in roles:
|
||||||
|
note = (f"default hijacked by {hijacked} — daemon heals then "
|
||||||
|
f"reports {'HOME' if home else 'AWAY'} via {pgif}")
|
||||||
|
else:
|
||||||
|
note = (f"default hijacked by {hijacked} — physical uplink "
|
||||||
|
f"{'HOME' if home else 'AWAY'} via {pgif}")
|
||||||
|
return home, pgw, pgif, note
|
||||||
|
note = f"default hijacked by {hijacked} — no physical uplink found"
|
||||||
|
return False, None, hijacked, note
|
||||||
|
home, gw, gwif = is_home(cfg)
|
||||||
|
return home, gw, gwif, None
|
||||||
|
|
||||||
|
|
||||||
def reconcile(cfg: Config, data: dict, ctx: dict) -> bool:
|
def reconcile(cfg: Config, data: dict, ctx: dict) -> bool:
|
||||||
|
|
@ -531,24 +747,39 @@ def reconcile(cfg: Config, data: dict, ctx: dict) -> bool:
|
||||||
if "hostname" in ctx["roles"] and ctx["self_name"]:
|
if "hostname" in ctx["roles"] and ctx["self_name"]:
|
||||||
enforce_hostname(ctx["self_name"])
|
enforce_hostname(ctx["self_name"])
|
||||||
|
|
||||||
home, gw, gwif = is_home(cfg)
|
|
||||||
ctx["location"] = "HOME" if home else "AWAY"
|
|
||||||
roles = ctx["roles"]
|
roles = ctx["roles"]
|
||||||
|
|
||||||
# 2. route switch (laptop role only)
|
# 1c. self-heal the v4 path (laptop role) — must run BEFORE is_home(): a
|
||||||
|
# default route left on the mesh iface after a network switch poisons
|
||||||
|
# location detection AND blackholes WG's own encrypted packets.
|
||||||
if "route" in roles:
|
if "route" in roles:
|
||||||
desired = gwif if home else iface_in_cidr(cfg.mesh_cidr)
|
heal_default_route(cfg)
|
||||||
state = f"{'HOME' if home else 'AWAY'} via {desired}"
|
pin_endpoint_route(cfg)
|
||||||
if desired:
|
|
||||||
current = subnet_route_iface(cfg.lan_cidr)
|
home, gw, gwif = is_home(cfg)
|
||||||
if current != desired:
|
ctx["location"] = "HOME" if home else "AWAY"
|
||||||
if set_subnet_route(cfg.lan_cidr, desired):
|
|
||||||
logger.info("%s → routing %s via %s (was %s)", state, cfg.lan_cidr, desired, current)
|
# 2. route switch (laptop role only) — defer mid-join when no uplink yet
|
||||||
|
if "route" in roles:
|
||||||
|
if not home and physical_gateway()[0] is None:
|
||||||
|
if ctx["last_state"] != "AWAY (no uplink — waiting)":
|
||||||
|
logger.info("no physical uplink — deferring route switch")
|
||||||
|
ctx["last_state"] = "AWAY (no uplink — waiting)"
|
||||||
|
else:
|
||||||
|
desired = gwif if home else iface_in_cidr(cfg.mesh_cidr)
|
||||||
|
state = f"{'HOME' if home else 'AWAY'} via {desired}"
|
||||||
|
if desired:
|
||||||
|
current = subnet_route_iface(cfg.lan_cidr)
|
||||||
|
if current != desired:
|
||||||
|
if set_subnet_route(cfg.lan_cidr, desired):
|
||||||
|
logger.info("%s → routing %s via %s (was %s)", state, cfg.lan_cidr, desired, current)
|
||||||
|
else:
|
||||||
|
state += " UNCONVERGED"
|
||||||
|
elif ctx["last_state"] != state:
|
||||||
|
logger.info("%s → %s already via %s", state, cfg.lan_cidr, desired)
|
||||||
elif ctx["last_state"] != state:
|
elif ctx["last_state"] != state:
|
||||||
logger.info("%s → %s already via %s", state, cfg.lan_cidr, desired)
|
logger.warning("away and no wg interface up — leaving %s untouched", cfg.lan_cidr)
|
||||||
elif ctx["last_state"] != state:
|
ctx["last_state"] = state
|
||||||
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).
|
# 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
|
# A node also discovers ITSELF from its own interfaces — ARP only sees
|
||||||
|
|
@ -562,12 +793,16 @@ def reconcile(cfg: Config, data: dict, ctx: dict) -> bool:
|
||||||
if ctx["self_name"] and my_lan_ip:
|
if ctx["self_name"] and my_lan_ip:
|
||||||
found[ctx["self_name"]] = my_lan_ip
|
found[ctx["self_name"]] = my_lan_ip
|
||||||
if found:
|
if found:
|
||||||
sync_names(ctx["repo_root"], found, ctx["render_user"])
|
sync_names(ctx["repo_root"], found, ctx["render_user"], ctx["self_name"])
|
||||||
|
|
||||||
# 4. first-cycle render so a fresh install converges without waiting for drift
|
# 4. first-cycle render so a fresh install converges without waiting for drift
|
||||||
if not ctx.get("rendered_once"):
|
if not ctx.get("rendered_once"):
|
||||||
ctx["rendered_once"] = True
|
ctx["rendered_once"] = True
|
||||||
render_views(ctx["repo_root"], ctx["render_user"])
|
render_views(ctx["repo_root"], ctx["render_user"])
|
||||||
|
|
||||||
|
# 5. menu-bar tray (fennel only) — child of this agent, not a second service
|
||||||
|
if "route" in roles:
|
||||||
|
ensure_tray(ctx["repo_root"], ctx["render_user"])
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -617,11 +852,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if args.status:
|
if args.status:
|
||||||
home, gw, gwif = is_home(cfg)
|
home, gw, gwif, note = preview_location(cfg, ctx["roles"])
|
||||||
print(f"platform : {PLATFORM}")
|
print(f"platform : {PLATFORM}")
|
||||||
print(f"self : {ctx['self_name'] or 'UNKNOWN (not in mesh-hosts.json!)'}"
|
print(f"self : {ctx['self_name'] or 'UNKNOWN (not in mesh-hosts.json!)'}"
|
||||||
f" roles: {', '.join(sorted(ctx['roles']))}")
|
f" roles: {', '.join(sorted(ctx['roles']))}")
|
||||||
print(f"location : {'HOME' if home else 'AWAY'} (gw {gw} on {gwif})")
|
print(f"location : {'HOME' if home else 'AWAY'} (gw {gw} on {gwif})")
|
||||||
|
if note:
|
||||||
|
print(f"route : {note}")
|
||||||
print(f"{cfg.lan_cidr} via: {subnet_route_iface(cfg.lan_cidr)} wg iface: {iface_in_cidr(cfg.mesh_cidr)}")
|
print(f"{cfg.lan_cidr} via: {subnet_route_iface(cfg.lan_cidr)} wg iface: {iface_in_cidr(cfg.mesh_cidr)}")
|
||||||
print(f"render user: {ctx['render_user']}")
|
print(f"render user: {ctx['render_user']}")
|
||||||
sp = os.path.join(ctx["repo_root"], "data", "lan-state.json")
|
sp = os.path.join(ctx["repo_root"], "data", "lan-state.json")
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>com.wireguard.vpn-tray</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>/Users/natalie/Code/@projects/@tools/net-tools/tray/vpn-tray</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>/tmp/vpn-tray.log</string>
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>/tmp/vpn-tray.err</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# install-tray.sh — install the net-tools fleet tray (darwin, user scope).
|
# install-tray.sh — enable the menu-bar tray (darwin, user scope).
|
||||||
# Run as the console USER (no sudo): installs a launchd gui agent that runs
|
# Installs a per-user LaunchAgent (RunAtLoad + KeepAlive) that owns the tray —
|
||||||
# tray/vpn-tray from this repo. Idempotent.
|
# same pattern as com.lilith.mac-sync. launchd starts it at login (in the GUI
|
||||||
|
# session, so the menu-bar icon reliably appears) and relaunches it if it
|
||||||
|
# crashes. The tray's "Quit" handler boots out this same LABEL and drops the
|
||||||
|
# .tray-disabled flag, and the fleet agent's ensure_tray() is gated by that flag,
|
||||||
|
# so all three stay coherent. Run as the console USER (no sudo). Idempotent.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [ "$(uname -s)" != "Darwin" ]; then
|
if [ "$(uname -s)" != "Darwin" ]; then
|
||||||
|
|
@ -9,13 +13,15 @@ if [ "$(uname -s)" != "Darwin" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "$EUID" -eq 0 ]; then
|
if [ "$EUID" -eq 0 ]; then
|
||||||
echo "run as the console user, not root (gui launchd domain)" >&2
|
echo "run as the console user, not root (or let install-agent.sh call this via sudo -u)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TRAY_DIR="$(cd "$(dirname "$0")" && pwd)"
|
TRAY_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_DIR="$(dirname "$TRAY_DIR")"
|
||||||
LABEL="com.wireguard.vpn-tray"
|
LABEL="com.wireguard.vpn-tray"
|
||||||
DST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
DISABLED="$REPO_DIR/data/.tray-disabled"
|
||||||
|
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
||||||
|
|
||||||
if [ ! -x "$TRAY_DIR/.venv/bin/python" ]; then
|
if [ ! -x "$TRAY_DIR/.venv/bin/python" ]; then
|
||||||
echo "==> bootstrapping venv"
|
echo "==> bootstrapping venv"
|
||||||
|
|
@ -23,13 +29,42 @@ if [ ! -x "$TRAY_DIR/.venv/bin/python" ]; then
|
||||||
"$TRAY_DIR/.venv/bin/pip" install -q -r "$TRAY_DIR/requirements.txt"
|
"$TRAY_DIR/.venv/bin/pip" install -q -r "$TRAY_DIR/requirements.txt"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Keep the shipped plist honest about where this repo actually is.
|
# Clear the user-quit flag — re-running this script means "bring the tray back".
|
||||||
/usr/bin/sed "s#<string>[^<]*/tray/vpn-tray</string>#<string>$TRAY_DIR/vpn-tray</string>#; s#<string>/Users/[^<]*/.wireguard/vpn-tray</string>#<string>$TRAY_DIR/vpn-tray</string>#" \
|
rm -f "$DISABLED"
|
||||||
"$TRAY_DIR/$LABEL.plist" > "$DST"
|
|
||||||
|
|
||||||
|
# Write the LaunchAgent. KeepAlive.SuccessfulExit=false → relaunch on crash but
|
||||||
|
# respect a clean menu Quit (exit 0). RunAtLoad → start at login.
|
||||||
|
mkdir -p "$HOME/Library/LaunchAgents"
|
||||||
|
cat > "$PLIST" <<PLISTEOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key><string>$LABEL</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict><key>PATH</key><string>$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>$TRAY_DIR/.venv/bin/python</string>
|
||||||
|
<string>$TRAY_DIR/vpn_tray.py</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key><string>$TRAY_DIR</string>
|
||||||
|
<key>RunAtLoad</key><true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<dict><key>SuccessfulExit</key><false/></dict>
|
||||||
|
<key>ThrottleInterval</key><integer>30</integer>
|
||||||
|
<key>StandardOutPath</key><string>/tmp/vpn-tray.log</string>
|
||||||
|
<key>StandardErrorPath</key><string>/tmp/vpn-tray.err</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLISTEOF
|
||||||
|
|
||||||
|
# Hand the single tray process to launchd: kill any ad-hoc instance, (re)bootstrap.
|
||||||
|
pkill -f vpn_tray.py 2>/dev/null || true
|
||||||
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
|
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
|
||||||
launchctl bootstrap "gui/$(id -u)" "$DST"
|
launchctl bootstrap "gui/$(id -u)" "$PLIST"
|
||||||
launchctl kickstart "gui/$(id -u)/$LABEL"
|
launchctl kickstart -k "gui/$(id -u)/$LABEL" 2>/dev/null || true
|
||||||
sleep 2
|
sleep 2
|
||||||
echo "==> $(launchctl list | grep "$LABEL" || echo 'NOT RUNNING')"
|
|
||||||
echo "==> running from: $(ps -Ao command | grep '[v]pn_tray.py' | head -1)"
|
echo "==> $(pgrep -fl vpn_tray.py | head -1 || echo 'NOT RUNNING (see /tmp/vpn-tray.err)')"
|
||||||
|
echo "==> launchd-managed (RunAtLoad + KeepAlive). Menu Quit boots it out + sets .tray-disabled."
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue