<# .SYNOPSIS Panel one-line installer for Windows — controller + agent + Postgres on one box. .DESCRIPTION PowerShell 5+ equivalent of install.sh. Checks Docker Desktop, starts a panel-postgres container, builds (or downloads) the panel binaries with the version baked in via ldflags, boots the controller once to bootstrap the CA and admin account, pairs the local agent, and registers both as logon-time Scheduled Tasks so they survive reboots. For a hardened always-on Windows service, wrap the same ExecStart lines with nssm (https://nssm.cc): `nssm install panel-controller \bin\controller.exe ...`. .EXAMPLE powershell -ExecutionPolicy Bypass -File install.ps1 -AdminPassword secret123 #> [CmdletBinding()] param( [string]$RepoUrl = $(if ($env:PANEL_REPO_URL) { $env:PANEL_REPO_URL } else { 'https://git.pdxtechs.com/dbledeez/panel' }), [string]$PrebuiltUrl = $env:PANEL_PREBUILT_URL, [string]$Version = $env:PANEL_VERSION, [string]$InstallDir = $(if ($env:PANEL_DIR) { $env:PANEL_DIR } else { 'C:\Panel' }), [int] $HttpPort = $(if ($env:PANEL_HTTP_PORT) { [int]$env:PANEL_HTTP_PORT } else { 8080 }), [int] $GrpcPort = $(if ($env:PANEL_GRPC_PORT) { [int]$env:PANEL_GRPC_PORT } else { 8443 }), [string]$AdminPassword = $env:PANEL_ADMIN_PASSWORD, [string]$DbUrl = $env:PANEL_DB_URL, [string]$DbPassword = $env:PANEL_DB_PASSWORD, [switch]$SkipAgent ) $ErrorActionPreference = 'Stop' $PgContainer = 'panel-postgres' function Log($msg) { Write-Host "[panel] $msg" -ForegroundColor Green } function Warn($msg) { Write-Host "[panel] $msg" -ForegroundColor Yellow } function Fail($msg) { Write-Host "[panel] ERROR: $msg" -ForegroundColor Red; exit 1 } # ---- 1. Docker Desktop ----------------------------------------------------- if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Fail "Docker not found. Install Docker Desktop (https://www.docker.com/products/docker-desktop/), start it, then re-run." } docker info *> $null if ($LASTEXITCODE -ne 0) { Fail "Docker daemon is not running. Start Docker Desktop and re-run." } Log "docker present: $(docker --version)" # ---- 2. Postgres ----------------------------------------------------------- New-Item -ItemType Directory -Force -Path "$InstallDir\bin", "$InstallDir\data" | Out-Null $EnvFile = Join-Path $InstallDir 'panel.env' if ($DbUrl) { Log "using external Postgres from -DbUrl" } else { docker inspect $PgContainer *> $null if ($LASTEXITCODE -eq 0) { Log "postgres container '$PgContainer' already exists - starting" docker start $PgContainer | Out-Null if (-not $DbPassword -and (Test-Path $EnvFile)) { $DbPassword = (Get-Content $EnvFile | Where-Object { $_ -match '^PANEL_DB_PASSWORD=' } | Select-Object -First 1) -replace '^PANEL_DB_PASSWORD=', '' } if (-not $DbPassword) { Fail "existing $PgContainer but no DbPassword known (pass -DbPassword, or 'docker rm -f $PgContainer' to start fresh)" } } else { if (-not $DbPassword) { $DbPassword = -join ((1..32) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }) } Log "starting postgres container '$PgContainer'" docker run -d --name $PgContainer --restart unless-stopped ` -e POSTGRES_USER=panel -e "POSTGRES_PASSWORD=$DbPassword" -e POSTGRES_DB=panel ` -p 127.0.0.1:5432:5432 ` -v panel_pgdata:/var/lib/postgresql/data ` postgres:17-alpine | Out-Null if ($LASTEXITCODE -ne 0) { Fail "docker run for postgres failed" } } $DbUrl = "postgres://panel:$DbPassword@127.0.0.1:5432/panel?sslmode=disable" Log "waiting for postgres" $ready = $false foreach ($i in 1..30) { docker exec $PgContainer pg_isready -U panel -d panel *> $null if ($LASTEXITCODE -eq 0) { $ready = $true; break } Start-Sleep -Seconds 1 } if (-not $ready) { Fail "postgres did not become ready" } } # ---- 3. Source / binaries --------------------------------------------------- $Src = $null $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path if ($ScriptDir -and (Test-Path (Join-Path $ScriptDir 'go.work')) -and (Test-Path (Join-Path $ScriptDir 'controller'))) { $Src = $ScriptDir Log "using local checkout at $Src" } if ($PrebuiltUrl) { Log "downloading prebuilt binaries: $PrebuiltUrl" $zip = Join-Path $env:TEMP 'panel-prebuilt.zip' Invoke-WebRequest -Uri $PrebuiltUrl -OutFile $zip -UseBasicParsing Expand-Archive -Path $zip -DestinationPath $InstallDir -Force Remove-Item $zip -Force if (-not (Test-Path "$InstallDir\bin\controller.exe")) { Fail "prebuilt archive did not contain bin\controller.exe" } } else { if (-not $Src) { if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Fail "git required to fetch sources (or pass -PrebuiltUrl)" } $Src = Join-Path $InstallDir 'src' if (Test-Path (Join-Path $Src '.git')) { Log "updating source at $Src" git -C $Src pull --ff-only } else { Log "cloning $RepoUrl" git clone --depth 1 $RepoUrl $Src } } if (-not (Get-Command go -ErrorAction SilentlyContinue)) { Fail "Go toolchain not found. Install Go >= 1.26 (https://go.dev/dl/) or pass -PrebuiltUrl." } if (-not $Version) { $Version = (git -C $Src describe --tags --always --dirty 2>$null) if (-not $Version) { $Version = 'dev' } } $ldflags = "-s -w -X github.com/dbledeez/panel/pkg/version.Version=$Version" Log "building panel $Version" Push-Location $Src try { $env:CGO_ENABLED = '0' go build -trimpath -ldflags $ldflags -o "$InstallDir\bin\controller.exe" ./controller/cmd/controller if ($LASTEXITCODE -ne 0) { Fail 'controller build failed' } go build -trimpath -ldflags $ldflags -o "$InstallDir\bin\agent.exe" ./agent/cmd/agent if ($LASTEXITCODE -ne 0) { Fail 'agent build failed' } go build -trimpath -ldflags $ldflags -o "$InstallDir\bin\panelctl.exe" ./controller/cmd/panelctl if ($LASTEXITCODE -ne 0) { Fail 'panelctl build failed' } } finally { Pop-Location } Log "syncing game modules" if (Test-Path "$InstallDir\modules") { Remove-Item "$InstallDir\modules" -Recurse -Force } Copy-Item -Recurse "$Src\modules" "$InstallDir\modules" } # ---- 4. Env file ------------------------------------------------------------ $envLines = @("PANEL_DB_URL=$DbUrl") if ($DbPassword) { $envLines += "PANEL_DB_PASSWORD=$DbPassword" } if ($AdminPassword) { $envLines += "PANEL_ADMIN_PASSWORD=$AdminPassword" } $envLines += "PANEL_HTTP_PORT=$HttpPort" $envLines += "PANEL_GRPC_PORT=$GrpcPort" Set-Content -Path $EnvFile -Value $envLines -Encoding ascii # ---- 5. Start controller (background) + bootstrap --------------------------- $ctrlLog = Join-Path $InstallDir 'controller.log' $firstBoot = -not (Test-Path "$InstallDir\data\ca") # Stop a previous run of this script's processes, if any. Get-Process -Name controller, agent -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$InstallDir*" } | Stop-Process -Force -ErrorAction SilentlyContinue $env:PANEL_DB_URL = $DbUrl if ($AdminPassword) { $env:PANEL_ADMIN_PASSWORD = $AdminPassword } Log "starting controller" Start-Process -FilePath "$InstallDir\bin\controller.exe" ` -ArgumentList @('-http', ":$HttpPort", '-grpc', ":$GrpcPort", '-ca-dir', './data/ca') ` -WorkingDirectory $InstallDir -WindowStyle Hidden ` -RedirectStandardError $ctrlLog $up = $false foreach ($i in 1..60) { try { Invoke-WebRequest -Uri "http://127.0.0.1:$HttpPort/login" -UseBasicParsing -TimeoutSec 2 | Out-Null $up = $true; break } catch { Start-Sleep -Seconds 1 } } if (-not $up) { Fail "controller did not come up - see $ctrlLog" } $adminEmail = 'admin@panel.local' $adminShown = '(set via -AdminPassword)' if (-not $AdminPassword) { if ($firstBoot) { $m = Select-String -Path $ctrlLog -Pattern 'password: ([a-f0-9]{16})' | Select-Object -First 1 if ($m) { $AdminPassword = $m.Matches[0].Groups[1].Value $adminShown = $AdminPassword } else { $adminShown = "(see $ctrlLog, line 'password:')" } } else { $adminShown = '(unchanged from previous install)' } } # ---- 6. Pair token + local agent --------------------------------------------- $pairCmd = "$InstallDir\bin\agent.exe --pair-url http://:$HttpPort --pair-token --cert-dir ./data/certs" if (-not $SkipAgent) { $paired = Test-Path "$InstallDir\data\certs\agent.crt" if (-not $paired -and $AdminPassword) { try { $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession $body = @{ email = $adminEmail; password = $AdminPassword } | ConvertTo-Json Invoke-RestMethod -Uri "http://127.0.0.1:$HttpPort/api/login" -Method Post ` -ContentType 'application/json' -Body $body -WebSession $session | Out-Null $tokBody = @{ description = 'install.ps1 local agent'; expires_minutes = 60 } | ConvertTo-Json $tok = Invoke-RestMethod -Uri "http://127.0.0.1:$HttpPort/api/admin/pair-tokens" -Method Post ` -ContentType 'application/json' -Body $tokBody -WebSession $session Log "pairing local agent" Push-Location $InstallDir try { & "$InstallDir\bin\agent.exe" --pair-url "http://127.0.0.1:$HttpPort" --pair-token $tok.token --cert-dir ./data/certs if ($LASTEXITCODE -eq 0) { $paired = $true } else { Warn 'agent pairing exited non-zero' } } finally { Pop-Location } } catch { Warn "auto-pairing failed: $($_.Exception.Message)" } } if ($paired) { Log "starting agent" Start-Process -FilePath "$InstallDir\bin\agent.exe" ` -ArgumentList @('--controller', "localhost:$GrpcPort", '--modules-dir', './modules', '--data-root', './data/instances', '--meta-dir', './data/instance-meta', '--backup-dir', './data/backups', '--cert-dir', './data/certs') ` -WorkingDirectory $InstallDir -WindowStyle Hidden ` -RedirectStandardError (Join-Path $InstallDir 'agent.log') } else { Warn "agent not paired. Create a pair token in the dashboard (Admin -> Pair tokens) and run:" Warn " cd $InstallDir; $pairCmd" } } # ---- 7. Scheduled tasks (survive reboots) ------------------------------------- Log "registering logon Scheduled Tasks (panel-controller / panel-agent)" try { $ctrlAction = New-ScheduledTaskAction -Execute "$InstallDir\bin\controller.exe" ` -Argument "-http :$HttpPort -grpc :$GrpcPort -ca-dir ./data/ca" -WorkingDirectory $InstallDir $agentAction = New-ScheduledTaskAction -Execute "$InstallDir\bin\agent.exe" ` -Argument "--controller localhost:$GrpcPort --modules-dir ./modules --data-root ./data/instances --meta-dir ./data/instance-meta --backup-dir ./data/backups --cert-dir ./data/certs" ` -WorkingDirectory $InstallDir $trigger = New-ScheduledTaskTrigger -AtLogOn $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) ` -ExecutionTimeLimit (New-TimeSpan -Days 3650) -StartWhenAvailable Register-ScheduledTask -TaskName 'panel-controller' -Action $ctrlAction -Trigger $trigger -Settings $settings -Force | Out-Null Register-ScheduledTask -TaskName 'panel-agent' -Action $agentAction -Trigger $trigger -Settings $settings -Force | Out-Null Warn "NOTE: the scheduled tasks read PANEL_DB_URL etc. from the machine environment;" Warn "for a true always-on service (no logon needed), use nssm: https://nssm.cc" # Persist the DB URL machine-wide so the scheduled-task runs see it. [Environment]::SetEnvironmentVariable('PANEL_DB_URL', $DbUrl, 'Machine') } catch { Warn "scheduled-task registration failed (not admin?): $($_.Exception.Message)" Warn "the controller/agent started by this script keep running until reboot." } # ---- 8. Summary ---------------------------------------------------------------- Write-Host "" Log "------------------------------------------------------------" Log " Panel is installed." Log " Dashboard : http://localhost:$HttpPort" Log " Login : $adminEmail" Log " Password : $adminShown" Log " (log in once and change the password in the UI)" Log "" Log " To pair an agent on ANOTHER machine, create a pair token in the" Log " dashboard, copy the panel binaries + modules\ there, then run:" Log " $pairCmd" Log " and start it: .\bin\agent.exe --controller :$GrpcPort --cert-dir ./data/certs" Log "------------------------------------------------------------"