03a281d009
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
364 lines
20 KiB
PowerShell
364 lines
20 KiB
PowerShell
<#
|
|
.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 <InstallDir>\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' }),
|
|
# Defaults to the published v0.9.1 windows/amd64 prebuilt zip so the installer
|
|
# needs no Go toolchain. Pass -PrebuiltUrl '' (empty) to force a source build.
|
|
# A local checkout (running install.ps1 from inside the repo) wins over this.
|
|
[string]$PrebuiltUrl = $(if ($null -ne $env:PANEL_PREBUILT_URL) { $env:PANEL_PREBUILT_URL } else { 'https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.1/panel-v0.9.1-windows-amd64.zip' }),
|
|
[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]$AdminEmail = $env:PANEL_ADMIN_EMAIL,
|
|
[string]$AdminPassword = $env:PANEL_ADMIN_PASSWORD,
|
|
[string]$DbUrl = $env:PANEL_DB_URL,
|
|
[string]$DbPassword = $env:PANEL_DB_PASSWORD,
|
|
[string]$DbContainer = $(if ($env:PANEL_DB_CONTAINER) { $env:PANEL_DB_CONTAINER } else { 'panel-postgres' }),
|
|
[int] $DbPort = $(if ($env:PANEL_DB_PORT) { [int]$env:PANEL_DB_PORT } else { 5432 }),
|
|
[string]$DbVolume = $(if ($env:PANEL_DB_VOLUME) { $env:PANEL_DB_VOLUME } else { 'panel_pgdata' }),
|
|
[switch]$SkipAgent,
|
|
# -InstallDocker (or PANEL_INSTALL_DOCKER=1) auto-installs Docker Desktop via
|
|
# winget without prompting; -SkipDockerInstall keeps the old fail-if-missing.
|
|
[switch]$InstallDocker,
|
|
[switch]$SkipDockerInstall
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
# Run two independent panels on one host by giving each install distinct
|
|
# -InstallDir, -HttpPort, -GrpcPort, -DbContainer, -DbPort and -DbVolume.
|
|
$PgContainer = $DbContainer
|
|
|
|
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 }
|
|
|
|
# The literal one-liner to paste to run this installer again. Used by the
|
|
# "you must reboot / start Docker, then run this again" messages so the user
|
|
# is never left guessing what "re-run" means.
|
|
$RerunCommand = 'irm https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.ps1 | iex'
|
|
|
|
# Show-RerunAndExit prints a loud, unmissable "not done yet — run this again"
|
|
# banner with the exact command, then exits. Windows Docker Desktop needs a
|
|
# reboot on first install, so a second run of the installer is expected and
|
|
# normal — this makes that crystal clear instead of a terse error.
|
|
function Show-RerunAndExit($why) {
|
|
Write-Host ''
|
|
Write-Host '===============================================================' -ForegroundColor Yellow
|
|
Write-Host ' ACTION NEEDED — the panel is not installed yet' -ForegroundColor Yellow
|
|
Write-Host '===============================================================' -ForegroundColor Yellow
|
|
Write-Host " $why" -ForegroundColor Yellow
|
|
Write-Host ''
|
|
Write-Host ' Do this, in order:' -ForegroundColor Yellow
|
|
Write-Host ' 1. REBOOT if Windows/winget asked you to (Docker Desktop needs' -ForegroundColor Yellow
|
|
Write-Host ' WSL2 + virtualization, which require a restart).' -ForegroundColor Yellow
|
|
Write-Host ' 2. Start Docker Desktop and wait until the whale icon in the' -ForegroundColor Yellow
|
|
Write-Host ' system tray stops animating (daemon ready).' -ForegroundColor Yellow
|
|
Write-Host ' 3. >>> RUN THIS INSTALLER AGAIN <<< in an Administrator' -ForegroundColor Green
|
|
Write-Host ' PowerShell window. It will pick up from here and finish:' -ForegroundColor Green
|
|
Write-Host ''
|
|
Write-Host " $RerunCommand" -ForegroundColor Cyan
|
|
Write-Host ''
|
|
Write-Host ' (Running it again is safe and expected — it is idempotent.)' -ForegroundColor Yellow
|
|
Write-Host '===============================================================' -ForegroundColor Yellow
|
|
exit 1
|
|
}
|
|
|
|
# ---- 0. Admin account: prompt when interactive -----------------------------
|
|
# A real person (interactive console) is asked to choose the admin login +
|
|
# password up front. When run non-interactively (irm | iex with no console,
|
|
# CI) the prompt is skipped: -AdminPassword / $env:PANEL_ADMIN_PASSWORD is used
|
|
# and a missing password is generated by the controller as before.
|
|
if (-not $AdminPassword -and [Environment]::UserInteractive -and $Host.UI.RawUI) {
|
|
Write-Host ''
|
|
Write-Host '-- Set up your panel admin login --' -ForegroundColor Cyan
|
|
if (-not $AdminEmail) {
|
|
$e = Read-Host 'Admin email [admin@panel.local]'
|
|
if ($e) { $AdminEmail = $e }
|
|
}
|
|
while (-not $AdminPassword) {
|
|
$s1 = Read-Host 'Admin password (min 8 chars, blank = auto-generate)' -AsSecureString
|
|
$p1 = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
|
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s1))
|
|
if (-not $p1) { Warn 'no password entered - the controller will generate one and print it.'; break }
|
|
if ($p1.Length -lt 8) { Write-Host ' password too short (need 8+); try again.'; continue }
|
|
$s2 = Read-Host 'Confirm password' -AsSecureString
|
|
$p2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
|
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s2))
|
|
if ($p1 -ne $p2) { Write-Host ' passwords did not match; try again.'; continue }
|
|
$AdminPassword = $p1
|
|
}
|
|
}
|
|
|
|
# ---- 1. Docker Desktop -----------------------------------------------------
|
|
# The panel needs Docker for Postgres + every game server. Unlike Linux we
|
|
# can't fully script Docker Desktop (it's a GUI install + WSL2 + reboot), but
|
|
# we CAN kick it off via winget and guide the rest. -InstallDocker / env
|
|
# PANEL_INSTALL_DOCKER=1 answers "yes" non-interactively; -SkipDockerInstall
|
|
# forces the old fail-fast behavior.
|
|
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
|
$wantDocker = $InstallDocker -or ($env:PANEL_INSTALL_DOCKER -eq '1')
|
|
if (-not $wantDocker -and -not $SkipDockerInstall -and [Environment]::UserInteractive -and $Host.UI.RawUI) {
|
|
Write-Host ''
|
|
Write-Host 'Docker Desktop is required (runs Postgres + your game servers) and is not installed.' -ForegroundColor Yellow
|
|
$ans = Read-Host 'Install Docker Desktop now via winget? [Y/n]'
|
|
$wantDocker = ($ans -eq '' -or $ans -match '^[Yy]')
|
|
}
|
|
if ($wantDocker) {
|
|
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
|
|
Fail @"
|
|
winget isn't available to auto-install Docker. Install Docker Desktop manually:
|
|
https://www.docker.com/products/docker-desktop/
|
|
Then start it (wait for the whale icon to settle) and re-run this installer.
|
|
"@
|
|
}
|
|
Log "installing Docker Desktop via winget (this is large; a reboot is usually required)"
|
|
winget install --id Docker.DockerDesktop -e --accept-source-agreements --accept-package-agreements
|
|
if ($LASTEXITCODE -ne 0) { Fail "winget install of Docker Desktop failed (exit $LASTEXITCODE). Install it manually, then re-run." }
|
|
Show-RerunAndExit "Docker Desktop was just installed — the panel is NOT installed yet."
|
|
} else {
|
|
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) {
|
|
Show-RerunAndExit "Docker is installed but its daemon isn't running yet — the panel is NOT installed yet."
|
|
}
|
|
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' on 127.0.0.1:$DbPort (volume $DbVolume)"
|
|
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:${DbPort}:5432" `
|
|
-v "${DbVolume}:/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:$DbPort/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"
|
|
# Running from inside the repo means build THIS tree. Don't let the prebuilt
|
|
# default override it. An explicit -PrebuiltUrl or $env:PANEL_PREBUILT_URL still
|
|
# forces prebuilt (it won't equal the untouched default / the env is non-null).
|
|
$prebuiltDefault = 'https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.1/panel-v0.9.1-windows-amd64.zip'
|
|
if (($null -eq $env:PANEL_PREBUILT_URL) -and ($PrebuiltUrl -eq $prebuiltDefault)) {
|
|
$PrebuiltUrl = ''
|
|
}
|
|
}
|
|
|
|
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 ($AdminEmail) { $envLines += "PANEL_ADMIN_EMAIL=$AdminEmail" }
|
|
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 ($AdminEmail) { $env:PANEL_ADMIN_EMAIL = $AdminEmail }
|
|
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 = if ($AdminEmail) { $AdminEmail } else { 'admin@panel.local' }
|
|
# BUG-5b: always give the operator a path to the password.
|
|
$adminShown = "(the one you set via -AdminPassword; also in $EnvFile)"
|
|
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://<controller-host>:$HttpPort --pair-token <token-from-dashboard> --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 <this-host>:$GrpcPort --cert-dir ./data/certs"
|
|
Log "------------------------------------------------------------"
|