From b78288640d8c13cc0fb3f4ee7c82f3efac33940f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 4 Sep 2026 17:29:24 +0200 Subject: feat: opt-in Windows service mode (boot-time, one elevation) + v1.0.0 The per-user Startup-folder launcher (W3) only ever runs after this user signs in. A real Windows Service would start earlier, but under LocalSystem/NetworkService -- accounts with no normal profile, so %LOCALAPPDATA%\meshbay\ (config, keystore, data) would not exist for it. Relocating storage to make that work is real surgery, deliberately not done here. Instead: a Scheduled Task, created once with admin rights, that runs AS THIS USER at boot without needing them to sign in first. `schtasks /create ... /ru /rp ""` with no `/it` registers an S4U (Service For User) logon -- no password stored anywhere, and unlike LocalSystem it loads this account's own profile, so config_dir()/ data_dir() need zero changes. The cost: S4U carries no network credential, which the node never needed -- everything it touches is local disk plus outbound internet. Creating the task needs admin (a boot trigger touches system-wide scheduler state, the same reason /sc onlogon needed it); querying/starting/stopping an existing one does not -- Task Scheduler grants the owning user that much itself, which is what lets the Node page's Start/Stop/Restart drive it with no further UAC prompts. meshbay_node/platform.py service_install/_remove/_status/_run/_end -- mirrors autostart_* but for the Scheduled Task; TASK_NAME moved here (was decorative before) meshbay_node/daemon.py new `service install|remove|start|stop|status` verb; restart-daemon and reset now check for the service task too packaging/win/service.ps1 the installer-side equivalent (extraResource); status/run/end never self-elevate -- only install/remove do, exactly matching what Task Scheduler itself requires packaging/win/service-mode.ps1 ONE elevated helper running service.ps1 + firewall.ps1 together, so choosing service mode costs exactly one UAC prompt, not two build/installer.nsh the install-time choice: "run as a background service?" (one elevation, both jobs) vs the existing per-user + separate firewall question. Checked first, unelevated, so re-running setup with everything already configured asks nothing. Uninstall offers the matching one-elevation cleanup, default No. src/main.js winServiceTaskStatus/Run/End, wired into node:installed, node:service-status/-stop/-restart and node:start: when the Scheduled Task exists, drive it; otherwise fall back to the existing per-user spawn/kill path. This is the hard requirement -- Start/Stop/Restart from the Node page must work in either mode. node-page.js / locales a hint explaining why the per-user autostart toggle is absent when service mode is active (info.mode from the backend, no new field to gate on -- it just isn't sent in that case) package.json: 0.1.0 -> 1.0.0. Verified: electron-builder compiles the new NSIS choice logic and ships all three scripts; service.ps1's S4U install fails cleanly (Access denied) when run unelevated, and its status/run/end never touch "runas". Cannot verify the elevated success path myself (no admin in this session) -- that needs a real UAC click. Node suite 843 pass / 25 skip; test_packaging_win.py pins the one-elevation property, the S4U flags, and that main.js actually checks the service task in all three handlers. Co-Authored-By: Claude Sonnet 5 --- packages/meshbay-node/src/meshbay_node/daemon.py | 65 +++++++++++++++++++++--- 1 file changed, 59 insertions(+), 6 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py') diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 37a2472..b932c16 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -1649,7 +1649,7 @@ def main() -> None: choices=["init", "reset", "status", "gek-init", "gek", "operator", "member", "group", "file", "video", "denylist", "stun", "reload", - "restart-daemon", "autostart", + "restart-daemon", "autostart", "service", "calibrate-argon2"], help="init: provision config + keystore | reset: erase all " "node state | status: node state and keys " @@ -1661,16 +1661,21 @@ def main() -> None: "| stun list|add|remove|reset " "| reload: re-read node.toml (hot; systemd or the " "loopback API) | restart-daemon: restart the node " - "(systemd unit, or the Windows autostart launcher) " + "(systemd unit, the Windows autostart launcher, or the " + "service task, whichever applies) " "| autostart install|remove|start|stop|status " - "(Windows: run meshbay-node at each sign-in) " + "(Windows: run meshbay-node at each sign-in, no admin) " + "| service install|remove|start|stop|status " + "(Windows: run at boot, before sign-in, needs admin " + "once to install) " "| calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", help="'pair' for operator; list|invite|revoke|unpin for " "member; list|add|remove for group; init|rotate for gek; " "list|rm for file; rematch for video; show|clear for " "denylist; list|add|remove|reset for stun; " - "install|remove|start|stop|status for autostart") + "install|remove|start|stop|status for autostart and " + "for service") parser.add_argument("target", nargs="?", help="username for member invite|revoke|unpin; group name " "for group add; file id for file rm; identifier for " @@ -1845,8 +1850,9 @@ def main() -> None: print("Could not unlink from hub (daemon not reachable).") if sys.platform == "win32": - from meshbay_node.platform import autostart_remove + from meshbay_node.platform import autostart_remove, service_remove autostart_remove() + service_remove() # no-op, silently, if not elevated or not installed else: _sp.run(["systemctl", "--user", "disable", "--now", "meshbay-node"], capture_output=True) @@ -2107,7 +2113,14 @@ def main() -> None: if args.command == "restart-daemon": if sys.platform == "win32": - from meshbay_node.platform import autostart_end, autostart_run + from meshbay_node.platform import ( + autostart_end, autostart_run, service_end, service_run, service_status, + ) + if service_status()["installed"]: + service_end() + service_run() + print("restarted the node (service task)") + return autostart_end() # kill whatever is running now try: autostart_run() @@ -2162,6 +2175,46 @@ def main() -> None: sys.exit(1) return + if args.command == "service": + from meshbay_node import platform as _plat + if sys.platform != "win32": + print("service mode is Windows-only — elsewhere use " + "'systemctl --user enable --now meshbay-node'.") + sys.exit(1) + sub = args.subcommand or "status" + if sub == "install": + try: + _plat.service_install() + except RuntimeError as e: + print(f"Could not install: {e}") + if "denied" in str(e).lower(): + print("Run this from an elevated (Administrator) prompt.") + sys.exit(1) + print(f"Registered the {_plat.TASK_NAME!r} scheduled task — it starts " + "meshbay-node at boot, as this user, whether or not you have " + "signed in yet (no password stored).") + print("Start it now with: meshbay-node service start") + elif sub == "remove": + _plat.service_remove() + print(f"Removed the {_plat.TASK_NAME!r} scheduled task.") + elif sub == "start": + _plat.service_run() + print("started") + elif sub == "stop": + _plat.service_end() + print("stopped") + elif sub == "status": + st = _plat.service_status() + if st["installed"]: + print(f"service installed — {st['state'] or 'unknown state'}") + else: + print("service not installed — meshbay-node service install " + "(needs an elevated prompt)") + else: + print("service: install | remove | start | stop | status") + sys.exit(1) + return + if args.command == "denylist": cfg = load_config(args.config or DEFAULT_CONFIG_PATH) sub = args.subcommand or "show" -- cgit v1.2.3