T-HUB/infra/peertube-workers/install-peertube-worker-windows.ps1
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

161 lines
12 KiB
PowerShell

# 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'){"<stopexecutable>$node</stopexecutable><stoparguments>$(Join-Path $Root 'bin\service-stop.cjs')</stoparguments>"}else{''}
$xml=@"
<service><id>$svc</id><name>$svc</name><description>PeerTube Windows NVENC worker: AC-only admission, VOD-only, no CPU video encoding</description>
<executable>$exe</executable><startarguments>$arguments</startarguments><workingdirectory>$Root</workingdirectory>
<env name="PT_ROOT" value="$Root"/><env name="PT_STATE_DIR" value="$(Join-Path $Root 'state')"/>
<env name="APPDATA" value="$(Join-Path $Root 'profile\Roaming')"/><env name="LOCALAPPDATA" value="$(Join-Path $Root 'profile\Local')"/>
<env name="TMP" value="$(Join-Path $Root 'tmp')"/><env name="TEMP" value="$(Join-Path $Root 'tmp')"/>
<startmode>Automatic</startmode><delayedAutoStart>true</delayedAutoStart><onfailure action="restart" delay="30 sec"/><resetfailure>1 hour</resetfailure>
<serviceaccount><domain>NT AUTHORITY</domain><user>LocalService</user></serviceaccount>
<logpath>$(Join-Path $Root 'logs')</logpath><log mode="roll-by-size"><sizeThreshold>1024</sizeThreshold><keepFiles>3</keepFiles></log>
<stoptimeout>1 hour</stoptimeout>$stop</service>
"@
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.'
}