param( [Parameter(Mandatory=$true)][ValidatePattern('^[a-f0-9]{16}$')][string]$Release, [switch]$Remove ) $ErrorActionPreference='Stop' $ProgressPreference='SilentlyContinue' [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 $root='D:\NDC_MISSIONCORE\runtime\simulation' $taskName='ndc-ai-polygon-worker' $isaac=Join-Path $root 'isaac-sim-6.1.0' $python=Join-Path $isaac 'kit\python\python.exe' $worker=Join-Path $root "releases\ai-polygon-$Release\simulation\ai-polygon\realtime_worker.py" $firewallBackup=Join-Path $root 'private\stream-firewall-backup.json' if (Test-Path (Join-Path $root 'state\active.json')) { throw 'Finish or reconcile the active episode before changing the service.' } if ($Remove) { if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName $taskName Unregister-ScheduledTask -TaskName $taskName -Confirm:$false } foreach ($name in @('ndc-ai-polygon-signal','ndc-ai-polygon-media')) { Get-NetFirewallRule -Name $name -ErrorAction SilentlyContinue | Remove-NetFirewallRule } if (Test-Path $firewallBackup) { foreach ($rule in (Get-Content $firewallBackup -Raw | ConvertFrom-Json)) { Get-NetFirewallRule -Name $rule.Name -ErrorAction Stop | Set-NetFirewallRule -Action Block -LocalPort $rule.LocalPort Get-NetFirewallRule -Name $rule.ExceptionName -ErrorAction SilentlyContinue | Remove-NetFirewallRule } } return } foreach ($file in @($python,$worker,(Join-Path $root 'private\worker.token'))) { if (!(Test-Path $file)) { throw "Missing prepared payload: $file" } } $payloadRoot=Join-Path $root "releases\ai-polygon-$Release" $manifestPath=Join-Path $root "releases\ai-polygon-$Release.manifest.json" if (!(Get-FileHash $manifestPath -Algorithm SHA256).Hash.ToLower().StartsWith($Release)) { throw 'Manifest identity mismatch' } $manifest=Get-Content $manifestPath -Raw | ConvertFrom-Json $verified=0 foreach($entry in $manifest.PSObject.Properties) { $file=[IO.Path]::GetFullPath((Join-Path $payloadRoot $entry.Name)) if (!$file.StartsWith($payloadRoot+'\',[StringComparison]::OrdinalIgnoreCase) -or $entry.Value -notmatch '^[a-f0-9]{64}$' -or (Get-FileHash $file -Algorithm SHA256).Hash.ToLower() -ne $entry.Value) { throw "Payload identity mismatch: $($entry.Name)" } $verified++ } if($verified -lt 1) { throw 'Empty release manifest' } $address=(& 'C:\Program Files\Tailscale\tailscale.exe' ip -4).Trim() $clientAddress=($env:SSH_CONNECTION -split ' ')[0] foreach ($value in @($address,$clientAddress)) { $ip=[Net.IPAddress]::Parse($value) $bytes=$ip.GetAddressBytes() if ($bytes.Length -ne 4 -or $bytes[0] -ne 100 -or $bytes[1] -lt 64 -or $bytes[1] -gt 127) { throw 'This installer admits only the existing private Tailscale path.' } } $kit=Join-Path $isaac 'kit\python\kit.exe' # Windows' user-prompt Block rules override scoped Allow rules. Keep those rules # blocking every other port; for the two admitted ports retain an explicit Block # for every remote address EXCEPT the current private operator. Preserve rollback. $oldRules=@() if (Test-Path $firewallBackup) { $oldRules=@(Get-Content $firewallBackup -Raw | ConvertFrom-Json) } $clientBytes=([Net.IPAddress]::Parse($clientAddress)).GetAddressBytes() # Windows rejects prefix /0 here. These disjoint CIDRs are the exact IPv4 # complement of the operator /32, plus both IPv6 halves. No address is exposed. $number=[uint64]$clientBytes[0]*16777216+[uint64]$clientBytes[1]*65536+[uint64]$clientBytes[2]*256+$clientBytes[3] $excluded=@('::/1','8000::/1') for($prefix=1;$prefix -le 32;$prefix++) { $bit=[uint64][math]::Pow(2,32-$prefix) $network=([uint64]([math]::Floor($number/$bit)*$bit)) -bxor $bit $networkBytes=[byte[]]@((($network -shr 24) -band 255),(($network -shr 16) -band 255),(($network -shr 8) -band 255),($network -band 255)) $excluded+="$([Net.IPAddress]::new($networkBytes))/$prefix" } try { $blocked=Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and ($_.Action -eq 'Block' -or $_.Name -in @($oldRules.Name)) -and $_.Name -notlike 'ndc-*' -and (($_ | Get-NetFirewallApplicationFilter).Program -ieq $kit) } foreach($rule in $blocked) { $filter=$rule | Get-NetFirewallPortFilter $port=if($filter.Protocol -eq 'TCP'){49100}elseif($filter.Protocol -eq 'UDP'){47998}else{throw 'Unexpected Isaac block protocol'} $exceptionName="ndc-ai-polygon-block-other-$($filter.Protocol.ToLower())" $backup=$oldRules | Where-Object {$_.Name -eq $rule.Name} if(!$backup) { if($filter.LocalPort -ne 'Any' -or ($rule | Get-NetFirewallAddressFilter).RemoteAddress -ne 'Any') { throw 'Inspect a nonstandard Isaac firewall policy before migrating it.' } $oldRules+=@{Name=$rule.Name;LocalPort='Any';ExceptionName=$exceptionName} $oldRules | ConvertTo-Json -Depth 4 | Set-Content -Encoding UTF8 $firewallBackup } Get-NetFirewallRule -Name $exceptionName -ErrorAction SilentlyContinue | Remove-NetFirewallRule New-NetFirewallRule -Name $exceptionName -DisplayName $exceptionName -Group 'ndc-ai-polygon' ` -Direction Inbound -Action Block -Program $kit -Protocol $filter.Protocol -LocalPort $port ` -RemoteAddress $excluded -Profile $rule.Profile | Out-Null $rule | Set-NetFirewallRule -Action Block -LocalPort @("1-$($port-1)","$($port+1)-65535") } foreach ($spec in @(@('ndc-ai-polygon-signal','TCP',49100),@('ndc-ai-polygon-media','UDP',47998))) { $existing=Get-NetFirewallRule -Name $spec[0] -ErrorAction SilentlyContinue if ($existing) { $existing | Remove-NetFirewallRule } New-NetFirewallRule -Name $spec[0] -DisplayName $spec[0] -Group 'ndc-ai-polygon' ` -Direction Inbound -Action Allow -Program $kit -Protocol $spec[1] -LocalPort $spec[2] ` -LocalAddress $address -RemoteAddress $clientAddress -Profile Any | Out-Null } } catch { foreach($old in $oldRules) { Get-NetFirewallRule -Name $old.Name -ErrorAction Stop | Set-NetFirewallRule -Action Block -LocalPort $old.LocalPort Get-NetFirewallRule -Name $old.ExceptionName -ErrorAction SilentlyContinue | Remove-NetFirewallRule } foreach($name in @('ndc-ai-polygon-signal','ndc-ai-polygon-media')) { Get-NetFirewallRule -Name $name -ErrorAction SilentlyContinue | Remove-NetFirewallRule } throw } if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName $taskName } $arguments="-u `"$worker`" --core http://127.0.0.1:18080 --token-file `"$root\private\worker.token`" --state `"$root\state`" --isaac `"$isaac`" --stream-address $address" $action=New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $root $user=[Security.Principal.WindowsIdentity]::GetCurrent().Name $principal=New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited $trigger=New-ScheduledTaskTrigger -AtLogOn -User $user $settings=New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -RestartCount 3 ` -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) ` -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal ` -Trigger $trigger -Settings $settings -Force | Out-Null Start-ScheduledTask -TaskName $taskName [ordered]@{schema_version='missioncore.ai-polygon-service/v1'; release=$Release; verified_files=$verified; installed_at=[DateTime]::UtcNow.ToString('o'); task=$taskName; lifecycle='Windows task, at user logon; independent of SSH/viewer'; control='existing operator tunnel 18080'; video='private Tailscale only'; rollback='Install-RealtimeWorker.ps1 -Release or -Remove; requires no active episode' } | ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $root 'realtime-service-installation.json') Get-Content (Join-Path $root 'realtime-service-installation.json')