From e4674e59f1c4af33025ac98db760f6c48f5712fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 23:11:27 +0000 Subject: [PATCH 1/2] service: cross-platform background-service install (no terminal window) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launching Burble currently opens a terminal that scrolls Bolt's udp/7373+9 bind log past the user. Replace that with proper per-OS service units so the control plane runs headless: - Linux/WSL: systemd --user units in assets/services/. Bolt's udp/9 privileged bind is handled via AmbientCapabilities=CAP_NET_BIND_SERVICE. - macOS: launchd LaunchAgents in assets/services/. - Windows host: scripts/wsl-bolt-udp-forward.ps1 -Install now registers the scheduled task to launch the relay WINDOWLESS via a generated VBS shim (wscript.exe + Run "...", 0, False — eliminates the PowerShell console flash that -WindowStyle Hidden alone can't avoid). New -WithTray flag opts into a NotifyIcon system-tray UI (Status / Open log / Restart / Exit) for users who want visibility. Adds scripts/install-service.sh as a one-shot cross-platform installer that detects the OS and dispatches. Justfile gets service-{install, uninstall,start,stop,restart,status,logs}. Relay output is captured to %LOCALAPPDATA%\BurbleBoltFwd\relay.log via a new Write-Relay helper so the windowless install path still leaves a trace. --- CHANGELOG.md | 20 ++ Justfile | 19 ++ assets/services/burble-ai-bridge.service | 28 +++ assets/services/burble.service | 39 ++++ .../com.hyperpolymath.burble.ai-bridge.plist | 43 +++++ .../services/com.hyperpolymath.burble.plist | 55 ++++++ docs/developer/wsl-mirrored-networking.adoc | 24 ++- scripts/install-service.sh | 175 ++++++++++++++++++ scripts/wsl-bolt-udp-forward.ps1 | 162 +++++++++++++--- 9 files changed, 537 insertions(+), 28 deletions(-) create mode 100644 assets/services/burble-ai-bridge.service create mode 100644 assets/services/burble.service create mode 100644 assets/services/com.hyperpolymath.burble.ai-bridge.plist create mode 100644 assets/services/com.hyperpolymath.burble.plist create mode 100755 scripts/install-service.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 974084b..7ccc483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Cross-platform background-service install path so launching Burble no + longer pops a terminal window. New `scripts/install-service.sh install` + detects the OS and installs: + - Linux/WSL: systemd `--user` units (`assets/services/burble.service`, + `burble-ai-bridge.service`) — Bolt's `udp/9` privileged bind handled + via `AmbientCapabilities=CAP_NET_BIND_SERVICE` instead of root. + - macOS: launchd LaunchAgents + (`assets/services/com.hyperpolymath.burble.plist`, + `…ai-bridge.plist`) in `~/Library/LaunchAgents/`. + - Windows host (WSL2 NAT): see the rewritten + `scripts/wsl-bolt-udp-forward.ps1 -Install` — now registers the + scheduled task to launch the relay **windowless** via a VBS shim + (`wsl-bolt-udp-forward.vbs`), so no PowerShell console flashes at + logon. `-WithTray` opts into a `NotifyIcon` system-tray UI (Status / + Open log / Restart / Exit). + Justfile recipes: `service-install`, `service-uninstall`, + `service-start`, `service-stop`, `service-restart`, `service-status`, + `service-logs`. Relay logs land in `%LOCALAPPDATA%\BurbleBoltFwd\relay.log` + on Windows / `journalctl --user -u burble` on Linux / + `/tmp/burble.{out,err}.log` on macOS. - `Burble.TestSupport.SingletonWatcher` in `test/test_helper.exs` — `Process.monitor`s each of 20 app-owned singletons (PubSub, Presence, RoomRegistry/Supervisor, PeerRegistry/Supervisor, CoprocessorRegistry/Supervisor, MessageStore, NNTPSBackend, Media.Engine, Timing.{PTP,ClockCorrelator,Alignment}, Groove + HealthMesh + Feedback, Transport.RTSP, Bolt.Listener, Endpoint), reports any mid-run death (name + pid + reason + ms-since-start) to stderr at suite end, freezes via `ExUnit.after_suite/1` before BEAM shutdown so the normal app-teardown `:DOWN` cascade is not mistaken for instability. Diagnostic for #62 Bucket B; advisory (does not fail CI). ### Changed diff --git a/Justfile b/Justfile index 4901850..290392c 100644 --- a/Justfile +++ b/Justfile @@ -142,6 +142,25 @@ full: server: cd server && mix phx.server +# ─── Background-service install (cross-platform, no terminal window) ────── +# systemd --user on Linux/WSL, launchd LaunchAgent on macOS. Windows host +# users should also run scripts\wsl-bolt-udp-forward.ps1 -Install from a +# Windows PowerShell to forward Bolt udp/7373+9 into WSL — windowless. + +# Install Burble as a background service (no terminal window pops up) +service-install: + scripts/install-service.sh install + +# Remove the Burble background service +service-uninstall: + scripts/install-service.sh uninstall + +service-start: ; scripts/install-service.sh start +service-stop: ; scripts/install-service.sh stop +service-restart: ; scripts/install-service.sh restart +service-status: ; scripts/install-service.sh status +service-logs: ; scripts/install-service.sh logs + # Start the web client dev server client: cd client/web && deno task dev diff --git a/assets/services/burble-ai-bridge.service b/assets/services/burble-ai-bridge.service new file mode 100644 index 0000000..54fcab3 --- /dev/null +++ b/assets/services/burble-ai-bridge.service @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Burble AI bridge — Deno HTTP/WS shim on tcp/6474 + tcp/6475 that lets +# Claude Code talk to the in-browser P2P data channel. Installed as a +# systemd *user* unit by scripts/install-service.sh. +# +# Logs: journalctl --user -u burble-ai-bridge -f + +[Unit] +Description=Burble Claude<->browser bridge (Deno, http :6474 / ws :6475) +Documentation=https://github.com/hyperpolymath/burble +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=@REPO_DIR@ +Environment=LANG=C.UTF-8 +ExecStart=/usr/bin/env deno run --allow-net --allow-env client/web/burble-ai-bridge.js +Restart=on-failure +RestartSec=3 +TimeoutStopSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=burble-ai-bridge + +[Install] +WantedBy=default.target diff --git a/assets/services/burble.service b/assets/services/burble.service new file mode 100644 index 0000000..6dcef2c --- /dev/null +++ b/assets/services/burble.service @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Burble Elixir/Phoenix control-plane service (Bolt udp/7373 + udp/9, voice +# signalling on tcp/4020). Installed as a systemd *user* unit by +# scripts/install-service.sh — the @REPO_DIR@ token is rewritten at install +# time. To install by hand: +# +# sed "s|@REPO_DIR@|$PWD|g" assets/services/burble.service \ +# > ~/.config/systemd/user/burble.service +# systemctl --user daemon-reload +# systemctl --user enable --now burble.service +# +# Logs: journalctl --user -u burble -f + +[Unit] +Description=Burble voice/media control plane (Elixir, Bolt udp/7373+9, http :4020) +Documentation=https://github.com/hyperpolymath/burble +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=@REPO_DIR@/server +Environment=MIX_ENV=dev +Environment=LANG=C.UTF-8 +ExecStart=/usr/bin/env mix phx.server +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 +KillMode=mixed +# Bolt binds udp/9 (WoL-compat poke port) which is privileged. Grant just +# the capability instead of running as root. +AmbientCapabilities=CAP_NET_BIND_SERVICE +StandardOutput=journal +StandardError=journal +SyslogIdentifier=burble + +[Install] +WantedBy=default.target diff --git a/assets/services/com.hyperpolymath.burble.ai-bridge.plist b/assets/services/com.hyperpolymath.burble.ai-bridge.plist new file mode 100644 index 0000000..4ba9cb3 --- /dev/null +++ b/assets/services/com.hyperpolymath.burble.ai-bridge.plist @@ -0,0 +1,43 @@ + + + + + + Label + com.hyperpolymath.burble.ai-bridge + + ProgramArguments + + /usr/bin/env + deno + run + --allow-net + --allow-env + client/web/burble-ai-bridge.js + + + WorkingDirectory + @REPO_DIR@ + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + + StandardOutPath + /tmp/burble-ai-bridge.out.log + StandardErrorPath + /tmp/burble-ai-bridge.err.log + + ProcessType + Background + + diff --git a/assets/services/com.hyperpolymath.burble.plist b/assets/services/com.hyperpolymath.burble.plist new file mode 100644 index 0000000..838cc15 --- /dev/null +++ b/assets/services/com.hyperpolymath.burble.plist @@ -0,0 +1,55 @@ + + + + + + Label + com.hyperpolymath.burble + + ProgramArguments + + /usr/bin/env + mix + phx.server + + + EnvironmentVariables + + MIX_ENV + dev + LANG + C.UTF-8 + + + WorkingDirectory + @REPO_DIR@/server + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + + StandardOutPath + /tmp/burble.out.log + StandardErrorPath + /tmp/burble.err.log + + ProcessType + Background + + diff --git a/docs/developer/wsl-mirrored-networking.adoc b/docs/developer/wsl-mirrored-networking.adoc index e80c0a5..a80c297 100644 --- a/docs/developer/wsl-mirrored-networking.adoc +++ b/docs/developer/wsl-mirrored-networking.adoc @@ -37,17 +37,35 @@ changes and none of the mirrored-mode instability. [source,powershell] ---- -# One-time: register a logon scheduled task (optionally add firewall rules -# from an elevated shell): +# One-time: register a logon scheduled task that launches the relay +# WINDOWLESS via a generated VBS shim (no PowerShell console flashes at +# logon). Optionally add firewall rules from an elevated shell: .\scripts\wsl-bolt-udp-forward.ps1 -Install .\scripts\wsl-bolt-udp-forward.ps1 -Install -Firewall # elevated +.\scripts\wsl-bolt-udp-forward.ps1 -Install -WithTray # add a system-tray icon + # (Status / Open log / Restart / Exit) # Inspect / run in foreground / remove: .\scripts\wsl-bolt-udp-forward.ps1 -Status -.\scripts\wsl-bolt-udp-forward.ps1 -Run +.\scripts\wsl-bolt-udp-forward.ps1 -Run # console, debugging +.\scripts\wsl-bolt-udp-forward.ps1 -Tray # tray-icon, ad-hoc (no scheduled task) .\scripts\wsl-bolt-udp-forward.ps1 -Uninstall ---- +The headless install path appends to `%LOCALAPPDATA%\BurbleBoltFwd\relay.log` +so the relay still leaves a trace even with no console attached. + +Inside WSL itself, install Burble's Elixir control plane as a systemd +`--user` service so launching the project never pops a terminal either: + +[source,bash] +---- +scripts/install-service.sh install # systemd --user (Linux/WSL) or + # launchd LaunchAgent (macOS) +scripts/install-service.sh status +journalctl --user -u burble -f +---- + The relay is bidirectional (per-client ephemeral upstream socket, 30 s idle expiry), so QUIC handshakes and ack datagrams return to the original sender — not only fire-and-forget cold pokes. It re-resolves the WSL IP diff --git a/scripts/install-service.sh b/scripts/install-service.sh new file mode 100755 index 0000000..e10779f --- /dev/null +++ b/scripts/install-service.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# install-service.sh — install Burble as a background service so it stops +# popping a terminal window every time you launch it. +# +# What gets installed (per platform): +# Linux systemd --user units: burble.service + burble-ai-bridge.service +# (`systemctl --user enable --now burble{,-ai-bridge}.service`) +# macOS launchd LaunchAgents in ~/Library/LaunchAgents/ +# (`launchctl bootstrap gui/$UID …`) +# WSL/Windows For the WSL2 NAT case, registers a hidden scheduled task on +# the Windows host via scripts/wsl-bolt-udp-forward.ps1 -Install +# (must be re-run from PowerShell on the host side itself — +# we just print the exact command here). +# +# Usage: +# scripts/install-service.sh install # install + start +# scripts/install-service.sh uninstall # stop + remove +# scripts/install-service.sh start | stop | restart | status | logs +# scripts/install-service.sh install --no-ai-bridge # just the Elixir server + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ACTION="${1:-help}"; shift || true + +INCLUDE_AI_BRIDGE=true +for arg in "$@"; do + case "$arg" in + --no-ai-bridge) INCLUDE_AI_BRIDGE=false ;; + esac +done + +log() { printf '\033[0;32m[burble-service]\033[0m %s\n' "$*"; } +warn() { printf '\033[0;33m[burble-service]\033[0m %s\n' "$*" >&2; } +err() { printf '\033[0;31m[burble-service]\033[0m %s\n' "$*" >&2; } + +detect_os() { + case "$(uname -s)" in + Linux*) if grep -qi microsoft /proc/version 2>/dev/null; then echo "wsl" + else echo "linux"; fi ;; + Darwin*) echo "macos" ;; + *) echo "unknown" ;; + esac +} +OS="$(detect_os)" + +# ─── Linux (systemd --user) ───────────────────────────────────────────────── +SYSTEMD_USER_DIR="$HOME/.config/systemd/user" +LINUX_UNITS=("burble.service") +$INCLUDE_AI_BRIDGE && LINUX_UNITS+=("burble-ai-bridge.service") + +linux_install() { + command -v systemctl >/dev/null || { err "systemctl not found — non-systemd Linux unsupported."; exit 1; } + mkdir -p "$SYSTEMD_USER_DIR" + for unit in "${LINUX_UNITS[@]}"; do + sed "s|@REPO_DIR@|$REPO_DIR|g" "$REPO_DIR/assets/services/$unit" \ + > "$SYSTEMD_USER_DIR/$unit" + log " + wrote $SYSTEMD_USER_DIR/$unit" + done + systemctl --user daemon-reload + for unit in "${LINUX_UNITS[@]}"; do + systemctl --user enable --now "$unit" + log " + enabled+started $unit" + done + log "✓ Burble is now a systemd --user service. No terminal window will pop up." + log " Logs: journalctl --user -u burble -f" + log " Status: scripts/install-service.sh status" +} + +linux_uninstall() { + for unit in "${LINUX_UNITS[@]}"; do + systemctl --user disable --now "$unit" 2>/dev/null || true + rm -f "$SYSTEMD_USER_DIR/$unit" && log " - removed $SYSTEMD_USER_DIR/$unit" + done + systemctl --user daemon-reload 2>/dev/null || true + log "✓ Burble service removed." +} + +linux_ctl() { for unit in "${LINUX_UNITS[@]}"; do systemctl --user "$1" "$unit" || true; done; } +linux_logs() { journalctl --user -u burble -u burble-ai-bridge -f; } + +# ─── macOS (launchd LaunchAgents) ─────────────────────────────────────────── +LAUNCHD_DIR="$HOME/Library/LaunchAgents" +MACOS_AGENTS=("com.hyperpolymath.burble.plist") +$INCLUDE_AI_BRIDGE && MACOS_AGENTS+=("com.hyperpolymath.burble.ai-bridge.plist") + +macos_install() { + mkdir -p "$LAUNCHD_DIR" + for plist in "${MACOS_AGENTS[@]}"; do + sed "s|@REPO_DIR@|$REPO_DIR|g" "$REPO_DIR/assets/services/$plist" \ + > "$LAUNCHD_DIR/$plist" + launchctl bootstrap "gui/$UID" "$LAUNCHD_DIR/$plist" 2>/dev/null \ + || launchctl load -w "$LAUNCHD_DIR/$plist" + log " + loaded $plist" + done + log "✓ Burble loaded as a launchd LaunchAgent." + log " Logs: tail -F /tmp/burble.out.log /tmp/burble.err.log" +} + +macos_uninstall() { + for plist in "${MACOS_AGENTS[@]}"; do + local label="${plist%.plist}" + launchctl bootout "gui/$UID/$label" 2>/dev/null \ + || launchctl unload "$LAUNCHD_DIR/$plist" 2>/dev/null || true + rm -f "$LAUNCHD_DIR/$plist" && log " - removed $plist" + done + log "✓ Burble LaunchAgents removed." +} + +macos_ctl() { + for plist in "${MACOS_AGENTS[@]}"; do + local label="${plist%.plist}" + case "$1" in + start) launchctl kickstart -k "gui/$UID/$label" ;; + stop) launchctl kill SIGTERM "gui/$UID/$label" ;; + restart) launchctl kickstart -k "gui/$UID/$label" ;; + status) launchctl print "gui/$UID/$label" | sed -n '1,12p' ;; + esac + done +} +macos_logs() { tail -F /tmp/burble.out.log /tmp/burble.err.log /tmp/burble-ai-bridge.out.log /tmp/burble-ai-bridge.err.log 2>/dev/null; } + +# ─── WSL / Windows host (Bolt UDP forwarder only) ─────────────────────────── +wsl_install() { + cat <<'EOF' +You are inside WSL. The Burble Elixir server still runs here as a regular +process, but inbound Bolt udp/7373+9 has to be forwarded from the Windows +host. Run this command in a *Windows* PowerShell: + + cd \\wsl$\Ubuntu\home\user\burble (or wherever you cloned) + .\scripts\wsl-bolt-udp-forward.ps1 -Install # windowless, runs at logon + +For an elevated shell, add inbound firewall rules too: + .\scripts\wsl-bolt-udp-forward.ps1 -Install -Firewall + +Then, on the WSL side, install the Elixir server as a systemd --user unit: + scripts/install-service.sh install # this is what installs the server +EOF + # Still run the Linux install for the Elixir server inside WSL. + log "Installing the WSL-side Elixir service now..." + linux_install +} + +# ─── Dispatch ─────────────────────────────────────────────────────────────── +case "$ACTION" in + install) + case "$OS" in + linux) linux_install ;; + macos) macos_install ;; + wsl) wsl_install ;; + *) err "Unsupported OS: $(uname -s)"; exit 1 ;; + esac ;; + uninstall|remove) + case "$OS" in + linux|wsl) linux_uninstall ;; + macos) macos_uninstall ;; + esac ;; + start|stop|restart|status) + case "$OS" in + linux|wsl) linux_ctl "$ACTION" ;; + macos) macos_ctl "$ACTION" ;; + esac ;; + logs) + case "$OS" in + linux|wsl) linux_logs ;; + macos) macos_logs ;; + esac ;; + help|--help|-h|"") + sed -n '2,22p' "$0" ; exit 0 ;; + *) + err "Unknown action: $ACTION" + sed -n '2,22p' "$0" ; exit 2 ;; +esac diff --git a/scripts/wsl-bolt-udp-forward.ps1 b/scripts/wsl-bolt-udp-forward.ps1 index 63a01d8..76b5b67 100755 --- a/scripts/wsl-bolt-udp-forward.ps1 +++ b/scripts/wsl-bolt-udp-forward.ps1 @@ -22,9 +22,12 @@ # the target is re-resolved periodically and sockets rebuilt on change. # # Usage: -# .\wsl-bolt-udp-forward.ps1 -Run # run the relay (foreground) -# .\wsl-bolt-udp-forward.ps1 -Install # register a logon scheduled task -# .\wsl-bolt-udp-forward.ps1 -Uninstall # remove the scheduled task +# .\wsl-bolt-udp-forward.ps1 -Run # run the relay (foreground, console) +# .\wsl-bolt-udp-forward.ps1 -Tray # run hidden + show a system-tray icon +# .\wsl-bolt-udp-forward.ps1 -Install # register a logon scheduled task that +# # launches the relay WINDOWLESS via a +# # VBS shim (no console pops up at logon) +# .\wsl-bolt-udp-forward.ps1 -Uninstall # remove scheduled task + VBS shim # .\wsl-bolt-udp-forward.ps1 -Status # show resolved IP + task state # # Options: @@ -33,6 +36,9 @@ # -Firewall With -Install, also add inbound Defender allow rules # (requires an elevated shell; skipped with a warning if # not elevated) +# -WithTray With -Install, the scheduled task launches the tray-icon +# variant instead of the headless relay. Off by default — +# most users want a true background service with no UI. # # Exit: # 0 - clean shutdown (Ctrl-C) / action completed @@ -43,16 +49,21 @@ [CmdletBinding(DefaultParameterSetName = 'Run')] param( [Parameter(ParameterSetName = 'Run')] [switch]$Run, + [Parameter(ParameterSetName = 'Tray')] [switch]$Tray, [Parameter(ParameterSetName = 'Install')] [switch]$Install, [Parameter(ParameterSetName = 'Uninstall')][switch]$Uninstall, [Parameter(ParameterSetName = 'Status')] [switch]$Status, [string]$Distro, [int[]]$Ports = @(7373, 9), - [switch]$Firewall + [switch]$Firewall, + [switch]$WithTray ) $ErrorActionPreference = 'Stop' $script:TaskName = 'BurbleBoltUdpForward' +$script:VbsShim = Join-Path $PSScriptRoot 'wsl-bolt-udp-forward.vbs' +$script:LogDir = Join-Path $env:LOCALAPPDATA 'BurbleBoltFwd' +$script:LogFile = Join-Path $script:LogDir 'relay.log' function Resolve-WslIp { param([string]$Distro) @@ -72,6 +83,19 @@ function Resolve-WslIp { return $null } +function Write-Relay { + # Console + persistent log. The scheduled-task launch path is windowless + # (no console attached), so Write-Host alone is dropped. Append to a log + # file so the relay still leaves a trace and -Status can point at it. + param([string]$Message) + $stamp = "[$([DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss'))] $Message" + try { + New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null + Add-Content -Path $script:LogFile -Value $stamp + } catch {} + try { Write-Host $stamp } catch {} +} + function Wait-WslIp { param([string]$Distro, [int]$TimeoutSec = 90) $deadline = (Get-Date).AddSeconds($TimeoutSec) @@ -89,10 +113,10 @@ function Invoke-Relay { Add-Type -AssemblyName System.Net | Out-Null $wslIp = Wait-WslIp -Distro $Distro if (-not $wslIp) { - Write-Error "WSL distro never became resolvable (hostname -I empty)." + Write-Relay "ERROR: WSL distro never became resolvable (hostname -I empty)." exit 3 } - Write-Host "[bolt-fwd] WSL target: $wslIp ; relaying udp/$($Ports -join ',')" + Write-Relay "WSL target: $wslIp ; relaying udp/$($Ports -join ',')" $listeners = @{} # port -> Socket bound on 0.0.0.0:port foreach ($p in $Ports) { @@ -103,7 +127,7 @@ function Invoke-Relay { try { $s.Bind((New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, $p))) } catch { - Write-Error "Could not bind udp/${p}: $($_.Exception.Message)" + Write-Relay "ERROR: Could not bind udp/${p}: $($_.Exception.Message)" exit 2 } $listeners[$p] = $s @@ -115,14 +139,14 @@ function Invoke-Relay { $buf = New-Object byte[] 65535 $lastResolve = Get-Date - Write-Host "[bolt-fwd] running. Ctrl-C to stop." + Write-Relay "running. Ctrl-C to stop." while ($true) { # Re-resolve the WSL IP every 15s; rebuild upstreams on change. if (((Get-Date) - $lastResolve).TotalSeconds -ge 15) { $lastResolve = Get-Date $cur = Resolve-WslIp -Distro $Distro if ($cur -and $cur -ne $wslIp) { - Write-Host "[bolt-fwd] WSL IP changed $wslIp -> $cur ; resetting upstreams" + Write-Relay "WSL IP changed $wslIp -> $cur ; resetting upstreams" $wslIp = $cur foreach ($u in $ups.Values) { $u.Sock.Close() } $ups.Clear() @@ -183,26 +207,52 @@ function Invoke-Relay { } } +function Write-VbsShim { + # VBScript launcher: WshShell.Run "...", 0, False truly creates no console + # window. powershell.exe -WindowStyle Hidden still flashes a console for ~1 + # frame on scheduled-task launch, which is the visible window users see at + # logon. wscript.exe + this shim avoids it entirely. + param([string]$PowerShellInvocation) + New-Item -ItemType Directory -Path (Split-Path $script:VbsShim) -Force | Out-Null + $escaped = $PowerShellInvocation.Replace('"', '""') + @" +' GENERATED by wsl-bolt-udp-forward.ps1 -Install. Do not edit. +' Launches the Burble Bolt UDP forwarder without ever showing a console. +Set sh = CreateObject("WScript.Shell") +sh.Run "$escaped", 0, False +"@ | Set-Content -Encoding ASCII -Path $script:VbsShim +} + function Install-Task { - param([string]$Distro, [int[]]$Ports, [switch]$Firewall) + param([string]$Distro, [int[]]$Ports, [switch]$Firewall, [switch]$WithTray) $self = $MyInvocation.MyCommand.Path if (-not $self) { $self = $PSCommandPath } - # NOTE: use -Command, not -File. With -File, `-Ports 7373,9` is passed - # as the literal string "7373,9" which cannot bind to [int[]]$Ports and - # the task aborts before binding (LastTaskResult=1). -Command parses the - # array argument correctly. - $inner = "& '$self' -Run" + + # Build the actual relay command the VBS shim will fire-and-forget. Use + # -Command (not -File) so `-Ports 7373,9` parses as [int[]] rather than + # the literal string "7373,9". + $mode = if ($WithTray) { '-Tray' } else { '-Run' } + $inner = "& '$self' $mode" if ($Distro) { $inner += " -Distro '$Distro'" } if ($Ports) { $inner += " -Ports $($Ports -join ',')" } - $argline = "-NoProfile -ExecutionPolicy Bypass -Command `"$inner`"" + $psInvocation = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command `"$inner`"" - $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argline + Write-VbsShim -PowerShellInvocation $psInvocation + Write-Host "[bolt-fwd] wrote VBS shim: $($script:VbsShim)" + + $action = New-ScheduledTaskAction -Execute 'wscript.exe' -Argument "`"$($script:VbsShim)`"" $trigger = New-ScheduledTaskTrigger -AtLogOn $set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` - -DontStopIfGoingOnBatteries -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) + -DontStopIfGoingOnBatteries -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` + -Hidden Register-ScheduledTask -TaskName $script:TaskName -Action $action -Trigger $trigger ` - -Settings $set -Description 'Burble Bolt UDP forwarder (WSL2 NAT, no mirrored networking)' -Force | Out-Null - Write-Host "[bolt-fwd] scheduled task '$($script:TaskName)' registered (runs at logon)." + -Settings $set -Description 'Burble Bolt UDP forwarder (WSL2 NAT, windowless background service)' -Force | Out-Null + Write-Host "[bolt-fwd] scheduled task '$($script:TaskName)' registered (runs at logon, no window)." + if ($WithTray) { + Write-Host "[bolt-fwd] tray-icon mode enabled — look for the Burble Bolt icon in your system tray after next logon." + } else { + Write-Host "[bolt-fwd] running as a headless background service. Log: $($script:LogFile)" + } if ($Firewall) { $elevated = ([Security.Principal.WindowsPrincipal] ` @@ -221,18 +271,80 @@ function Install-Task { } } -switch ($PSCmdlet.ParameterSetName) { - 'Install' { Install-Task -Distro $Distro -Ports $Ports -Firewall:$Firewall } - 'Uninstall' { - Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue - Write-Host "[bolt-fwd] scheduled task removed." +function Invoke-Tray { + # Tray-icon mode: spawn the relay as a hidden child process and host a + # NotifyIcon for visibility/control. Right-click menu: Status / Open log / + # Restart / Exit. The relay itself logs to $script:LogFile. + param([string]$Distro, [int[]]$Ports) + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + + New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null + $self = $PSCommandPath + + # The child relay uses Write-Relay (-> $script:LogFile) for its own + # output, so we don't need to tail its stdout from here. + $script:RelayProc = $null + function Start-Relay { + $argInner = "& '$self' -Run" + if ($Distro) { $argInner += " -Distro '$Distro'" } + if ($Ports) { $argInner += " -Ports $($Ports -join ',')" } + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = 'powershell.exe' + $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command `"$argInner`"" + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $script:RelayProc = [System.Diagnostics.Process]::Start($psi) } + Start-Relay + + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Information + $icon.Text = "Burble Bolt UDP forwarder (udp/$($Ports -join ',') -> WSL)" + $icon.Visible = $true + + $menu = New-Object System.Windows.Forms.ContextMenuStrip + [void]$menu.Items.Add("Status", $null, { + $ip = Resolve-WslIp -Distro $Distro + $msg = "WSL target : $(if ($ip) { $ip } else { '(unresolved)' })`r`nRelayed : udp/$($Ports -join ',')`r`nRelay PID : $($script:RelayProc.Id)" + [System.Windows.Forms.MessageBox]::Show($msg, 'Burble Bolt forwarder') + }) + [void]$menu.Items.Add("Open log folder",$null, { Start-Process explorer.exe $script:LogDir }) + [void]$menu.Items.Add("Restart relay", $null, { + try { $script:RelayProc.Kill() } catch {} + Start-Relay + $icon.ShowBalloonTip(2000, 'Burble Bolt', 'Relay restarted', 'Info') + }) + [void]$menu.Items.Add('-') + [void]$menu.Items.Add("Exit", $null, { + try { $script:RelayProc.Kill() } catch {} + $icon.Visible = $false + [System.Windows.Forms.Application]::Exit() + }) + $icon.ContextMenuStrip = $menu + $icon.ShowBalloonTip(2000, 'Burble Bolt forwarder', "Listening on udp/$($Ports -join ',')", 'Info') + + [System.Windows.Forms.Application]::Run() +} + +function Uninstall-Task { + Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue + if (Test-Path $script:VbsShim) { Remove-Item -Force $script:VbsShim } + Write-Host "[bolt-fwd] scheduled task + VBS shim removed." +} + +switch ($PSCmdlet.ParameterSetName) { + 'Install' { Install-Task -Distro $Distro -Ports $Ports -Firewall:$Firewall -WithTray:$WithTray } + 'Uninstall' { Uninstall-Task } + 'Tray' { Invoke-Tray -Distro $Distro -Ports $Ports } 'Status' { $ip = Resolve-WslIp -Distro $Distro Write-Host "WSL target IP : $(if ($ip) { $ip } else { '(unresolved - is the distro running?)' })" $t = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue Write-Host "Scheduled task: $(if ($t) { $t.State } else { 'not installed' })" + Write-Host "VBS shim : $(if (Test-Path $script:VbsShim) { $script:VbsShim } else { '(not installed)' })" Write-Host "Relayed ports : udp/$($Ports -join ',')" + Write-Host "Log file : $script:LogFile" } default { Invoke-Relay -Distro $Distro -Ports $Ports } } From 9ed5edf672de53a1b99d3f9ab7498699b1e30a8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 23:18:22 +0000 Subject: [PATCH 2/2] service: Windows host now installs as a true sc.exe-registered service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the scheduled-task + VBS shim path with a real Windows Service. A minimal C# service host (ServiceBase) is embedded in the script and compiled in-place by the in-box .NET Framework csc.exe — no NSSM, srvany, or external tooling. The service stub spawns powershell.exe running the relay as a child on OnStart and kills it on OnStop. Service runs under the installing user's account, not LocalSystem, because WSL distros are registered per-user (HKCU\…\Lxss). New-Service -Credential prompts via Get-Credential and the SCM stores the password via LSA Secrets. Failure actions: restart at 5s, 5s, 30s. Install dir is C:\ProgramData\BurbleBoltFwd\; the user gets Modify on it so the service can append to relay.log. Drops -Tray / NotifyIcon and the VBS shim entirely. -Install and -Uninstall now require an elevated shell (Assert-Elevated). -Status queries Get-Service instead of Get-ScheduledTask. --- CHANGELOG.md | 19 +- docs/developer/wsl-mirrored-networking.adoc | 29 +- scripts/wsl-bolt-udp-forward.ps1 | 330 ++++++++++++-------- 3 files changed, 225 insertions(+), 153 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ccc483..afd15b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,15 +20,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - macOS: launchd LaunchAgents (`assets/services/com.hyperpolymath.burble.plist`, `…ai-bridge.plist`) in `~/Library/LaunchAgents/`. - - Windows host (WSL2 NAT): see the rewritten - `scripts/wsl-bolt-udp-forward.ps1 -Install` — now registers the - scheduled task to launch the relay **windowless** via a VBS shim - (`wsl-bolt-udp-forward.vbs`), so no PowerShell console flashes at - logon. `-WithTray` opts into a `NotifyIcon` system-tray UI (Status / - Open log / Restart / Exit). + - Windows host (WSL2 NAT): `scripts/wsl-bolt-udp-forward.ps1 -Install` + now installs a **true Windows Service** registered with `sc.exe` + (replaces the previous scheduled-task-at-logon path). A minimal C# + service host is compiled in-place from an embedded source using the + in-box .NET Framework `csc.exe` — no NSSM, srvany, or external + tooling required. The service runs under the installing user's + account (prompted once via `Get-Credential`, password stored by the + SCM via LSA Secrets) because WSL distros are per-user — a + LocalSystem service can launch `wsl.exe` but won't see the user's + distro. Configured for auto-restart on crash (5s/5s/30s). Install + + uninstall must be run from an elevated PowerShell. Justfile recipes: `service-install`, `service-uninstall`, `service-start`, `service-stop`, `service-restart`, `service-status`, - `service-logs`. Relay logs land in `%LOCALAPPDATA%\BurbleBoltFwd\relay.log` + `service-logs`. Relay logs land in `C:\ProgramData\BurbleBoltFwd\relay.log` on Windows / `journalctl --user -u burble` on Linux / `/tmp/burble.{out,err}.log` on macOS. - `Burble.TestSupport.SingletonWatcher` in `test/test_helper.exs` — `Process.monitor`s each of 20 app-owned singletons (PubSub, Presence, RoomRegistry/Supervisor, PeerRegistry/Supervisor, CoprocessorRegistry/Supervisor, MessageStore, NNTPSBackend, Media.Engine, Timing.{PTP,ClockCorrelator,Alignment}, Groove + HealthMesh + Feedback, Transport.RTSP, Bolt.Listener, Endpoint), reports any mid-run death (name + pid + reason + ms-since-start) to stderr at suite end, freezes via `ExUnit.after_suite/1` before BEAM shutdown so the normal app-teardown `:DOWN` cascade is not mistaken for instability. Diagnostic for #62 Bucket B; advisory (does not fail CI). diff --git a/docs/developer/wsl-mirrored-networking.adoc b/docs/developer/wsl-mirrored-networking.adoc index a80c297..ef54417 100644 --- a/docs/developer/wsl-mirrored-networking.adoc +++ b/docs/developer/wsl-mirrored-networking.adoc @@ -37,23 +37,30 @@ changes and none of the mirrored-mode instability. [source,powershell] ---- -# One-time: register a logon scheduled task that launches the relay -# WINDOWLESS via a generated VBS shim (no PowerShell console flashes at -# logon). Optionally add firewall rules from an elevated shell: +# One-time: install as a true Windows Service (elevated shell required). +# A minimal C# service host is compiled in-place using the in-box +# .NET Framework csc.exe — no NSSM, no external tooling. New-Service +# prompts via Get-Credential for your password so the service runs +# under YOUR account (WSL distros are per-user; LocalSystem can't see +# them). The password is stored by the SCM via LSA Secrets. .\scripts\wsl-bolt-udp-forward.ps1 -Install -.\scripts\wsl-bolt-udp-forward.ps1 -Install -Firewall # elevated -.\scripts\wsl-bolt-udp-forward.ps1 -Install -WithTray # add a system-tray icon - # (Status / Open log / Restart / Exit) +.\scripts\wsl-bolt-udp-forward.ps1 -Install -Firewall # also adds inbound rules # Inspect / run in foreground / remove: .\scripts\wsl-bolt-udp-forward.ps1 -Status -.\scripts\wsl-bolt-udp-forward.ps1 -Run # console, debugging -.\scripts\wsl-bolt-udp-forward.ps1 -Tray # tray-icon, ad-hoc (no scheduled task) -.\scripts\wsl-bolt-udp-forward.ps1 -Uninstall +.\scripts\wsl-bolt-udp-forward.ps1 -Run # console, debugging only +.\scripts\wsl-bolt-udp-forward.ps1 -Uninstall # elevated + +# Standard service-management surface: +sc.exe query BurbleBoltUdpForward +sc.exe stop BurbleBoltUdpForward +sc.exe start BurbleBoltUdpForward +Get-Service BurbleBoltUdpForward | Format-List * ---- -The headless install path appends to `%LOCALAPPDATA%\BurbleBoltFwd\relay.log` -so the relay still leaves a trace even with no console attached. +The relay appends to `C:\ProgramData\BurbleBoltFwd\relay.log` so logs +survive across logoffs and reboots. The service is configured for +auto-restart on crash (5s, 5s, 30s). Inside WSL itself, install Burble's Elixir control plane as a systemd `--user` service so launching the project never pops a terminal either: diff --git a/scripts/wsl-bolt-udp-forward.ps1 b/scripts/wsl-bolt-udp-forward.ps1 index 76b5b67..fbfcea7 100755 --- a/scripts/wsl-bolt-udp-forward.ps1 +++ b/scripts/wsl-bolt-udp-forward.ps1 @@ -23,47 +23,54 @@ # # Usage: # .\wsl-bolt-udp-forward.ps1 -Run # run the relay (foreground, console) -# .\wsl-bolt-udp-forward.ps1 -Tray # run hidden + show a system-tray icon -# .\wsl-bolt-udp-forward.ps1 -Install # register a logon scheduled task that -# # launches the relay WINDOWLESS via a -# # VBS shim (no console pops up at logon) -# .\wsl-bolt-udp-forward.ps1 -Uninstall # remove scheduled task + VBS shim -# .\wsl-bolt-udp-forward.ps1 -Status # show resolved IP + task state +# .\wsl-bolt-udp-forward.ps1 -Install # install as a true Windows Service +# # (sc.exe + a generated C# host). +# # Prompts for your password — the +# # service runs under YOUR account +# # so it can see your WSL distro. +# # MUST run from an elevated shell. +# .\wsl-bolt-udp-forward.ps1 -Uninstall # stop + remove the service (elevated) +# .\wsl-bolt-udp-forward.ps1 -Status # show resolved IP + service state # # Options: # -Distro WSL distro (default: the WSL default distribution) # -Ports UDP ports to relay (default: 7373,9) # -Firewall With -Install, also add inbound Defender allow rules -# (requires an elevated shell; skipped with a warning if -# not elevated) -# -WithTray With -Install, the scheduled task launches the tray-icon -# variant instead of the headless relay. Off by default — -# most users want a true background service with no UI. +# +# Why a true service + your account: +# WSL distros are registered per-user (HKCU\Software\Microsoft\Windows\ +# CurrentVersion\Lxss). A service running as LocalSystem can launch +# wsl.exe but won't see *your* distros. Running the service under your +# account fixes this. New-Service -Credential stores the password +# securely via LSA Secrets; you never see it again. # # Exit: -# 0 - clean shutdown (Ctrl-C) / action completed -# 1 - bad arguments +# 0 - clean shutdown / action completed +# 1 - bad arguments / not elevated # 2 - a listen socket could not bind (port already in use?) # 3 - WSL distro never became resolvable +# 4 - C# compile / sc.exe install failure [CmdletBinding(DefaultParameterSetName = 'Run')] param( [Parameter(ParameterSetName = 'Run')] [switch]$Run, - [Parameter(ParameterSetName = 'Tray')] [switch]$Tray, [Parameter(ParameterSetName = 'Install')] [switch]$Install, [Parameter(ParameterSetName = 'Uninstall')][switch]$Uninstall, [Parameter(ParameterSetName = 'Status')] [switch]$Status, [string]$Distro, [int[]]$Ports = @(7373, 9), - [switch]$Firewall, - [switch]$WithTray + [switch]$Firewall ) $ErrorActionPreference = 'Stop' -$script:TaskName = 'BurbleBoltUdpForward' -$script:VbsShim = Join-Path $PSScriptRoot 'wsl-bolt-udp-forward.vbs' -$script:LogDir = Join-Path $env:LOCALAPPDATA 'BurbleBoltFwd' -$script:LogFile = Join-Path $script:LogDir 'relay.log' +$script:ServiceName = 'BurbleBoltUdpForward' +$script:ServiceDisp = 'Burble Bolt UDP forwarder' +$script:ServiceDesc = 'Forwards Bolt udp/7373+9 from the Windows host into the WSL2 NAT-mode Burble server (no mirrored networking required).' +$script:InstallDir = Join-Path $env:ProgramData 'BurbleBoltFwd' +$script:ServiceExe = Join-Path $script:InstallDir 'BurbleBoltService.exe' +$script:ServiceArgs = Join-Path $script:InstallDir 'service-args.txt' +$script:LogDir = $script:InstallDir +$script:LogFile = Join-Path $script:LogDir 'relay.log' function Resolve-WslIp { param([string]$Distro) @@ -207,142 +214,195 @@ function Invoke-Relay { } } -function Write-VbsShim { - # VBScript launcher: WshShell.Run "...", 0, False truly creates no console - # window. powershell.exe -WindowStyle Hidden still flashes a console for ~1 - # frame on scheduled-task launch, which is the visible window users see at - # logon. wscript.exe + this shim avoids it entirely. - param([string]$PowerShellInvocation) - New-Item -ItemType Directory -Path (Split-Path $script:VbsShim) -Force | Out-Null - $escaped = $PowerShellInvocation.Replace('"', '""') - @" -' GENERATED by wsl-bolt-udp-forward.ps1 -Install. Do not edit. -' Launches the Burble Bolt UDP forwarder without ever showing a console. -Set sh = CreateObject("WScript.Shell") -sh.Run "$escaped", 0, False -"@ | Set-Content -Encoding ASCII -Path $script:VbsShim +function Assert-Elevated { + param([string]$Action = 'this operation') + $elevated = ([Security.Principal.WindowsPrincipal] ` + [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltinRole]::Administrator) + if (-not $elevated) { + Write-Error "Elevated shell required for $Action. Re-run PowerShell as Administrator." + exit 1 + } } -function Install-Task { - param([string]$Distro, [int[]]$Ports, [switch]$Firewall, [switch]$WithTray) - $self = $MyInvocation.MyCommand.Path - if (-not $self) { $self = $PSCommandPath } - - # Build the actual relay command the VBS shim will fire-and-forget. Use - # -Command (not -File) so `-Ports 7373,9` parses as [int[]] rather than - # the literal string "7373,9". - $mode = if ($WithTray) { '-Tray' } else { '-Run' } - $inner = "& '$self' $mode" - if ($Distro) { $inner += " -Distro '$Distro'" } - if ($Ports) { $inner += " -Ports $($Ports -join ',')" } - $psInvocation = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command `"$inner`"" - - Write-VbsShim -PowerShellInvocation $psInvocation - Write-Host "[bolt-fwd] wrote VBS shim: $($script:VbsShim)" - - $action = New-ScheduledTaskAction -Execute 'wscript.exe' -Argument "`"$($script:VbsShim)`"" - $trigger = New-ScheduledTaskTrigger -AtLogOn - $set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` - -DontStopIfGoingOnBatteries -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` - -Hidden - Register-ScheduledTask -TaskName $script:TaskName -Action $action -Trigger $trigger ` - -Settings $set -Description 'Burble Bolt UDP forwarder (WSL2 NAT, windowless background service)' -Force | Out-Null - Write-Host "[bolt-fwd] scheduled task '$($script:TaskName)' registered (runs at logon, no window)." - if ($WithTray) { - Write-Host "[bolt-fwd] tray-icon mode enabled — look for the Burble Bolt icon in your system tray after next logon." - } else { - Write-Host "[bolt-fwd] running as a headless background service. Log: $($script:LogFile)" - } +# C# source for a minimal Windows Service host. Compiled on demand by +# Compile-ServiceHost using the in-box .NET Framework csc.exe (always +# present on every Windows 10/11). The service has no .NET Core / Roslyn / +# NSSM dependency. +$script:ServiceSource = @' +using System; +using System.Diagnostics; +using System.IO; +using System.ServiceProcess; - if ($Firewall) { - $elevated = ([Security.Principal.WindowsPrincipal] ` - [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( - [Security.Principal.WindowsBuiltinRole]::Administrator) - if (-not $elevated) { - Write-Warning "-Firewall needs an elevated shell; skipping firewall rules. Re-run elevated or add them manually." - } else { - foreach ($p in $Ports) { - New-NetFirewallRule -DisplayName "Burble Bolt (WSL2 NAT fwd) udp/$p" ` - -Direction Inbound -Protocol UDP -LocalPort $p -Action Allow ` - -Profile Private,Domain -ErrorAction SilentlyContinue | Out-Null +namespace BurbleBoltForward { + public class Service : ServiceBase { + private Process _child; + public Service() { + this.ServiceName = "BurbleBoltUdpForward"; + this.CanStop = true; + this.CanShutdown = true; + this.AutoLog = true; + } + protected override void OnStart(string[] args) { + try { + string dir = Path.GetDirectoryName(typeof(Service).Assembly.Location); + string argFile = Path.Combine(dir, "service-args.txt"); + string argLine = File.ReadAllText(argFile).Trim(); + var psi = new ProcessStartInfo { + FileName = "powershell.exe", + Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command \"" + argLine.Replace("\"", "\\\"") + "\"", + UseShellExecute = false, + CreateNoWindow = true, + }; + _child = Process.Start(psi); + } catch (Exception ex) { + try { EventLog.WriteEntry("BurbleBoltUdpForward", + "OnStart failed: " + ex.ToString(), EventLogEntryType.Error); } catch {} + throw; } - Write-Host "[bolt-fwd] firewall allow rules added for udp/$($Ports -join ',')." } + protected override void OnStop() { + try { if (_child != null && !_child.HasExited) { + _child.Kill(); _child.WaitForExit(5000); + } } catch {} + } + protected override void OnShutdown() { OnStop(); } + public static void Main(string[] args) { ServiceBase.Run(new Service()); } } } +'@ -function Invoke-Tray { - # Tray-icon mode: spawn the relay as a hidden child process and host a - # NotifyIcon for visibility/control. Right-click menu: Status / Open log / - # Restart / Exit. The relay itself logs to $script:LogFile. - param([string]$Distro, [int[]]$Ports) - Add-Type -AssemblyName System.Windows.Forms - Add-Type -AssemblyName System.Drawing +function Compile-ServiceHost { + # Use the .NET Framework 4 in-box C# compiler — it's always at this + # path on a stock Windows install, no extra tooling needed. + $cscPaths = @( + "$env:windir\Microsoft.NET\Framework64\v4.0.30319\csc.exe", + "$env:windir\Microsoft.NET\Framework\v4.0.30319\csc.exe" + ) + $csc = $cscPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $csc) { + Write-Error "Could not find csc.exe under %WINDIR%\Microsoft.NET\Framework*\v4.0.30319\. .NET Framework 4 not installed?" + exit 4 + } - New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null + New-Item -ItemType Directory -Path $script:InstallDir -Force | Out-Null + $src = Join-Path $script:InstallDir 'BurbleBoltService.cs' + Set-Content -Path $src -Value $script:ServiceSource -Encoding ASCII + + & $csc /nologo /target:exe /optimize+ /platform:anycpu ` + /reference:System.ServiceProcess.dll ` + /out:"$($script:ServiceExe)" "$src" 2>&1 | ForEach-Object { Write-Host " csc: $_" } + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $script:ServiceExe)) { + Write-Error "csc.exe failed to produce $($script:ServiceExe)." + exit 4 + } + Write-Host "[bolt-fwd] compiled service host: $($script:ServiceExe)" +} + +function Install-Service { + param([string]$Distro, [int[]]$Ports, [switch]$Firewall) + Assert-Elevated -Action '-Install (creates a Windows Service)' $self = $PSCommandPath + if (-not $self) { $self = $MyInvocation.MyCommand.Path } - # The child relay uses Write-Relay (-> $script:LogFile) for its own - # output, so we don't need to tail its stdout from here. - $script:RelayProc = $null - function Start-Relay { - $argInner = "& '$self' -Run" - if ($Distro) { $argInner += " -Distro '$Distro'" } - if ($Ports) { $argInner += " -Ports $($Ports -join ',')" } - $psi = New-Object System.Diagnostics.ProcessStartInfo - $psi.FileName = 'powershell.exe' - $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command `"$argInner`"" - $psi.UseShellExecute = $false - $psi.CreateNoWindow = $true - $script:RelayProc = [System.Diagnostics.Process]::Start($psi) + # If an old install exists (either the scheduled-task variant from a + # prior version, or a previous service install), remove it first. + if (Get-Service -Name $script:ServiceName -ErrorAction SilentlyContinue) { + Write-Host "[bolt-fwd] existing service found — removing before reinstall." + Uninstall-Service -Quiet } - Start-Relay + Unregister-ScheduledTask -TaskName $script:ServiceName -Confirm:$false -ErrorAction SilentlyContinue - $icon = New-Object System.Windows.Forms.NotifyIcon - $icon.Icon = [System.Drawing.SystemIcons]::Information - $icon.Text = "Burble Bolt UDP forwarder (udp/$($Ports -join ',') -> WSL)" - $icon.Visible = $true + New-Item -ItemType Directory -Path $script:InstallDir -Force | Out-Null - $menu = New-Object System.Windows.Forms.ContextMenuStrip - [void]$menu.Items.Add("Status", $null, { - $ip = Resolve-WslIp -Distro $Distro - $msg = "WSL target : $(if ($ip) { $ip } else { '(unresolved)' })`r`nRelayed : udp/$($Ports -join ',')`r`nRelay PID : $($script:RelayProc.Id)" - [System.Windows.Forms.MessageBox]::Show($msg, 'Burble Bolt forwarder') - }) - [void]$menu.Items.Add("Open log folder",$null, { Start-Process explorer.exe $script:LogDir }) - [void]$menu.Items.Add("Restart relay", $null, { - try { $script:RelayProc.Kill() } catch {} - Start-Relay - $icon.ShowBalloonTip(2000, 'Burble Bolt', 'Relay restarted', 'Info') - }) - [void]$menu.Items.Add('-') - [void]$menu.Items.Add("Exit", $null, { - try { $script:RelayProc.Kill() } catch {} - $icon.Visible = $false - [System.Windows.Forms.Application]::Exit() - }) - $icon.ContextMenuStrip = $menu - $icon.ShowBalloonTip(2000, 'Burble Bolt forwarder', "Listening on udp/$($Ports -join ',')", 'Info') - - [System.Windows.Forms.Application]::Run() + # Stash the script and its arguments next to the service exe so the + # service host can find them across reboots / script-tree moves. + $deployedScript = Join-Path $script:InstallDir 'wsl-bolt-udp-forward.ps1' + Copy-Item -Force -Path $self -Destination $deployedScript + + $argInner = "& '$deployedScript' -Run" + if ($Distro) { $argInner += " -Distro '$Distro'" } + if ($Ports) { $argInner += " -Ports $($Ports -join ',')" } + Set-Content -Path $script:ServiceArgs -Value $argInner -Encoding ASCII + + Compile-ServiceHost + + Write-Host "[bolt-fwd] WSL distros are per-user. The service needs to run under YOUR account" + Write-Host " so it can launch wsl.exe and see your distro. New-Service stores the" + Write-Host " password securely via LSA Secrets — you only enter it once." + $cred = Get-Credential -UserName "$env:USERDOMAIN\$env:USERNAME" ` + -Message "Password for $env:USERDOMAIN\$env:USERNAME (so the service can launch wsl.exe as you)" + if (-not $cred) { Write-Error "Cancelled — no credential supplied."; exit 1 } + + # Grant the service user Modify on the install dir — it lives under + # %ProgramData% which is admin-only by default, but the service runs + # as a normal user and needs to append to relay.log. + try { + $acl = Get-Acl $script:InstallDir + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $cred.UserName, 'Modify', + 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $acl.SetAccessRule($rule) + Set-Acl -Path $script:InstallDir -AclObject $acl + } catch { + Write-Warning "Could not adjust ACL on $($script:InstallDir): $($_.Exception.Message)" + } + + # Grant the account 'Log on as a service' right; New-Service does this + # automatically when -Credential is provided on recent Windows, but be + # explicit so older builds don't choke. + New-Service -Name $script:ServiceName ` + -BinaryPathName "`"$($script:ServiceExe)`"" ` + -DisplayName $script:ServiceDisp ` + -Description $script:ServiceDesc ` + -StartupType Automatic ` + -Credential $cred | Out-Null + + # Recover automatically on crash: restart after 5s, 5s, 30s. + & sc.exe failure $script:ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/30000 | Out-Null + + Start-Service -Name $script:ServiceName + Write-Host "[bolt-fwd] service '$($script:ServiceName)' installed and started." + Write-Host " Log: $($script:LogFile)" + Write-Host " Manage: sc.exe query/start/stop/delete $($script:ServiceName)" + + if ($Firewall) { + foreach ($p in $Ports) { + New-NetFirewallRule -DisplayName "Burble Bolt (WSL2 NAT fwd) udp/$p" ` + -Direction Inbound -Protocol UDP -LocalPort $p -Action Allow ` + -Profile Private,Domain -ErrorAction SilentlyContinue | Out-Null + } + Write-Host "[bolt-fwd] firewall allow rules added for udp/$($Ports -join ',')." + } } -function Uninstall-Task { - Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false -ErrorAction SilentlyContinue - if (Test-Path $script:VbsShim) { Remove-Item -Force $script:VbsShim } - Write-Host "[bolt-fwd] scheduled task + VBS shim removed." +function Uninstall-Service { + param([switch]$Quiet) + if (-not $Quiet) { Assert-Elevated -Action '-Uninstall (removes a Windows Service)' } + $svc = Get-Service -Name $script:ServiceName -ErrorAction SilentlyContinue + if ($svc) { + if ($svc.Status -ne 'Stopped') { + Stop-Service -Name $script:ServiceName -Force -ErrorAction SilentlyContinue + } + & sc.exe delete $script:ServiceName | Out-Null + if (-not $Quiet) { Write-Host "[bolt-fwd] service '$($script:ServiceName)' removed." } + } elseif (-not $Quiet) { + Write-Host "[bolt-fwd] service '$($script:ServiceName)' was not installed." + } + # Also clear any legacy scheduled-task install from an older version. + Unregister-ScheduledTask -TaskName $script:ServiceName -Confirm:$false -ErrorAction SilentlyContinue } switch ($PSCmdlet.ParameterSetName) { - 'Install' { Install-Task -Distro $Distro -Ports $Ports -Firewall:$Firewall -WithTray:$WithTray } - 'Uninstall' { Uninstall-Task } - 'Tray' { Invoke-Tray -Distro $Distro -Ports $Ports } + 'Install' { Install-Service -Distro $Distro -Ports $Ports -Firewall:$Firewall } + 'Uninstall' { Uninstall-Service } 'Status' { - $ip = Resolve-WslIp -Distro $Distro + $ip = Resolve-WslIp -Distro $Distro + $svc = Get-Service -Name $script:ServiceName -ErrorAction SilentlyContinue Write-Host "WSL target IP : $(if ($ip) { $ip } else { '(unresolved - is the distro running?)' })" - $t = Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue - Write-Host "Scheduled task: $(if ($t) { $t.State } else { 'not installed' })" - Write-Host "VBS shim : $(if (Test-Path $script:VbsShim) { $script:VbsShim } else { '(not installed)' })" + Write-Host "Service : $(if ($svc) { "$($svc.Status) ($($script:ServiceName))" } else { 'not installed' })" + Write-Host "Service exe : $(if (Test-Path $script:ServiceExe) { $script:ServiceExe } else { '(not installed)' })" Write-Host "Relayed ports : udp/$($Ports -join ',')" Write-Host "Log file : $script:LogFile" }