diff --git a/CHANGELOG.md b/CHANGELOG.md index 974084b..afd15b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,31 @@ 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): `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 `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). ### 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..ef54417 100644 --- a/docs/developer/wsl-mirrored-networking.adoc +++ b/docs/developer/wsl-mirrored-networking.adoc @@ -37,15 +37,40 @@ 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: 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 -Firewall # also adds inbound rules # 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 -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 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: + +[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 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..fbfcea7 100755 --- a/scripts/wsl-bolt-udp-forward.ps1 +++ b/scripts/wsl-bolt-udp-forward.ps1 @@ -22,23 +22,34 @@ # 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 -Status # show resolved IP + task state +# .\wsl-bolt-udp-forward.ps1 -Run # run the relay (foreground, console) +# .\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) +# +# 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( @@ -52,7 +63,14 @@ param( ) $ErrorActionPreference = 'Stop' -$script:TaskName = 'BurbleBoltUdpForward' +$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) @@ -72,6 +90,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 +120,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 +134,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 +146,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,56 +214,197 @@ function Invoke-Relay { } } -function Install-Task { +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 + } +} + +# 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; + +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; + } + } + 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 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: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) - $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" - if ($Distro) { $inner += " -Distro '$Distro'" } - if ($Ports) { $inner += " -Ports $($Ports -join ',')" } - $argline = "-NoProfile -ExecutionPolicy Bypass -Command `"$inner`"" - - $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argline - $trigger = New-ScheduledTaskTrigger -AtLogOn - $set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` - -DontStopIfGoingOnBatteries -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) - 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)." + Assert-Elevated -Action '-Install (creates a Windows Service)' + $self = $PSCommandPath + if (-not $self) { $self = $MyInvocation.MyCommand.Path } + + # 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 + } + Unregister-ScheduledTask -TaskName $script:ServiceName -Confirm:$false -ErrorAction SilentlyContinue + + New-Item -ItemType Directory -Path $script:InstallDir -Force | Out-Null + + # 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) { - $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 - } - Write-Host "[bolt-fwd] firewall allow rules added for udp/$($Ports -join ',')." + 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 ',')." } } -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 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-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 "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" } default { Invoke-Relay -Distro $Distro -Ports $Ports } }