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 }
}