feat: support AWS desktop targets

This commit is contained in:
Peter Steinberger 2026-05-04 02:22:42 +01:00
parent d79cba3fa5
commit ab7fc6fd71
No known key found for this signature in database
14 changed files with 451 additions and 27 deletions

View File

@ -183,7 +183,7 @@ func (c *AWSClient) createServer(ctx context.Context, cfg Config, publicKey, lea
name := leaseProviderName(leaseID, slug)
now := time.Now().UTC()
labels := directLeaseLabels(cfg, leaseID, slug, "aws", mapMarket(spot), keep, now)
userData := base64.StdEncoding.EncodeToString([]byte(cloudInit(cfg, publicKey)))
userData := base64.StdEncoding.EncodeToString([]byte(awsUserData(cfg, publicKey)))
rootGB := cfg.AWSRootGB
if rootGB <= 0 {
rootGB = 400
@ -240,6 +240,9 @@ func (c *AWSClient) createServer(ctx context.Context, cfg Config, publicKey, lea
},
}
}
if cfg.TargetOS == targetMacOS {
input.Placement = &types.Placement{HostId: aws.String(cfg.AWSMacHostID), Tenancy: types.TenancyHost}
}
out, err := c.ec2.RunInstances(ctx, input)
if err != nil {
return Server{}, err
@ -308,6 +311,15 @@ func (c *AWSClient) resolveAMI(ctx context.Context, cfg Config) (string, error)
if cfg.AWSAMI != "" {
return cfg.AWSAMI, nil
}
if cfg.TargetOS == targetWindows && cfg.WindowsMode == windowsModeNormal {
return "resolve:ssm:/aws/service/ami-windows-latest/Windows_Server-2022-English-Full-Base", nil
}
if cfg.TargetOS == targetMacOS {
if strings.HasPrefix(cfg.ServerType, "mac1.") {
return "resolve:ssm:/aws/service/ec2-macos/sonoma/x86_64_mac/latest/image_id", nil
}
return "resolve:ssm:/aws/service/ec2-macos/sonoma/arm64_mac/latest/image_id", nil
}
out, err := c.ec2.DescribeImages(ctx, &ec2.DescribeImagesInput{
Owners: []string{awsUbuntuOwner},
Filters: []types.Filter{
@ -494,7 +506,14 @@ func awsLaunchCandidates(cfg Config) []string {
if cfg.ServerTypeExplicit {
return []string{cfg.ServerType}
}
return appendUniqueStrings([]string{cfg.ServerType}, append(awsInstanceTypeCandidatesForClass(cfg.Class), "t3.small")...)
if cfg.TargetOS == targetMacOS {
return appendUniqueStrings([]string{cfg.ServerType}, awsInstanceTypeCandidatesForTargetClass(cfg.TargetOS, cfg.Class)...)
}
fallback := "t3.small"
if cfg.TargetOS == targetWindows {
fallback = "t3.large"
}
return appendUniqueStrings([]string{cfg.ServerType}, append(awsInstanceTypeCandidatesForTargetClass(cfg.TargetOS, cfg.Class), fallback)...)
}
func parsePort32(port string) (int32, bool) {

View File

@ -5,6 +5,22 @@ import (
"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"
)
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) {
@ -75,6 +91,115 @@ runcmd:
`, cfg.SSHUser, publicKey, cfg.WorkRoot, portLines, readyChecks, writeFiles, bootstrap)
}
func windowsUserData(cfg Config, publicKey string) string {
workRoot := cfg.WorkRoot
if workRoot == "" {
workRoot = `C:\crabbox`
}
return `<powershell>
$ErrorActionPreference = "Stop"
[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)
}
}
}
$user = ` + psQuote(cfg.SSHUser) + `
$publicKey = ` + psQuote(publicKey) + `
$workRoot = ` + psQuote(workRoot) + `
$vncPasswordPath = "C:\ProgramData\crabbox\vnc.password"
$gitInstaller = "$env:TEMP\Git-2.52.0-64-bit.exe"
$tightVNCInstaller = "$env:TEMP\tightvnc-2.8.85-gpl-setup-64bit.msi"
New-Item -ItemType Directory -Force -Path "C:\ProgramData\crabbox", $workRoot | Out-Null
if (-not (Test-Path -LiteralPath $vncPasswordPath)) {
$bytes = New-Object byte[] 12
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
[Convert]::ToBase64String($bytes).Substring(0, 12) | Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath
}
$userPassword = [Guid]::NewGuid().ToString("N") + "aA1!"
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
}
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
$ssh = Get-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
if ($ssh.State -ne "Installed") {
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 | Out-Null
}
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
if (-not (Get-NetFirewallRule -Name "crabbox-sshd" -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -Name "crabbox-sshd" -DisplayName "Crabbox OpenSSH" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | 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\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")
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
}
Get-Service -Name tvnserver -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic
Start-Service -Name tvnserver -ErrorAction SilentlyContinue
Restart-Service sshd
</powershell>`
}
func macOSUserData(cfg Config, _ string) string {
workRoot := cfg.WorkRoot
if workRoot == "" {
workRoot = "/work/crabbox"
}
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
pw="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
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.Desktop {

View File

@ -9,10 +9,12 @@ import (
)
const (
desktopDisplay = ":99"
managedVNCPort = "5900"
vncPasswordPath = "/var/lib/crabbox/vnc.password"
browserEnvPath = "/var/lib/crabbox/browser.env"
desktopDisplay = ":99"
managedVNCPort = "5900"
vncPasswordPath = "/var/lib/crabbox/vnc.password"
windowsVNCPasswordPath = `C:\ProgramData\crabbox\vnc.password`
macOSVNCPasswordPath = "/var/db/crabbox/vnc.password"
browserEnvPath = "/var/lib/crabbox/browser.env"
)
type vncEndpoint struct {
@ -237,6 +239,16 @@ if (-not $result.TcpTestSucceeded) { exit 1 }`)
return "ss -ltn | grep -q '127.0.0.1:5900'"
}
func vncPasswordCommand(target SSHTarget) string {
if isWindowsNativeTarget(target) {
return powershellCommand("Get-Content -Raw -LiteralPath " + psQuote(windowsVNCPasswordPath))
}
if target.TargetOS == targetMacOS {
return "cat " + shellQuote(macOSVNCPasswordPath)
}
return "cat " + shellQuote(vncPasswordPath)
}
func tcpReachable(ctx context.Context, host, port string, timeout time.Duration) bool {
if host == "" || port == "" {
return false

View File

@ -35,6 +35,7 @@ type Config struct {
AWSProfile string
AWSRootGB int32
AWSSSHCIDRs []string
AWSMacHostID string
SSHUser string
SSHKey string
SSHPort string
@ -145,7 +146,7 @@ func loadConfig() (Config, error) {
return Config{}, err
}
if cfg.ServerType == "" {
cfg.ServerType = serverTypeForProviderClass(cfg.Provider, cfg.Class)
cfg.ServerType = serverTypeForConfig(cfg)
}
return cfg, nil
}
@ -271,6 +272,7 @@ type fileAWSConfig struct {
InstanceProfile string `yaml:"instanceProfile,omitempty"`
RootGB int32 `yaml:"rootGB,omitempty"`
SSHCIDRs []string `yaml:"sshCIDRs,omitempty"`
MacHostID string `yaml:"macHostId,omitempty"`
}
type fileSSHConfig struct {
@ -543,6 +545,9 @@ func applyFileConfig(cfg *Config, file fileConfig) {
if len(file.AWS.SSHCIDRs) > 0 {
cfg.AWSSSHCIDRs = file.AWS.SSHCIDRs
}
if file.AWS.MacHostID != "" {
cfg.AWSMacHostID = file.AWS.MacHostID
}
}
if file.SSH != nil {
if file.SSH.User != "" {
@ -754,6 +759,7 @@ func applyEnv(cfg *Config) {
cfg.AWSSubnetID = getenv("CRABBOX_AWS_SUBNET_ID", cfg.AWSSubnetID)
cfg.AWSProfile = getenv("CRABBOX_AWS_INSTANCE_PROFILE", cfg.AWSProfile)
cfg.AWSRootGB = int32(getenvInt("CRABBOX_AWS_ROOT_GB", int(cfg.AWSRootGB)))
cfg.AWSMacHostID = getenv("CRABBOX_AWS_MAC_HOST_ID", cfg.AWSMacHostID)
if cidrs := os.Getenv("CRABBOX_AWS_SSH_CIDRS"); cidrs != "" {
cfg.AWSSSHCIDRs = splitCommaList(cidrs)
}
@ -876,6 +882,16 @@ func serverTypeForClass(class string) string {
return serverTypeCandidatesForClass(class)[0]
}
func serverTypeForConfig(cfg Config) string {
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
return ""
}
if cfg.Provider == "aws" {
return awsInstanceTypeCandidatesForTargetClass(cfg.TargetOS, cfg.Class)[0]
}
return serverTypeForClass(cfg.Class)
}
func serverTypeForProviderClass(provider, class string) string {
if isBlacksmithProvider(provider) || isStaticProvider(provider) {
return ""
@ -901,6 +917,28 @@ func serverTypeCandidatesForClass(class string) []string {
}
}
func awsInstanceTypeCandidatesForTargetClass(target, class string) []string {
switch target {
case targetMacOS:
return []string{"mac2.metal"}
case targetWindows:
switch class {
case "standard":
return []string{"m7i.large", "m7a.large", "t3.large"}
case "fast":
return []string{"m7i.xlarge", "m7a.xlarge", "t3.xlarge"}
case "large":
return []string{"m7i.2xlarge", "m7a.2xlarge", "t3.2xlarge"}
case "beast":
return []string{"m7i.4xlarge", "m7a.4xlarge", "m7i.2xlarge"}
default:
return []string{class}
}
default:
return awsInstanceTypeCandidatesForClass(class)
}
}
func awsInstanceTypeCandidatesForClass(class string) []string {
switch class {
case "standard":

View File

@ -138,8 +138,8 @@ ssh:
if cfg.TargetOS != targetWindows || cfg.WindowsMode != windowsModeWSL2 {
t.Fatalf("target config not loaded: target=%s windowsMode=%s", cfg.TargetOS, cfg.WindowsMode)
}
if cfg.ServerType != "c7a.8xlarge" {
t.Fatalf("ServerType=%q want c7a.8xlarge", cfg.ServerType)
if cfg.ServerType != "m7i.large" {
t.Fatalf("ServerType=%q want m7i.large", cfg.ServerType)
}
if cfg.Coordinator != "https://crabbox.example.test" || cfg.CoordToken != "secret" || cfg.CoordAdminToken != "admin-secret" {
t.Fatalf("broker config not loaded: %#v", cfg)

View File

@ -320,6 +320,7 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
"awsProfile": cfg.AWSProfile,
"awsRootGB": cfg.AWSRootGB,
"awsSSHCIDRs": cfg.AWSSSHCIDRs,
"awsMacHostID": cfg.AWSMacHostID,
"capacity": map[string]any{
"market": cfg.Capacity.Market,
"strategy": cfg.Capacity.Strategy,

View File

@ -44,8 +44,8 @@ func (a App) screenshot(ctx context.Context, args []string) error {
if err != nil {
return err
}
if target.TargetOS != targetLinux {
return exit(2, "desktop screenshots are currently supported for Linux desktop leases only; target=%s is not a Crabbox-created desktop", target.TargetOS)
if isStaticProvider(cfg.Provider) && target.TargetOS != targetLinux {
return exit(2, "desktop screenshots are not captured from static %s hosts because those are existing host machines, not Crabbox-created desktops", target.TargetOS)
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
@ -98,7 +98,7 @@ func captureDesktopScreenshot(ctx context.Context, target SSHTarget, outputPath
_ = os.Remove(outputPath)
}
}()
if err := runSSHToWriter(ctx, target, screenshotRemoteCommand(), file); err != nil {
if err := runSSHToWriter(ctx, target, screenshotRemoteCommand(target), file); err != nil {
return exit(5, "capture screenshot: %v", err)
}
ok = true
@ -121,7 +121,32 @@ func runSSHToWriter(ctx context.Context, target SSHTarget, remote string, stdout
return nil
}
func screenshotRemoteCommand() string {
func screenshotRemoteCommand(target SSHTarget) string {
if isWindowsNativeTarget(target) {
return `$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$stream = New-Object System.IO.MemoryStream
$bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
$bytes = $stream.ToArray()
[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)
$graphics.Dispose()
$bitmap.Dispose()
$stream.Dispose()`
}
if target.TargetOS == targetMacOS {
return `set -eu
if command -v screencapture >/dev/null 2>&1; then
screencapture -x -t png -
else
echo "no screenshot tool found; EC2 macOS should provide screencapture" >&2
exit 127
fi`
}
return `set -eu
export DISPLAY="${DISPLAY:-:99}"
if command -v scrot >/dev/null 2>&1; then

View File

@ -15,7 +15,7 @@ func TestDefaultScreenshotPath(t *testing.T) {
}
func TestScreenshotRemoteCommandUsesDesktopDisplayAndPNG(t *testing.T) {
got := screenshotRemoteCommand()
got := screenshotRemoteCommand(SSHTarget{TargetOS: targetLinux})
for _, want := range []string{
`DISPLAY="${DISPLAY:-:99}"`,
"command -v scrot",

View File

@ -24,6 +24,9 @@ func normalizeTargetConfig(cfg *Config) {
cfg.WorkRoot = `C:\crabbox`
}
}
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS && cfg.SSHUser == baseConfig().SSHUser {
cfg.SSHUser = "ec2-user"
}
if cfg.Static.User != "" && cfg.SSHUser == baseConfig().SSHUser {
cfg.SSHUser = cfg.Static.User
}
@ -82,6 +85,18 @@ func validateProviderTarget(cfg Config) error {
if isStaticProvider(cfg.Provider) || isBlacksmithProvider(cfg.Provider) {
return nil
}
if cfg.Provider == "aws" && cfg.TargetOS == targetWindows && cfg.WindowsMode == windowsModeNormal {
return nil
}
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS {
if cfg.AWSMacHostID == "" {
return exit(2, "provider=aws target=macos requires CRABBOX_AWS_MAC_HOST_ID or aws.macHostId for an allocated EC2 Mac Dedicated Host")
}
if cfg.Capacity.Market != "on-demand" {
return exit(2, "provider=aws target=macos requires --market on-demand; EC2 Mac instances are not Spot")
}
return nil
}
if cfg.TargetOS != targetLinux {
return exit(2, "provider=%s currently supports target=linux only; use provider=ssh for target=%s", cfg.Provider, cfg.TargetOS)
}

View File

@ -5,15 +5,36 @@ import (
"testing"
)
func TestValidateProviderTargetRejectsBrokeredNonLinux(t *testing.T) {
for _, target := range []string{targetMacOS, targetWindows} {
func TestValidateProviderTargetRejectsUnsupportedAWSTargets(t *testing.T) {
t.Run("macOS needs dedicated host", func(t *testing.T) {
cfg := baseConfig()
cfg.Provider = "aws"
cfg.TargetOS = target
cfg.TargetOS = targetMacOS
err := validateProviderTarget(cfg)
if err == nil || !strings.Contains(err.Error(), "requires CRABBOX_AWS_MAC_HOST_ID") {
t.Fatalf("err=%v", err)
}
})
t.Run("Windows WSL2 is not brokered by AWS", func(t *testing.T) {
cfg := baseConfig()
cfg.Provider = "aws"
cfg.TargetOS = targetWindows
cfg.WindowsMode = windowsModeWSL2
err := validateProviderTarget(cfg)
if err == nil || !strings.Contains(err.Error(), "currently supports target=linux only") {
t.Fatalf("target=%s err=%v", target, err)
t.Fatalf("err=%v", err)
}
})
}
func TestValidateProviderTargetAllowsAWSNativeWindows(t *testing.T) {
cfg := baseConfig()
cfg.Provider = "aws"
cfg.TargetOS = targetWindows
cfg.WindowsMode = windowsModeNormal
if err := validateProviderTarget(cfg); err != nil {
t.Fatalf("err=%v", err)
}
}

View File

@ -67,12 +67,10 @@ func (a App) vnc(ctx context.Context, args []string) error {
}
password := ""
if endpoint.Managed {
if target.TargetOS == targetLinux {
password, _ = runSSHOutput(ctx, target, "cat "+shellQuote(vncPasswordPath))
}
password, _ = runSSHOutput(ctx, target, vncPasswordCommand(target))
}
if target.TargetOS == targetLinux && !isStaticProvider(cfg.Provider) && password == "" {
password, _ = runSSHOutput(ctx, target, "cat "+shellQuote(vncPasswordPath))
if !isStaticProvider(cfg.Provider) && password == "" {
password, _ = runSSHOutput(ctx, target, vncPasswordCommand(target))
}
tunnel := vncTunnelCommand(target, *localPort)
staticHostVNC := isStaticProvider(cfg.Provider) && !endpoint.Managed

View File

@ -1,5 +1,20 @@
import { sshPorts, type LeaseConfig } from "./config";
const tightVNCMSIURL =
"https://www.tightvnc.com/download/2.8.85/tightvnc-2.8.85-gpl-setup-64bit.msi";
const gitForWindowsSetupURL =
"https://github.com/git-for-windows/git/releases/download/v2.52.0.windows.1/Git-2.52.0-64-bit.exe";
export function awsUserData(config: LeaseConfig): string {
if (config.target === "windows") {
return windowsUserData(config);
}
if (config.target === "macos") {
return macOSUserData(config);
}
return cloudInit(config);
}
export function cloudInit(config: LeaseConfig): string {
const portLines = sshPorts(config)
.map((port) => ` Port ${port}`)
@ -69,6 +84,107 @@ ${bootstrap}
`;
}
export function windowsUserData(config: LeaseConfig): string {
return `<powershell>
$ErrorActionPreference = "Stop"
[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)
}
}
}
$user = ${psQuote(config.sshUser)}
$publicKey = ${psQuote(config.sshPublicKey)}
$workRoot = ${psQuote(config.workRoot)}
$vncPasswordPath = "C:\\ProgramData\\crabbox\\vnc.password"
$gitInstaller = "$env:TEMP\\Git-2.52.0-64-bit.exe"
$tightVNCInstaller = "$env:TEMP\\tightvnc-2.8.85-gpl-setup-64bit.msi"
New-Item -ItemType Directory -Force -Path "C:\\ProgramData\\crabbox", $workRoot | Out-Null
if (-not (Test-Path -LiteralPath $vncPasswordPath)) {
$bytes = New-Object byte[] 12
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
[Convert]::ToBase64String($bytes).Substring(0, 12) | Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath
}
$userPassword = [Guid]::NewGuid().ToString("N") + "aA1!"
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
}
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
$ssh = Get-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
if ($ssh.State -ne "Installed") {
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 | Out-Null
}
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
if (-not (Get-NetFirewallRule -Name "crabbox-sshd" -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -Name "crabbox-sshd" -DisplayName "Crabbox OpenSSH" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | 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\\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")
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
}
Get-Service -Name tvnserver -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic
Start-Service -Name tvnserver -ErrorAction SilentlyContinue
Restart-Service sshd
</powershell>`;
}
export function macOSUserData(config: LeaseConfig): string {
return `#!/bin/bash
set -euxo pipefail
install -d -m 0755 ${shellQuote(config.workRoot)} /var/db/crabbox
chown -R ${shellQuote(config.sshUser)}:staff ${shellQuote(config.workRoot)}
if [ ! -s /var/db/crabbox/vnc.password ]; then
pw="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
printf '%s\\n' "$pw" >/var/db/crabbox/vnc.password
dscl . -passwd /Users/${shellQuote(config.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(config.workRoot)}
nc -z 127.0.0.1 5900
READY
chmod 0755 /usr/local/bin/crabbox-ready
/usr/local/bin/crabbox-ready
`;
}
function optionalReadyChecks(config: LeaseConfig): string {
const lines: string[] = [];
if (config.desktop) {

View File

@ -19,6 +19,7 @@ export interface LeaseConfig {
awsProfile: string;
awsRootGB: number;
awsSSHCIDRs: string[];
awsMacHostID: string;
capacityMarket: "spot" | "on-demand";
capacityStrategy:
| "most-available"
@ -46,11 +47,26 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
}
const target = normalizeTarget(input.target ?? input.targetOS ?? "linux");
const windowsMode = normalizeWindowsMode(input.windowsMode ?? "normal");
if (target !== "linux") {
if (
target !== "linux" &&
!(provider === "aws" && target === "windows" && windowsMode === "normal") &&
!(provider === "aws" && target === "macos")
) {
throw new Error(`unsupported target for brokered ${provider}: ${target}`);
}
if (target === "macos") {
if (provider !== "aws") {
throw new Error(`unsupported target for brokered ${provider}: ${target}`);
}
if (!input.awsMacHostID?.trim()) {
throw new Error("brokered aws target=macos requires awsMacHostID or CRABBOX_AWS_MAC_HOST_ID");
}
if ((input.capacity?.market ?? "spot") !== "on-demand") {
throw new Error("brokered aws target=macos requires capacity.market=on-demand");
}
}
const machineClass = input.class ?? "beast";
const serverType = input.serverType ?? serverTypeForProviderClass(provider, machineClass);
const serverType = input.serverType ?? serverTypeForConfig(provider, target, machineClass);
const ttlSeconds = clampTTL(input.ttlSeconds ?? 5400);
const idleTimeoutSeconds = clampIdleTimeout(input.idleTimeoutSeconds ?? 1800);
const sshPublicKey = input.sshPublicKey?.trim() ?? "";
@ -76,16 +92,17 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
awsProfile: input.awsProfile ?? "",
awsRootGB: input.awsRootGB ?? 400,
awsSSHCIDRs: validCIDRs(input.awsSSHCIDRs ?? []),
awsMacHostID: input.awsMacHostID ?? "",
capacityMarket: input.capacity?.market ?? "spot",
capacityStrategy: input.capacity?.strategy ?? "most-available",
capacityFallback: input.capacity?.fallback ?? "on-demand-after-120s",
capacityRegions: input.capacity?.regions ?? [],
capacityAvailabilityZones: input.capacity?.availabilityZones ?? [],
sshUser: input.sshUser ?? "crabbox",
sshUser: input.sshUser ?? (provider === "aws" && target === "macos" ? "ec2-user" : "crabbox"),
sshPort: input.sshPort ?? "2222",
sshFallbackPorts: validPorts(input.sshFallbackPorts ?? ["22"]),
providerKey: input.providerKey ?? "crabbox-steipete",
workRoot: input.workRoot ?? "/work/crabbox",
workRoot: input.workRoot ?? (target === "windows" && windowsMode === "normal" ? "C:\\crabbox" : "/work/crabbox"),
ttlSeconds,
idleTimeoutSeconds,
keep: input.keep ?? false,
@ -164,6 +181,41 @@ export function serverTypeForProviderClass(provider: Provider, machineClass: str
return serverTypeForClass(machineClass);
}
export function serverTypeForConfig(
provider: Provider,
target: TargetOS,
machineClass: string,
): string {
if (provider === "aws") {
return awsInstanceTypeCandidatesForTargetClass(target, machineClass)[0] ?? machineClass;
}
return serverTypeForClass(machineClass);
}
export function awsInstanceTypeCandidatesForTargetClass(
target: TargetOS,
machineClass: string,
): string[] {
if (target === "macos") {
return ["mac2.metal"];
}
if (target === "windows") {
switch (machineClass) {
case "standard":
return ["m7i.large", "m7a.large", "t3.large"];
case "fast":
return ["m7i.xlarge", "m7a.xlarge", "t3.xlarge"];
case "large":
return ["m7i.2xlarge", "m7a.2xlarge", "t3.2xlarge"];
case "beast":
return ["m7i.4xlarge", "m7a.4xlarge", "m7i.2xlarge"];
default:
return [machineClass];
}
}
return awsInstanceTypeCandidatesForClass(machineClass);
}
export function serverTypeCandidatesForClass(machineClass: string): string[] {
switch (machineClass) {
case "standard":

View File

@ -11,6 +11,7 @@ export interface Env {
CRABBOX_AWS_INSTANCE_PROFILE?: string;
CRABBOX_AWS_ROOT_GB?: string;
CRABBOX_AWS_SSH_CIDRS?: string;
CRABBOX_AWS_MAC_HOST_ID?: string;
CRABBOX_SHARED_TOKEN?: string;
CRABBOX_ADMIN_TOKEN?: string;
CRABBOX_SESSION_SECRET?: string;
@ -57,6 +58,7 @@ export interface LeaseRequest {
awsProfile?: string;
awsRootGB?: number;
awsSSHCIDRs?: string[];
awsMacHostID?: string;
capacity?: {
market?: "spot" | "on-demand";
strategy?: "most-available" | "price-capacity-optimized" | "capacity-optimized" | "sequential";