# LicensePanel installer — Windows. Per contracts/installer.md. # # Differences from install.sh: # • Docker auto-install is NEVER attempted (precondition, not bootstrapped). # Operator must have Docker Desktop or Docker EE running before invoking, # OR pass -NoDocker to land on the AOT-binary-as-Windows-Service path. # • State directory is C:\ProgramData\LicensePanel\ with NTFS ACL locked to # SYSTEM + Administrators (matches the 0600 root posture on Linux). # • KEK custody is DPAPI machine-scope; no master.pfx is generated. #requires -Version 7.0 [CmdletBinding()] param( [string]$PanelHost, [string]$AcmeEmail, [string]$License, [string]$Activation, [switch]$NoDocker, [switch]$Upgrade, [string]$Version = 'latest', [switch]$NoStart, [string]$FromTarball, [switch]$AcceptTos, [switch]$Help ) $ErrorActionPreference = 'Stop' $PanelDir = 'C:\ProgramData\LicensePanel' $StateDb = Join-Path $PanelDir 'state.db' $ComposeDir = Join-Path $PanelDir 'panel-stack' $ComposeFile = Join-Path $ComposeDir 'docker-compose.yml' $EnvFile = Join-Path $ComposeDir '.env' $TraefikDir = Join-Path $PanelDir 'traefik' $PostgresTlsDir = Join-Path $PanelDir 'postgres-tls' $KeysDir = Join-Path $PanelDir 'keys' $DataDir = Join-Path $PanelDir 'data' $ServiceName = 'LicensePanel' # Exit codes mirror contracts/installer.md. $Exit = @{ Success = 0 Generic = 1 Preflight = 2 DockerInstall = 3 AirgapInvalid = 4 Permissions = 5 Fingerprint = 6 Tls = 7 Compose = 8 } function Write-Help { @' LicensePanel installer (Windows) Usage: iwr -useb https://license-install.mod-sol-sa.com/install.ps1 | iex & ([scriptblock]::Create((iwr -useb https://license-install.mod-sol-sa.com/install.ps1).Content)) ` -PanelHost 'panel.acme.example' -AcmeEmail 'ops@acme.example' Parameters: -PanelHost Pre-seed panel host (enables ACME at first boot). -AcmeEmail Required when -PanelHost is set. -License Reserved (R1: ignored). -Activation Reserved (R1: ignored). -NoDocker Skip Docker probe; install AOT binary as a Service. -Upgrade Re-run on existing host (preserves state). -Version Pin the panel image (default: latest). -NoStart Write state but do not start the stack/service. -FromTarball Air-gap install from a directory containing the panel tarball + SHA256SUMS.asc. -AcceptTos Skip the interactive vendor terms-of-service prompt. -Help Print this help and exit 0. '@ } if ($Help) { Write-Help; exit $Exit.Success } if ($PanelHost -and -not $AcmeEmail) { Write-Error 'error: -AcmeEmail is required when -PanelHost is set.' exit $Exit.Generic } function Assert-Administrator { $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { Write-Error 'error: must run as Administrator.' exit $Exit.Generic } } function Test-StatePerms { if (Test-Path $PanelDir) { $acl = Get-Acl $PanelDir $extras = $acl.Access | Where-Object { $_.IdentityReference.Value -notmatch '^(NT AUTHORITY\\SYSTEM|BUILTIN\\Administrators)$' } if ($extras) { Write-Error "error: $PanelDir has unexpected ACEs (FR-062). Refusing." exit $Exit.Permissions } } } function Initialize-StateDirs { foreach ($dir in @($PanelDir, $KeysDir, $PostgresTlsDir, $TraefikDir, $ComposeDir, $DataDir)) { if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } } # Lock ACL to SYSTEM + Administrators only (mirrors 0600 root on Linux). $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $rules = @( New-Object System.Security.AccessControl.FileSystemAccessRule( 'NT AUTHORITY\SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'), New-Object System.Security.AccessControl.FileSystemAccessRule( 'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') ) foreach ($rule in $rules) { $acl.AddAccessRule($rule) } Set-Acl -Path $PanelDir -AclObject $acl } function Test-ExistingInstall { return Test-Path $StateDb } function Invoke-IdempotentRerun { Write-Output 'LicensePanel is already installed.' $shaBefore = (Get-FileHash $StateDb -Algorithm SHA256).Hash if (-not $NoDocker) { Push-Location $ComposeDir try { docker compose pull --quiet | Out-Null if (-not $NoStart) { docker compose up -d } } finally { Pop-Location } } $shaAfter = (Get-FileHash $StateDb -Algorithm SHA256).Hash if ($shaBefore -ne $shaAfter) { Write-Error 'error: state.db SHA-256 changed during idempotent re-run.' exit $Exit.Generic } Write-Output "Pulled image: ditssa/licensepanel:$Version" Write-Output 'Service state: running' if ($PanelHost) { Write-Output ('Open in your browser: https://{0}/' -f $PanelHost) } else { $primary = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.PrefixOrigin -eq 'Manual' -or $_.PrefixOrigin -eq 'Dhcp' } | Select-Object -First 1).IPAddress Write-Output ('Open in your browser: https://{0}:8443/' -f $primary) } exit $Exit.Success } function Test-AirgapTarball { param([string]$Dir) $arch = if ([Environment]::Is64BitOperatingSystem) { 'x64' } else { 'x86' } $tarball = Join-Path $Dir "licensepanel-win-$arch.tar.gz" $sums = Join-Path $Dir 'SHA256SUMS.asc' if (-not (Test-Path $tarball)) { Write-Error "error: tarball missing: $tarball" exit $Exit.AirgapInvalid } if (-not (Test-Path $sums)) { Write-Error "error: SHA256SUMS.asc missing in $Dir" exit $Exit.AirgapInvalid } if (-not (Get-Command gpg -ErrorAction SilentlyContinue)) { Write-Error 'error: gpg required for air-gap verification.' exit $Exit.AirgapInvalid } $gpgVerify = & gpg --verify $sums 2>&1 if ($LASTEXITCODE -ne 0) { if (Test-Path $PanelDir) { Remove-Item -Recurse -Force $PanelDir } Write-Error 'AIRGAP_SIGNATURE_INVALID: SHA256SUMS.asc PGP signature did not verify.' exit $Exit.AirgapInvalid } $expected = (Get-Content $sums | Select-String (Split-Path $tarball -Leaf) | Select-Object -First 1).ToString().Split(' ')[0] $actual = (Get-FileHash $tarball -Algorithm SHA256).Hash.ToLowerInvariant() if (-not $expected -or $expected -ne $actual) { if (Test-Path $PanelDir) { Remove-Item -Recurse -Force $PanelDir } Write-Error 'AIRGAP_SIGNATURE_INVALID: tarball SHA-256 mismatch.' exit $Exit.AirgapInvalid } Write-Output "air-gap: signature + checksum verified for $(Split-Path $tarball -Leaf)" } function New-PanelEnv { $postgresPassword = -join (Get-Random -InputObject ([char[]]'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') -Count 32) $keycloakPassword = -join (Get-Random -InputObject ([char[]]'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') -Count 24) @" PANEL_VERSION=$Version PANEL_HOST=$PanelHost ACME_EMAIL=$AcmeEmail POSTGRES_PASSWORD=$postgresPassword KEYCLOAK_ADMIN_PASSWORD=$keycloakPassword "@ | Set-Content -Path $EnvFile -Encoding utf8 -NoNewline } function Register-PanelService { if ($NoDocker) { $binary = Join-Path $PanelDir 'bin\licensepanel.exe' if (-not (Test-Path $binary)) { Write-Error "error: panel binary missing at $binary (use -FromTarball or copy manually)." exit $Exit.Generic } if (-not (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue)) { New-Service -Name $ServiceName -BinaryPathName "`"$binary`"" ` -DisplayName 'LicensePanel' -StartupType Automatic } if (-not $NoStart) { Start-Service -Name $ServiceName } } } function Start-PanelStack { if ($NoStart) { Write-Output 'stack not started (-NoStart).'; return } if ($NoDocker) { return } Push-Location $ComposeDir try { docker compose up -d if ($LASTEXITCODE -ne 0) { exit $Exit.Compose } } finally { Pop-Location } } function Show-FirstInstallSummary { Write-Output '' Write-Output 'LicensePanel installed.' Write-Output 'Open in your browser:' if ($PanelHost) { Write-Output (' https://{0}/' -f $PanelHost) } else { $primary = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.PrefixOrigin -in 'Manual','Dhcp' } | Select-Object -First 1).IPAddress Write-Output (' https://{0}:8443/' -f $primary) } Write-Output '' Write-Output 'Bootstrap mode is active for the next 60 minutes.' Write-Output "If you can't reach the URL, run:" Write-Output ' .\server.exe bootstrap status' } # ============================================================================ # Entrypoint # ============================================================================ Assert-Administrator Test-StatePerms if (Test-ExistingInstall) { Invoke-IdempotentRerun } Initialize-StateDirs if ($FromTarball) { Test-AirgapTarball -Dir $FromTarball $airgap = Join-Path $DataDir 'airgap' if (-not (Test-Path $airgap)) { New-Item -ItemType Directory -Path $airgap | Out-Null } Copy-Item -Path (Join-Path $FromTarball 'licensepanel-win-*.tar.gz') -Destination $airgap $NoDocker = $true } if (-not $NoDocker) { if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Write-Error 'error: docker not found in PATH. Install Docker Desktop / EE or pass -NoDocker.' exit $Exit.DockerInstall } } New-PanelEnv Register-PanelService Start-PanelStack Show-FirstInstallSummary