# Run from an elevated PowerShell for MSI/services. User-level preparation is supported. [CmdletBinding()] param( [string]$Root='C:\pt-worker', [string]$Name="$env:COMPUTERNAME-win", [string]$PeerTubeUrl='http://TAILSCALE_IP:9000', [string]$CanonicalOrigin='https://tube.occupymedia.cc', [string]$ServerIp='TAILSCALE_IP', [int]$Device=0, [ValidateSet('Prepare','SelfTest','Enroll','ServiceFiles','Services','All')][string]$Phase='All', [switch]$RegistrationTokenStdin, [switch]$UserLevel ) $ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue' $Source=$PSScriptRoot $Pins=Get-Content (Join-Path $Source 'win\dependencies.json') -Raw | ConvertFrom-Json $admin=([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if($Root -notmatch '^[A-Za-z]:\\' -or $Root -match '[\s"<>]'){throw 'Choose an absolute local path without spaces or XML characters'} $env:PT_ROOT=$Root $env:PT_STATE_DIR=Join-Path $Root 'state' $env:PT_WORKER_CONFIG=Join-Path $Root 'state\worker.json' $env:APPDATA=Join-Path $Root 'profile\Roaming' $env:LOCALAPPDATA=Join-Path $Root 'profile\Local' $env:TMP=Join-Path $Root 'tmp';$env:TEMP=$env:TMP $env:FFMPEG_PATH=Join-Path $Root 'bin\ffmpeg-nvenc.exe' $env:FFPROBE_PATH=Join-Path $Root 'bin\ffprobe.exe' $node=Join-Path $Root 'node\node.exe' $toml=Join-Path $env:APPDATA 'peertube-runner-nodejs\Config\default\config.toml' function CheckExit([string]$What){if($LASTEXITCODE -ne 0){throw "$What failed (exit $LASTEXITCODE)"}} function Fetch($Pin,[string]$File){ $out=Join-Path $Root "downloads\$File" if(!(Test-Path $out)){& curl.exe --fail --location --silent --show-error --output $out $Pin.url;CheckExit 'Download'} if((Get-FileHash $out -Algorithm SHA256).Hash.ToLower() -ne $Pin.sha256){throw "SHA256 mismatch for $File; no installation performed"} return $out } function ProtectPaths { $me=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value & icacls.exe $Root /inheritance:r /grant:r "*$($me):(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-19:(OI)(CI)RX' | Out-Null;CheckExit 'Root ACL' foreach($dir in @('state','profile','tmp','logs')){ & icacls.exe (Join-Path $Root $dir) /inheritance:r /grant:r "*$($me):(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-19:(OI)(CI)F' | Out-Null;CheckExit 'Private ACL' } } if($Phase -in @('Prepare','All')){ if(Test-Path (Join-Path $Root 'state\runner.json')){ $running=Get-Content (Join-Path $Root 'state\runner.json') -Raw | ConvertFrom-Json if($running.running){throw 'Drain and wait for runner_running=false before preparing/upgrading'} } foreach($dir in @('bin','downloads','runner','state','profile','tmp','logs','build')){New-Item -ItemType Directory -Force (Join-Path $Root $dir) | Out-Null} '{"paused":true}' | Set-Content (Join-Path $Root 'state\control.json') -Encoding ASCII ProtectPaths $msi=Fetch $Pins.node_msi 'node.msi' if(!$UserLevel){ if(!$admin){throw 'Node MSI requires an elevated Windows PowerShell. Use -UserLevel to prepare isolated binaries.'} $p=Start-Process msiexec.exe -Wait -PassThru -ArgumentList @('/i',$msi,'/qn','/norestart');if($p.ExitCode -notin @(0,3010)){throw "Node MSI exit $($p.ExitCode)"} } # The worker always uses its isolated pinned Node even after a machine-wide MSI. $zip=Fetch $Pins.node_zip 'node.zip' Expand-Archive $zip (Join-Path $Root 'downloads\node') -Force if(!(Test-Path (Join-Path $Root 'node'))){New-Item -ItemType Directory (Join-Path $Root 'node') | Out-Null} Copy-Item (Join-Path $Root "downloads\node\node-v$($Pins.node_version)-win-x64\*") (Join-Path $Root 'node') -Recurse -Force $zip=Fetch $Pins.ffmpeg 'ffmpeg-8.1-20261009.zip' Expand-Archive $zip (Join-Path $Root 'downloads\ffmpeg') -Force $ff=Get-ChildItem (Join-Path $Root 'downloads\ffmpeg') -Filter ffmpeg.exe -Recurse | Select-Object -First 1 Copy-Item ($ff.Directory.FullName+'\*') (Join-Path $Root 'bin') -Force Copy-Item (Fetch $Pins.winsw 'winsw.exe') (Join-Path $Root 'bin\WinSW-x64.exe') -Force foreach($file in @('ffmpeg-nvenc.py','pt-worker-nvenc.py','ipc-nvenc.cjs','runner-supervisor.cjs','enroll-server.cjs','service-stop.cjs')){Copy-Item (Join-Path $Source "win\$file") (Join-Path $Root "bin\$file") -Force} Copy-Item (Join-Path $Source 'observe.cjs') (Join-Path $Root 'bin\observe.cjs') -Force Copy-Item (Join-Path $Source 'win\selftest-nvenc.ps1') (Join-Path $Root 'selftest-nvenc.ps1') -Force Copy-Item (Join-Path $Source 'pt-worker-win.ps1') (Join-Path $Root 'pt-worker-win.ps1') -Force Copy-Item (Join-Path $Source 'runtime-package.json') (Join-Path $Root 'runner\package.json') -Force Copy-Item (Join-Path $Source 'runtime-package-lock.json') (Join-Path $Root 'runner\package-lock.json') -Force $env:PATH=(Join-Path $Root 'node')+';'+$env:PATH Push-Location (Join-Path $Root 'runner');try {& (Join-Path $Root 'node\npm.cmd') ci --ignore-scripts;CheckExit 'Pinned runner install'}finally{Pop-Location} # Python is a build prerequisite only. Frozen executables embed their runtime. $python=(Get-Command python.exe -ErrorAction Stop).Source & $python -m venv (Join-Path $Root 'build-env');CheckExit 'Build environment' $builder=Join-Path $Root 'build-env\Scripts\python.exe' & $builder -m pip install --disable-pip-version-check -r (Join-Path $Source 'win\build-requirements.txt');CheckExit 'Pinned builder install' foreach($app in @(@{Name='ffmpeg-nvenc';Source='ffmpeg-nvenc.py'},@{Name='pt-admission';Source='pt-worker-nvenc.py'})){ & $builder -m PyInstaller --noconfirm --clean --onefile --name $app.Name --distpath (Join-Path $Root 'bin') --workpath (Join-Path $Root "build\$($app.Name)") --specpath (Join-Path $Root 'build') (Join-Path $Root "bin\$($app.Source)");CheckExit 'Executable build' } if(!(Test-Path (Join-Path $Root 'state\worker.json'))){ @{name=$Name;peertube_url=$PeerTubeUrl;canonical_origin=$CanonicalOrigin;tailscale_peer=$ServerIp;probe_url="http://${ServerIp}:9044";peertube_version='7.3.0';concurrency=2;nvenc_device=$Device;require_ac=$true;node_binary=$node;tailscale_binary='C:\Program Files\Tailscale\tailscale.exe';poll_seconds=5;probe_interval_seconds=300} | ConvertTo-Json | Set-Content (Join-Path $Root 'state\worker.json') -Encoding ASCII } if(!(Test-Path $toml)){New-Item -ItemType Directory -Force (Split-Path $toml) | Out-Null;"[jobs]`nconcurrency = 0`n[ffmpeg]`nthreads = 2`nnice = 20`n" | Set-Content $toml -Encoding ASCII} $manifest=@{pins=$Pins;built_at=[DateTime]::UtcNow.ToString('o');binaries=@()} foreach($file in @('ffmpeg.exe','ffprobe.exe','ffmpeg-nvenc.exe','pt-admission.exe','WinSW-x64.exe')){$manifest.binaries+=@{name=$file;sha256=(Get-FileHash (Join-Path $Root "bin\$file")).Hash.ToLower()}} $manifest | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $Root 'state\dependencies.json') -Encoding ASCII } if($Phase -in @('SelfTest','All')){ & (Join-Path $Root 'selftest-nvenc.ps1') -Root $Root -Device $Device;CheckExit 'Mandatory NVENC proof' & (Join-Path $Root 'bin\pt-admission.exe') network-test;CheckExit 'Windows bandwidth probe' } if($Phase -in @('Enroll','All')){ $cfg=Get-Content (Join-Path $Root 'state\worker.json') -Raw | ConvertFrom-Json $proof=Get-Content (Join-Path $Root 'state\nvenc.json') -Raw | ConvertFrom-Json if(!$proof.nvenc_h264_ok -or !$proof.nvenc_hevc_ok){throw 'Registration refused: NVENC proof absent'} $boot=(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o') $driver=(& nvidia-smi.exe -i $cfg.nvenc_device --query-gpu=driver_version --format=csv,noheader,nounits).Trim() if($proof.boot_id -ne $boot -or $proof.driver_version -ne $driver -or ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()-$proof.tested_at) -gt 86400){throw 'Registration refused: stale boot/driver proof'} $text=Get-Content $toml -Raw if($text -match 'runnerToken\s*='){ if($text -notmatch [regex]::Escape($cfg.name)){throw 'Other runner exists in profile; inspect before enrollment'} Write-Output 'Already enrolled; registration preserved' }else{ if(Test-Path (Join-Path $Root 'state\enrollment.pending')){throw 'Previous registration result uncertain; inspect server before retry'} if(!$RegistrationTokenStdin){throw 'Enrollment requires -RegistrationTokenStdin with the existing secret piped on stdin; never pass the token as an argument'} ProtectPaths & $node (Join-Path $Root 'bin\enroll-server.cjs');CheckExit 'Enrollment listener' Start-Sleep -Seconds 3 'Attempted; no secret material' | Set-Content (Join-Path $Root 'state\enrollment.pending') # Node reads inherited stdin directly. No PowerShell variable/file/history holds the token. & $node (Join-Path $Root 'bin\ipc-nvenc.cjs') register;CheckExit 'Official registration IPC' & $node (Join-Path $Root 'bin\ipc-nvenc.cjs') graceful-shutdown;CheckExit 'Enrollment drain' Remove-Item (Join-Path $Root 'state\enrollment.pending') } # Keep official runner credential private, never print TOML or arbitrary logs. [IO.File]::WriteAllText($toml, ((Get-Content $toml -Raw) -replace '(?m)^concurrency\s*=\s*\d+', 'concurrency = 2')) } if($Phase -in @('ServiceFiles','Services','All')){ foreach($svc in @('pt-runner','pt-admission')){if(Get-Service $svc -ErrorAction SilentlyContinue){throw 'Existing service requires an explicit drain/upgrade; refusing overwrite'}} New-Item -ItemType Directory -Force (Join-Path $Root 'services'),(Join-Path $Root 'logs') | Out-Null ProtectPaths foreach($svc in @('pt-runner','pt-admission')){ $exe=if($svc -eq 'pt-runner'){$node}else{Join-Path $Root 'bin\pt-admission.exe'} $arguments=if($svc -eq 'pt-runner'){Join-Path $Root 'bin\runner-supervisor.cjs'}else{'daemon'} $stop=if($svc -eq 'pt-runner'){"$node$(Join-Path $Root 'bin\service-stop.cjs')"}else{''} $xml=@" $svc$svcPeerTube Windows NVENC worker: AC-only admission, VOD-only, no CPU video encoding $exe$arguments$Root Automatictrue1 hour NT AUTHORITYLocalService $(Join-Path $Root 'logs')10243 1 hour$stop "@ Copy-Item (Join-Path $Root 'bin\WinSW-x64.exe') (Join-Path $Root "services\$svc.exe") $xml | Set-Content (Join-Path $Root "services\$svc.xml") -Encoding UTF8 } if($Phase -eq 'ServiceFiles'){Write-Output 'WinSW service files prepared; no SCM changes';return} if(!$admin){throw 'Service files prepared. Installation requires elevated Windows PowerShell (OpenSCManager error 5 in filtered SSH/WSL tokens).'} # Transition an interactive validation deployment without duplicate profile owners. '{"paused":true}' | Set-Content (Join-Path $Root 'state\control.json') -Encoding ASCII $deadline=(Get-Date).AddHours(1) while(Test-Path (Join-Path $Root 'state\runner.json')){ $r=Get-Content (Join-Path $Root 'state\runner.json') -Raw | ConvertFrom-Json if(!$r.running){break} if((Get-Date) -gt $deadline){throw 'Active jobs still draining; service transition stopped'} Start-Sleep -Seconds 2 } if(Test-Path (Join-Path $Root 'state\supervisor-process.json')){ 'requested' | Set-Content (Join-Path $Root 'state\supervisor-stop') -Encoding ASCII Start-Sleep -Seconds 3 } Get-Process pt-admission -ErrorAction SilentlyContinue | Where-Object {$_.Path -eq (Join-Path $Root 'bin\pt-admission.exe')} | Stop-Process foreach($svc in @('pt-runner','pt-admission')){& (Join-Path $Root "services\$svc.exe") install;CheckExit 'Service installation'} & powercfg.exe /change standby-timeout-ac 0;CheckExit 'AC sleep policy' # Force proof from service/session 0 before opening admission there. Remove-Item (Join-Path $Root 'state\nvenc.json') -ErrorAction SilentlyContinue Start-Service pt-admission,pt-runner Write-Output 'Services installed and started PAUSED. Validate session-0 NVENC, then pt-worker-win start.' }