crabbox/internal/cli/bootstrap.go
Jonathan Moss 00725544c7
feat(azure): support linux and native windows leases
Add Azure as a managed provider for direct and brokered Crabbox leases.

- provision Azure Linux VMs with cloud-init, spot fallback, shared network adoption, and per-lease cleanup
- provision native Azure Windows VMs with VM Agent bootstrap and SSH/sync/run support
- add Azure broker support in the Cloudflare Worker, provider config, docs, and tests
- fix async Azure delete handling so successful 202 delete LROs do not refetch deleted resources
- keep Go core coverage above the CI threshold

Verified with CI plus live Azure Linux and native Windows leases.

Co-authored-by: Jonathan Moss <2729151+jwmoss@users.noreply.github.com>
2026-05-08 08:23:38 +01:00

700 lines
30 KiB
Go

package cli
import (
"fmt"
"strings"
)
const (
tightVNCMSIURL = "https://www.tightvnc.com/download/2.8.85/tightvnc-2.8.85-gpl-setup-64bit.msi"
gitForWindowsSetupURL = "https://github.com/git-for-windows/git/releases/download/v2.52.0.windows.1/Git-2.52.0-64-bit.exe"
openSSHWin64ZipURL = "https://github.com/PowerShell/Win32-OpenSSH/releases/download/v9.8.3.0p2-Preview/OpenSSH-Win64.zip"
ubuntuWSLRootFSURL = "https://cloud-images.ubuntu.com/wsl/releases/24.04/current/ubuntu-noble-wsl-amd64-wsl.rootfs.tar.gz"
)
func awsUserData(cfg Config, publicKey string) string {
switch cfg.TargetOS {
case targetWindows:
return windowsUserData(cfg, publicKey)
case targetMacOS:
return macOSUserData(cfg, publicKey)
default:
return cloudInit(cfg, publicKey)
}
}
func cloudInit(cfg Config, publicKey string) string {
portLines := ""
for _, port := range sshPortCandidates(cfg.SSHPort, cfg.SSHFallbackPorts) {
portLines += fmt.Sprintf(" Port %s\n", port)
}
readyChecks := cloudInitOptionalReadyChecks(cfg)
writeFiles := cloudInitOptionalWriteFiles(cfg)
bootstrap := cloudInitOptionalBootstrap(cfg)
return fmt.Sprintf(`#cloud-config
package_update: false
package_upgrade: false
users:
- name: %[1]s
groups: sudo
shell: /bin/bash
sudo: ['ALL=(ALL) NOPASSWD:ALL']
ssh_authorized_keys:
- %[2]s
write_files:
- path: /etc/ssh/sshd_config.d/99-crabbox-port.conf
permissions: '0644'
content: |
%[4]s
PasswordAuthentication no
- path: /usr/local/bin/crabbox-ready
permissions: '0755'
content: |
#!/usr/bin/env bash
set -euo pipefail
git --version
rsync --version >/dev/null
curl --version >/dev/null
jq --version >/dev/null
test -f /var/lib/crabbox/bootstrapped
test -w %[3]s
%[5]s
%[6]s
runcmd:
- |
bash -euxo pipefail <<'BOOT'
export DEBIAN_FRONTEND=noninteractive
cat >/etc/apt/apt.conf.d/80-crabbox-retries <<'APT'
Acquire::Retries "8";
Acquire::http::Timeout "30";
Acquire::https::Timeout "30";
APT
retry() {
n=1
until "$@"; do
if [ "$n" -ge 8 ]; then
return 1
fi
sleep $((n * 5))
n=$((n + 1))
done
}
retry apt-get update
retry apt-get install -y --no-install-recommends openssh-server ca-certificates curl git rsync jq
mkdir -p %[3]s /var/cache/crabbox/pnpm /var/cache/crabbox/npm
chown -R %[1]s:%[1]s %[3]s /var/cache/crabbox
install -d /var/lib/crabbox
systemctl enable ssh || true
timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true
%[7]s
touch /var/lib/crabbox/bootstrapped
crabbox-ready
BOOT
`, cfg.SSHUser, publicKey, cfg.WorkRoot, portLines, readyChecks, writeFiles, bootstrap)
}
func windowsUserData(cfg Config, publicKey string) string {
_ = cfg
_ = publicKey
return `version: 1.1
tasks:
- task: enableOpenSsh
`
}
func windowsBootstrapHeaderPowerShell(cfg Config, publicKey, workRoot string) string {
return `
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function Retry($ScriptBlock) {
for ($i = 1; $i -le 8; $i++) {
try { & $ScriptBlock; return }
catch {
if ($i -eq 8) { throw }
Start-Sleep -Seconds ($i * 5)
}
}
}
function New-CrabboxPassword {
$bytes = New-Object byte[] 18
$rng = [Security.Cryptography.RandomNumberGenerator]::Create()
try { $rng.GetBytes($bytes) } finally { $rng.Dispose() }
return "Cb1!" + [Convert]::ToBase64String($bytes).Substring(0, 18)
}
$user = ` + psQuote(cfg.SSHUser) + `
$publicKey = ` + psQuote(publicKey) + `
$workRoot = ` + psQuote(workRoot) + `
$sshPorts = ` + windowsSSHPortsPowerShell(cfg) + `
$base = "C:\ProgramData\crabbox"
$setupCompletePath = Join-Path $base "setup-complete"
$openSSHZip = "$env:TEMP\OpenSSH-Win64.zip"
$gitInstaller = "$env:TEMP\Git-2.52.0-64-bit.exe"
New-Item -ItemType Directory -Force -Path $base, $workRoot | Out-Null
`
}
func windowsBootstrapCorePowerShell() string {
return `
if (-not (Test-Path -LiteralPath $passwordPath)) {
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $passwordPath
}
$userPassword = (Get-Content -Raw -Path $passwordPath).Trim()
if ($userPassword.Length -lt 12 -or $userPassword -notmatch '[A-Z]' -or $userPassword -notmatch '[a-z]' -or $userPassword -notmatch '[0-9]' -or $userPassword -notmatch '[^A-Za-z0-9]') {
$userPassword = New-CrabboxPassword
Set-Content -NoNewline -Encoding ASCII -Path $passwordPath -Value $userPassword
}
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
} else {
Set-LocalUser -Name $user -Password $secure -PasswordNeverExpires $true
}
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
Set-Content -NoNewline -Encoding ASCII -Path $usernamePath -Value $user
if ($passwordMirrorPath) {
Set-Content -NoNewline -Encoding ASCII -Path $passwordMirrorPath -Value $userPassword
}
$userSID = (Get-LocalUser -Name $user).SID.Value
icacls.exe $workRoot /grant "*${userSID}:(OI)(CI)F" | Out-Null
$userSSHDir = Join-Path (Join-Path "C:\Users" $user) ".ssh"
$userAuthorizedKeys = Join-Path $userSSHDir "authorized_keys"
New-Item -ItemType Directory -Force -Path $userSSHDir | Out-Null
Set-Content -Encoding ASCII -Path $userAuthorizedKeys -Value $publicKey
icacls.exe $userSSHDir /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
icacls.exe $userAuthorizedKeys /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) {
Retry { Invoke-WebRequest -Uri ` + psQuote(openSSHWin64ZipURL) + ` -OutFile $openSSHZip -UseBasicParsing }
Remove-Item -Recurse -Force "C:\Program Files\OpenSSH" -ErrorAction SilentlyContinue
Expand-Archive -LiteralPath $openSSHZip -DestinationPath "C:\Program Files" -Force
if (Test-Path -LiteralPath "C:\Program Files\OpenSSH-Win64") {
Rename-Item -LiteralPath "C:\Program Files\OpenSSH-Win64" -NewName "OpenSSH" -Force
}
& "C:\Program Files\OpenSSH\install-sshd.ps1"
}
New-Item -ItemType Directory -Force -Path "$env:ProgramData\ssh" | Out-Null
Set-Content -Encoding ASCII -Path "$env:ProgramData\ssh\administrators_authorized_keys" -Value $publicKey
icacls.exe "$env:ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
$sshdConfigPath = "$env:ProgramData\ssh\sshd_config"
$sshdConfig = ""
if (Test-Path -LiteralPath $sshdConfigPath) {
$sshdConfig = Get-Content -Raw -LiteralPath $sshdConfigPath
}
$globalLines = @()
$matchLines = @()
$inMatch = $false
foreach ($line in ($sshdConfig -split "\r?\n")) {
if ($line -match '^\s*Match\s+') { $inMatch = $true }
if (-not $inMatch -and $line -match '^\s*Port\s+\d+\s*$') { continue }
if ($enforceKeyAuth -and -not $inMatch -and $line -match '^\s*(PasswordAuthentication|PubkeyAuthentication)\s+') { continue }
if ($inMatch) { $matchLines += $line } else { $globalLines += $line }
}
foreach ($port in $sshPorts) { $globalLines += "Port $port" }
if ($enforceKeyAuth) {
$globalLines += "PubkeyAuthentication yes"
$globalLines += "PasswordAuthentication no"
}
if (($matchLines -join [Environment]::NewLine) -notmatch '(?im)^\s*Match\s+Group\s+administrators\b') {
$matchLines += "Match Group administrators"
$matchLines += " AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys"
}
Set-Content -Encoding ASCII -LiteralPath $sshdConfigPath -Value (($globalLines + $matchLines) -join [Environment]::NewLine)
foreach ($port in $sshPorts) {
$ruleName = "crabbox-sshd-$port"
if (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -Name $ruleName -DisplayName "Crabbox OpenSSH $port" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort $port | Out-Null
}
}
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
if (-not (Test-Path -LiteralPath "C:\Program Files\Git\cmd\git.exe")) {
Retry { Invoke-WebRequest -Uri ` + psQuote(gitForWindowsSetupURL) + ` -OutFile $gitInstaller -UseBasicParsing }
Start-Process -FilePath $gitInstaller -ArgumentList "/VERYSILENT","/NORESTART","/NOCANCEL","/SP-" -Wait
}
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
foreach ($path in @("C:\Program Files\OpenSSH", "C:\Program Files\Git\cmd", "C:\Program Files\Git\usr\bin")) {
if ($machinePath -notlike "*$path*") { $machinePath = "$machinePath;$path" }
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
}
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
`
}
func windowsBootstrapPowerShell(cfg Config, publicKey string) string {
workRoot := cfg.WorkRoot
if workRoot == "" {
workRoot = `C:\crabbox`
}
wslMode := cfg.WindowsMode == windowsModeWSL2
return windowsBootstrapHeaderPowerShell(cfg, publicKey, workRoot) + `
$wslMode = $` + fmt.Sprint(wslMode) + `
$wslDistro = "Crabbox"
$wslRoot = "C:\ProgramData\crabbox\wsl\Crabbox"
$wslRootfs = "C:\ProgramData\crabbox\wsl\ubuntu-noble-wsl-amd64.rootfs.tar.gz"
$wslRootfsDownload = "$wslRootfs.download"
$wslRootfsMinBytes = 100 * 1024 * 1024
$wslSetup = "C:\ProgramData\crabbox\wsl\linux-setup.sh"
$wslFeaturesMarker = "C:\ProgramData\crabbox\wsl-features-rebooted"
$wslKernelMarker = "C:\ProgramData\crabbox\wsl-kernel-rebooted"
$vncPasswordPath = "C:\ProgramData\crabbox\vnc.password"
$windowsUsernamePath = "C:\ProgramData\crabbox\windows.username"
$windowsPasswordPath = "C:\ProgramData\crabbox\windows.password"
$passwordPath = $vncPasswordPath
$usernamePath = $windowsUsernamePath
$passwordMirrorPath = $windowsPasswordPath
$enforceKeyAuth = $false
$userVNCStartupPath = "C:\ProgramData\crabbox\start-user-vnc.ps1"
$userVNCStartupCommandPath = Join-Path (Join-Path (Join-Path "C:\Users" $user) "AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup") "crabbox-user-vnc.cmd"
$tightVNCInstaller = "$env:TEMP\tightvnc-2.8.85-gpl-setup-64bit.msi"
function Restart-CrabboxBootstrap($MarkerPath) {
Set-Content -NoNewline -Encoding ASCII -Path $MarkerPath -Value (Get-Date).ToString("o")
Restart-Computer -Force
exit 0
}
function Initialize-CrabboxWSL2 {
if (-not $wslMode) { return }
$needsFeatureReboot = $false
foreach ($feature in @("Microsoft-Windows-Subsystem-Linux", "VirtualMachinePlatform", "HypervisorPlatform")) {
$state = (Get-WindowsOptionalFeature -Online -FeatureName $feature -ErrorAction SilentlyContinue).State
if ($state -ne "Enabled") {
dism.exe /online /enable-feature /featurename:$feature /all /norestart | Out-Host
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) { throw "enable $feature failed with exit $LASTEXITCODE" }
$needsFeatureReboot = $true
}
}
bcdedit.exe /set hypervisorlaunchtype auto | Out-Host
if ($LASTEXITCODE -ne 0) { throw "bcdedit hypervisorlaunchtype failed with exit $LASTEXITCODE" }
if ($needsFeatureReboot -and -not (Test-Path -LiteralPath $wslFeaturesMarker)) {
Restart-CrabboxBootstrap $wslFeaturesMarker
}
if (-not (Test-Path -LiteralPath $wslKernelMarker)) {
wsl.exe --update --web-download | Out-Host
if ($LASTEXITCODE -ne 0) { throw "wsl --update --web-download failed with exit $LASTEXITCODE" }
Restart-CrabboxBootstrap $wslKernelMarker
}
wsl.exe --set-default-version 2 | Out-Host
if ($LASTEXITCODE -ne 0) { throw "wsl --set-default-version 2 failed with exit $LASTEXITCODE" }
$distros = (wsl.exe --list --quiet 2>$null) -join [Environment]::NewLine
if ($distros -notmatch "(?m)^$([Regex]::Escape($wslDistro))$") {
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $wslRoot), $wslRoot | Out-Null
if ((Test-Path -LiteralPath $wslRootfs) -and ((Get-Item -LiteralPath $wslRootfs).Length -lt $wslRootfsMinBytes)) {
Remove-Item -Force -LiteralPath $wslRootfs
}
if (-not (Test-Path -LiteralPath $wslRootfs)) {
Remove-Item -Force -LiteralPath $wslRootfsDownload -ErrorAction SilentlyContinue
Retry {
$expectedLength = 0
try {
$head = Invoke-WebRequest -Uri ` + psQuote(ubuntuWSLRootFSURL) + ` -Method Head -UseBasicParsing
if ($head.Headers.ContainsKey("Content-Length")) {
[void][Int64]::TryParse(($head.Headers["Content-Length"] | Select-Object -First 1), [ref]$expectedLength)
}
} catch {
$expectedLength = 0
}
if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
& curl.exe -fL --retry 8 --retry-delay 5 --connect-timeout 30 --speed-time 30 --speed-limit 1024 -o $wslRootfsDownload ` + psQuote(ubuntuWSLRootFSURL) + `
if ($LASTEXITCODE -ne 0) { throw "download WSL rootfs failed with exit $LASTEXITCODE" }
} else {
Invoke-WebRequest -Uri ` + psQuote(ubuntuWSLRootFSURL) + ` -OutFile $wslRootfsDownload -UseBasicParsing
}
$actualLength = (Get-Item -LiteralPath $wslRootfsDownload).Length
if ($actualLength -lt $wslRootfsMinBytes) { throw "downloaded WSL rootfs is incomplete" }
if ($expectedLength -gt 0 -and $actualLength -ne $expectedLength) {
throw "downloaded WSL rootfs is incomplete: $actualLength of $expectedLength bytes"
}
}
Move-Item -Force -LiteralPath $wslRootfsDownload -Destination $wslRootfs
}
wsl.exe --import $wslDistro $wslRoot $wslRootfs --version 2 | Out-Host
if ($LASTEXITCODE -ne 0) { throw "wsl --import failed with exit $LASTEXITCODE" }
wsl.exe --set-default $wslDistro | Out-Host
if ($LASTEXITCODE -ne 0) { throw "wsl --set-default failed with exit $LASTEXITCODE" }
}
$linuxSetup = @'
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
mkdir -p ` + shellQuote(workRoot) + ` /var/cache/crabbox/pnpm /var/cache/crabbox/npm /var/lib/crabbox
cat >/etc/apt/apt.conf.d/80-crabbox-retries <<'APT'
Acquire::Retries "8";
Acquire::http::Timeout "30";
Acquire::https::Timeout "30";
APT
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl git rsync jq
cat >/usr/local/bin/crabbox-ready <<'READY'
#!/usr/bin/env bash
set -euo pipefail
git --version >/dev/null
rsync --version >/dev/null
curl --version >/dev/null
jq --version >/dev/null
test -w ` + shellQuote(workRoot) + `
READY
chmod 0755 /usr/local/bin/crabbox-ready
touch /var/lib/crabbox/bootstrapped
crabbox-ready
'@
$linuxSetup = $linuxSetup.Replace(([string][char]13 + [string][char]10), ([string][char]10))
[IO.File]::WriteAllText($wslSetup, $linuxSetup, (New-Object Text.UTF8Encoding($false)))
wsl.exe -d $wslDistro --user root --exec bash /mnt/c/ProgramData/crabbox/wsl/linux-setup.sh
if ($LASTEXITCODE -ne 0) { throw "WSL setup failed with exit $LASTEXITCODE" }
}
` + windowsBootstrapCorePowerShell() + `
Initialize-CrabboxWSL2
if (-not (Test-Path -LiteralPath "C:\Program Files\TightVNC\tvnserver.exe")) {
Retry { Invoke-WebRequest -Uri ` + psQuote(tightVNCMSIURL) + ` -OutFile $tightVNCInstaller -UseBasicParsing }
$vncPassword = Get-Content -Raw -Path $vncPasswordPath
Start-Process -FilePath msiexec.exe -ArgumentList @(
"/i", $tightVNCInstaller, "/quiet", "/norestart",
"ADDLOCAL=Server",
"SERVER_REGISTER_AS_SERVICE=1",
"SERVER_ADD_FIREWALL_EXCEPTION=0",
"SET_USEVNCAUTHENTICATION=1", "VALUE_OF_USEVNCAUTHENTICATION=1",
"SET_PASSWORD=1", "VALUE_OF_PASSWORD=$vncPassword",
"SET_USECONTROLAUTHENTICATION=1", "VALUE_OF_USECONTROLAUTHENTICATION=1",
"SET_CONTROLPASSWORD=1", "VALUE_OF_CONTROLPASSWORD=$vncPassword",
"SET_ALLOWLOOPBACK=1", "VALUE_OF_ALLOWLOOPBACK=1",
"SET_ACCEPTHTTPCONNECTIONS=1", "VALUE_OF_ACCEPTHTTPCONNECTIONS=0"
) -Wait
}
$userVNCStartup = @'
$ErrorActionPreference = "SilentlyContinue"
$base = "C:\ProgramData\crabbox"
$password = (Get-Content -Raw -LiteralPath (Join-Path $base "vnc.password")).Trim()
$serverKey = "HKCU:\Software\TightVNC\Server"
$serviceKey = "HKLM:\Software\TightVNC\Server"
$serviceConfig = Get-ItemProperty -Path $serviceKey -ErrorAction SilentlyContinue
function Set-TightVNCBinaryValue($Name) {
$hex = ""
if ($serviceConfig -and $serviceConfig.$Name) {
$bytes = [byte[]]$serviceConfig.$Name
if ($bytes -and $bytes.Length -gt 0) {
$hex = -join ($bytes | ForEach-Object { $_.ToString("X2") })
}
}
if ($hex) {
& reg.exe add "HKCU\Software\TightVNC\Server" /v $Name /t REG_BINARY /d $hex /f | Out-Null
}
}
New-Item -Force -Path $serverKey | Out-Null
New-ItemProperty -Force -Path $serverKey -Name UseVncAuthentication -PropertyType DWord -Value 1 | Out-Null
Set-TightVNCBinaryValue "Password"
New-ItemProperty -Force -Path $serverKey -Name UseControlAuthentication -PropertyType DWord -Value 1 | Out-Null
Set-TightVNCBinaryValue "ControlPassword"
New-ItemProperty -Force -Path $serverKey -Name AllowLoopback -PropertyType DWord -Value 1 | Out-Null
New-ItemProperty -Force -Path $serverKey -Name AcceptHttpConnections -PropertyType DWord -Value 0 | Out-Null
$exe = "C:\Program Files\TightVNC\tvnserver.exe"
Get-Process tvnserver -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -eq (Get-Process -Id $PID).SessionId } | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Process -FilePath $exe -ArgumentList "-run" -WindowStyle Minimized
'@
Set-Content -Encoding UTF8 -LiteralPath $userVNCStartupPath -Value $userVNCStartup
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $userVNCStartupCommandPath) | Out-Null
Set-Content -Encoding ASCII -LiteralPath $userVNCStartupCommandPath -Value ('@echo off' + [Environment]::NewLine + 'powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $userVNCStartupPath + '"' + [Environment]::NewLine)
$startupTask = "CrabboxUserVNC"
cmd.exe /c "schtasks.exe /Delete /TN $startupTask /F 2>NUL" | Out-Null
schtasks.exe /Create /TN $startupTask /SC ONCE /ST ((Get-Date).AddMinutes(1).ToString("HH:mm")) /TR "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File $userVNCStartupPath" /RU $user /IT /F | Out-Null
Get-Service -Name tvnserver -ErrorAction SilentlyContinue | Set-Service -StartupType Disabled
Stop-Service -Name tvnserver -Force -ErrorAction SilentlyContinue
$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
Set-ItemProperty -Path $winlogon -Name AutoAdminLogon -Value "1" -Type String
Set-ItemProperty -Path $winlogon -Name ForceAutoLogon -Value "1" -Type String
Set-ItemProperty -Path $winlogon -Name DefaultUserName -Value $user -Type String
Set-ItemProperty -Path $winlogon -Name DefaultPassword -Value $userPassword -Type String
Restart-Service sshd
if (-not (Test-Path -LiteralPath $setupCompletePath)) {
Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath -Value (Get-Date).ToString("o")
Restart-Computer -Force
}
`
}
func azureWindowsBootstrapPowerShell(cfg Config, publicKey string) string {
workRoot := cfg.WorkRoot
if workRoot == "" {
workRoot = defaultWindowsWorkRoot
}
return windowsBootstrapHeaderPowerShell(cfg, publicKey, workRoot) + `
$passwordPath = Join-Path $base "windows.password"
$usernamePath = Join-Path $base "windows.username"
$passwordMirrorPath = $null
$enforceKeyAuth = $true
` + windowsBootstrapCorePowerShell() + `
Restart-Service sshd -Force
git --version | Out-Null
tar --version | Out-Null
Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath -Value (Get-Date).ToString("o")
`
}
func windowsSSHPortsPowerShell(cfg Config) string {
ports := sshPortCandidates(cfg.SSHPort, cfg.SSHFallbackPorts)
quoted := make([]string, 0, len(ports))
for _, port := range ports {
quoted = append(quoted, psQuote(port))
}
return "@(" + strings.Join(quoted, ", ") + ")"
}
func macOSUserData(cfg Config, _ string) string {
workRoot := cfg.WorkRoot
if workRoot == "" {
workRoot = defaultMacOSWorkRoot
}
return `#!/bin/bash
set -euxo pipefail
install -d -m 0755 ` + shellQuote(workRoot) + ` /var/db/crabbox
chown -R ` + shellQuote(cfg.SSHUser) + `:staff ` + shellQuote(workRoot) + `
if [ ! -s /var/db/crabbox/vnc.password ]; then
set +o pipefail
pw="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
set -o pipefail
if [ "${#pw}" -ne 16 ]; then
echo "failed to generate vnc password" >&2
exit 1
fi
printf '%s\n' "$pw" >/var/db/crabbox/vnc.password
dscl . -passwd /Users/` + shellQuote(cfg.SSHUser) + ` "$pw"
fi
chmod 0600 /var/db/crabbox/vnc.password
launchctl enable system/com.apple.screensharing || true
launchctl load -w /System/Library/LaunchDaemons/com.apple.screensharing.plist || true
cat >/usr/local/bin/crabbox-ready <<'READY'
#!/bin/bash
set -euo pipefail
rsync --version >/dev/null
curl --version >/dev/null
test -w ` + shellQuote(workRoot) + `
nc -z 127.0.0.1 5900
READY
chmod 0755 /usr/local/bin/crabbox-ready
/usr/local/bin/crabbox-ready
`
}
func cloudInitOptionalReadyChecks(cfg Config) string {
var b strings.Builder
if cfg.Tailscale.Enabled {
b.WriteString(" test -s /var/lib/crabbox/tailscale-ipv4\n")
b.WriteString(" grep -Eq '^100\\.' /var/lib/crabbox/tailscale-ipv4\n")
}
if cfg.Desktop {
b.WriteString(" systemctl is-active --quiet crabbox-xvfb.service\n")
b.WriteString(" systemctl is-active --quiet crabbox-desktop.service\n")
b.WriteString(" systemctl is-active --quiet crabbox-desktop-session.service\n")
b.WriteString(" systemctl is-active --quiet crabbox-x11vnc.service\n")
b.WriteString(" ss -ltn | grep -q '127.0.0.1:5900'\n")
}
if cfg.Browser {
b.WriteString(" test -s /var/lib/crabbox/browser.env\n")
b.WriteString(" . /var/lib/crabbox/browser.env\n")
b.WriteString(" test -x \"$BROWSER\"\n")
b.WriteString(" \"$BROWSER\" --version >/dev/null\n")
}
if cfg.Code {
b.WriteString(" test -x /usr/local/bin/code-server\n")
b.WriteString(" /usr/local/bin/code-server --version >/dev/null\n")
}
return strings.TrimRight(b.String(), "\n")
}
func cloudInitOptionalWriteFiles(cfg Config) string {
if !cfg.Desktop {
return ""
}
return ` - path: /etc/systemd/system/crabbox-xvfb.service
permissions: '0644'
content: |
[Unit]
Description=Crabbox Xvfb display
After=network.target
[Service]
User=crabbox
ExecStart=/usr/bin/Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp -ac
Restart=always
[Install]
WantedBy=multi-user.target
- path: /etc/systemd/system/crabbox-desktop.service
permissions: '0644'
content: |
[Unit]
Description=Crabbox XFCE desktop session
After=crabbox-xvfb.service
Requires=crabbox-xvfb.service
[Service]
User=crabbox
Environment=DISPLAY=:99
ExecStart=/usr/bin/startxfce4
Restart=always
[Install]
WantedBy=multi-user.target
- path: /usr/local/bin/crabbox-desktop-session
permissions: '0755'
content: |
#!/bin/sh
set -eu
export DISPLAY="${DISPLAY:-:99}"
if command -v xsetroot >/dev/null 2>&1; then
xsetroot -solid '#20242b' || true
fi
if command -v xfce4-terminal >/dev/null 2>&1 && ! pgrep -u "$(id -u)" -f 'xfce4-terminal.*Crabbox Desktop' >/dev/null 2>&1; then
xfce4-terminal --title='Crabbox Desktop' --geometry=110x32+48+48 &
elif command -v xterm >/dev/null 2>&1 && ! pgrep -u "$(id -u)" -f 'xterm -title Crabbox Desktop' >/dev/null 2>&1; then
xterm -title 'Crabbox Desktop' -geometry 110x32+48+48 -bg '#111827' -fg '#e5e7eb' &
fi
tail -f /dev/null
- path: /etc/systemd/system/crabbox-desktop-session.service
permissions: '0644'
content: |
[Unit]
Description=Crabbox visible desktop helper
After=crabbox-desktop.service
Requires=crabbox-xvfb.service crabbox-desktop.service
[Service]
User=crabbox
Environment=DISPLAY=:99
ExecStart=/usr/local/bin/crabbox-desktop-session
Restart=always
[Install]
WantedBy=multi-user.target
- path: /etc/systemd/system/crabbox-x11vnc.service
permissions: '0644'
content: |
[Unit]
Description=Crabbox loopback VNC server
After=crabbox-xvfb.service
Requires=crabbox-xvfb.service
[Service]
User=crabbox
ExecStart=/usr/bin/x11vnc -display :99 -localhost -rfbport 5900 -forever -shared -rfbauth /var/lib/crabbox/vnc.pass
Restart=always
[Install]
WantedBy=multi-user.target
`
}
func cloudInitOptionalBootstrap(cfg Config) string {
var parts []string
if cfg.Tailscale.Enabled {
parts = append(parts, cloudInitTailscaleBootstrap(cfg))
}
if cfg.Desktop {
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4-session xfwm4 xfce4-panel xfdesktop4 xfce4-terminal xfconf xfce4-settings x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl xclip xsel fonts-dejavu-core fonts-liberation iproute2 openssl
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
if [ ! -s /var/lib/crabbox/vnc.password ]; then
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)
fi
x11vnc -storepasswd "$(cat /var/lib/crabbox/vnc.password)" /var/lib/crabbox/vnc.pass >/dev/null
chown crabbox:crabbox /var/lib/crabbox/vnc.password /var/lib/crabbox/vnc.pass
chmod 0600 /var/lib/crabbox/vnc.password /var/lib/crabbox/vnc.pass
systemctl daemon-reload
systemctl enable --now crabbox-xvfb.service crabbox-desktop.service crabbox-desktop-session.service crabbox-x11vnc.service`)
}
if cfg.Browser {
parts = append(parts, ` retry apt-get install -y --no-install-recommends gnupg build-essential python3
browser_path=""
if [ "$(dpkg --print-architecture)" = "amd64" ]; then
install -d -m 0755 /etc/apt/trusted.gpg.d
curl -fsSL https://dl.google.com/linux/linux_signing_key.pub > /etc/apt/trusted.gpg.d/google.asc
chmod 0644 /etc/apt/trusted.gpg.d/google.asc
echo "deb [arch=amd64] https://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list
if apt-get update && retry apt-get install -y --no-install-recommends google-chrome-stable; then
browser_path="$(command -v google-chrome || true)"
else
rm -f /etc/apt/sources.list.d/google-chrome.list
retry apt-get update || true
fi
fi
if [ -z "$browser_path" ]; then
if apt-cache show chromium >/dev/null 2>&1 && retry apt-get install -y --no-install-recommends chromium; then
browser_path="$(command -v chromium || true)"
elif apt-cache show chromium-browser >/dev/null 2>&1 && retry apt-get install -y --no-install-recommends chromium-browser; then
browser_path="$(command -v chromium-browser || true)"
fi
fi
if [ -n "$browser_path" ]; then
browser_wrapper=/usr/local/bin/crabbox-browser
install -d -m 0755 /etc/opt/chrome/policies/managed /etc/chromium/policies/managed
printf '%s\n' '{"DefaultBrowserSettingEnabled":false,"MetricsReportingEnabled":false,"PromotionalTabsEnabled":false}' > /etc/opt/chrome/policies/managed/crabbox.json
cp /etc/opt/chrome/policies/managed/crabbox.json /etc/chromium/policies/managed/crabbox.json
printf '%s\n' '#!/bin/sh' "exec \"$browser_path\" --no-first-run --no-default-browser-check --disable-default-apps --window-size=1500,900 --window-position=80,80 \"\$@\"" > "$browser_wrapper"
chmod 0755 "$browser_wrapper"
printf 'CHROME_BIN=%s\nBROWSER=%s\n' "$browser_wrapper" "$browser_wrapper" > /var/lib/crabbox/browser.env
chown crabbox:crabbox /var/lib/crabbox/browser.env
chmod 0644 /var/lib/crabbox/browser.env
fi`)
}
if cfg.Code {
parts = append(parts, ` retry apt-get install -y --no-install-recommends libatomic1
retry env HOME=/root sh -c 'curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/usr/local'
/usr/local/bin/code-server --version >/dev/null`)
}
return strings.Join(parts, "\n")
}
func cloudInitTailscaleBootstrap(cfg Config) string {
authKey := strings.TrimSpace(cfg.Tailscale.AuthKey)
hostname := strings.TrimSpace(cfg.Tailscale.Hostname)
if hostname == "" {
hostname = renderTailscaleHostname(cfg.Tailscale.HostnameTemplate, "", "lease", cfg.Provider)
}
sshUser := strings.TrimSpace(cfg.SSHUser)
if sshUser == "" {
sshUser = "crabbox"
}
sshUserOwner := shellQuote(sshUser)
sshUserGroup := shellQuote(sshUser)
sshUserChown := shellQuote(sshUser + ":" + sshUser)
tags := strings.Join(cfg.Tailscale.Tags, ",")
tailscaleUpArgs := []string{
"--auth-key=\"$TS_AUTHKEY\"",
"--hostname=" + shellQuote(hostname),
"--advertise-tags=" + shellQuote(tags),
}
exitNode := strings.TrimSpace(cfg.Tailscale.ExitNode)
if exitNode != "" {
tailscaleUpArgs = append(tailscaleUpArgs, "--exit-node="+shellQuote(exitNode))
if cfg.Tailscale.ExitNodeAllowLANAccess {
tailscaleUpArgs = append(tailscaleUpArgs, "--exit-node-allow-lan-access")
}
}
if authKey == "" {
return ` echo "tailscale requested but no auth key was injected" >&2
exit 1`
}
return ` retry sh -c 'curl -fsSL https://tailscale.com/install.sh | sh'
systemctl enable --now tailscaled || service tailscaled start || true
install -d -m 0750 -o ` + sshUserOwner + ` -g ` + sshUserGroup + ` /var/lib/crabbox
set +x
TS_AUTHKEY=` + shellQuote(authKey) + `
tailscale up ` + strings.Join(tailscaleUpArgs, " ") + `
unset TS_AUTHKEY
set -x
ts_ip=""
for _ in $(seq 1 24); do
ts_ip="$(tailscale ip -4 2>/dev/null | head -n1 || true)"
if [ -n "$ts_ip" ]; then break; fi
sleep 5
done
test -n "$ts_ip"
printf '%s\n' "$ts_ip" > /var/lib/crabbox/tailscale-ipv4
printf '%s\n' ` + shellQuote(hostname) + ` > /var/lib/crabbox/tailscale-hostname
if [ -n ` + shellQuote(exitNode) + ` ]; then
printf '%s\n' ` + shellQuote(exitNode) + ` > /var/lib/crabbox/tailscale-exit-node
printf '%s\n' ` + shellQuote(fmt.Sprint(cfg.Tailscale.ExitNodeAllowLANAccess)) + ` > /var/lib/crabbox/tailscale-exit-node-allow-lan-access
fi
if tailscale status --json >/var/lib/crabbox/tailscale-status.json 2>/dev/null; then
jq -r '.Self.DNSName // empty' /var/lib/crabbox/tailscale-status.json > /var/lib/crabbox/tailscale-fqdn || true
fi
chown ` + sshUserChown + ` /var/lib/crabbox/tailscale-* || true
chmod 0640 /var/lib/crabbox/tailscale-* || true`
}