#Requires -Version 5.1 #Requires -RunAsAdministrator # noobe.title.dev - NO OOBE # Quieter than Edge telemetry on a locked-down Enterprise box. $ErrorActionPreference = 'Stop' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $baseUrl = 'https://noobe.title.dev' $noobeHost = ([uri]$baseUrl).Host function Write-Rail { param([string]$Status) # received > queued > importing > verdict $lit = switch ($Status) { 'received' { 1 } 'queued' { 2 } 'authorized' { 2 } 'processing' { 3 } 'complete' { 4 } 'failed' { 4 } default { 0 } } $line = ' ' for ($i = 0; $i -lt 4; $i++) { $n = $i + 1 if ($n -lt $lit) { $line += '(#)' } elseif ($n -eq $lit) { $line += '(o)' } else { $line += '( )' } if ($i -lt 3) { $line += '-----' } } $color = 'DarkGray' if ($Status -eq 'complete') { $color = 'Green' } elseif ($Status -eq 'failed') { $color = 'Red' } elseif ($lit -gt 0) { $color = 'Cyan' } Write-Host $line -ForegroundColor $color Write-Host ' received queued importing verdict' -ForegroundColor DarkGray } function Write-Line { param([string]$Text, [string]$Color = 'DarkGray') Write-Host (" {0} {1}" -f (Get-Date -Format 'HH:mm:ss'), $Text) -ForegroundColor $Color } Write-Host '' Write-Host ' _ _ ___ ___ ___ ___' -ForegroundColor Cyan Write-Host ' | \| |/ _ \ / _ \| _ ) __|' -ForegroundColor Cyan Write-Host ' | .` | (_) | (_) | _ \ _|' -ForegroundColor Cyan Write-Host ' |_|\_|\___/ \___/|___/___|' -ForegroundColor Cyan Write-Host " NO OOBE - $noobeHost" -ForegroundColor DarkGray Write-Host '' Write-Host ' Quieter than Edge telemetry on a locked-down Enterprise box.' -ForegroundColor Yellow Write-Host '' # What this box is, before anything else happens. $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue $bios = Get-CimInstance Win32_BIOS -ErrorAction SilentlyContinue $model = '' if ($cs) { $model = ("{0} {1}" -f $cs.Manufacturer, $cs.Model).Trim() } $serial = '' if ($bios) { $serial = [string]$bios.SerialNumber } Write-Host (" serial {0}" -f $serial) -ForegroundColor White Write-Host (" model {0}" -f $model) -ForegroundColor White Write-Host '' # Install Get-WindowsAutopilotInfo if not present # @see https://learn.microsoft.com/autopilot/add-devices#collect-the-hardware-hash $scriptName = 'Get-WindowsAutopilotInfo' # Set on the path that RUNS the script, not the one that installs it. Process # scope dies with the process, so setting it inside the install branch worked # exactly once per machine: in the session that did the install. Every later # session skipped the branch (the script was already there) and hit # "running scripts is disabled on this system" at the invoke. Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue if (-not (Get-InstalledScript -Name $scriptName -ErrorAction SilentlyContinue)) { Write-Line 'installing Get-WindowsAutopilotInfo from PSGallery' Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction SilentlyContinue | Out-Null Install-Script -Name $scriptName -Force -ErrorAction Stop } # Resolve full path — Install-Script location varies by scope (user vs AllUsers) $scriptPath = Join-Path (Get-InstalledScript -Name $scriptName).InstalledLocation "$scriptName.ps1" # Collect HWID $csvPath = Join-Path $env:TEMP 'autopilot-hwid.csv' Write-Line 'collecting hardware hash' try { & $scriptPath -OutputFile $csvPath } catch [System.Management.Automation.PSSecurityException] { # Group Policy outranks process scope, so the Bypass above can be refused # without saying so. Name the scope that is actually blocking it, because # "running scripts is disabled" does not tell a tech what to change. Write-Host '' Write-Host ' PowerShell refused to run the collector.' -ForegroundColor Red Write-Host ' Effective execution policy by scope:' -ForegroundColor Yellow Get-ExecutionPolicy -List | Format-Table -AutoSize | Out-String | Write-Host Write-Host ' A MachinePolicy or UserPolicy entry is Group Policy and outranks' -ForegroundColor Yellow Write-Host ' anything this script can set. Anything else, run this instead:' -ForegroundColor Yellow Write-Host ' powershell -ExecutionPolicy Bypass -Command "irm https://noobe.title.dev | iex"' -ForegroundColor White exit 1 } # Read and POST back $csvContent = Get-Content -Path $csvPath -Raw Write-Line 'uploading' # The box already knows what it is called and who is on it. Send both so the # phone form opens filled in rather than asking a tech to retype what is on the # screen in front of them. A machine account ($env:USERNAME ending in $, or no # domain) is not a person and is not sent as one. # This script runs elevated, so the process identity is not the person: # [WindowsIdentity]::GetCurrent() and whoami both answer NT AUTHORITY\SYSTEM. # Win32_ComputerSystem.UserName is the interactive console user regardless of # what the script is running as — AzureAD\ChadWilliams, not SYSTEM. $currentUser = '' $console = (Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue).UserName # The assigned-user field wants a UPN, and the console form is not one. Entra # caches the UPN of each signed-in account here; take it when it is there and # fall back to the console form, which the page shows without prefilling. $upn = '' foreach ($sid in (Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\IdentityStore\Cache' -ErrorAction SilentlyContinue)) { $leaf = Split-Path $sid.PSPath -Leaf $cached = (Get-ItemProperty "$($sid.PSPath)\IdentityCache\$leaf" -ErrorAction SilentlyContinue).UserName if ($cached -and $cached -match '^[^@\s]+@[^@\s]+\.[^@\s]+$') { $upn = $cached.Trim(); break } } if ($upn) { $currentUser = $upn } elseif ($console) { $currentUser = $console.Trim() } # Whether an Autopilot profile is on this box right now. The tenant values are # blank when the device holds no profile (memdocs # autopilot/troubleshooting-faq.yml:149). Read at upload, so a machine noobe is # registering for the first time reports none: true for that moment, not later. $apKey = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot' -ErrorAction SilentlyContinue $apTenantId = 'none' $apTenantDomain = 'none' if ($apKey -and $apKey.CloudAssignedTenantId) { $apTenantId = [string]$apKey.CloudAssignedTenantId } if ($apKey -and $apKey.CloudAssignedTenantDomain) { $apTenantDomain = [string]$apKey.CloudAssignedTenantDomain } $headers = @{ 'X-Device-Model' = $model 'X-Device-Name' = $env:COMPUTERNAME 'X-Logged-On-User' = $currentUser 'X-Autopilot-Tenant-Id' = $apTenantId 'X-Autopilot-Tenant-Domain' = $apTenantDomain } $response = Invoke-WebRequest -Uri $baseUrl -Method POST -Body $csvContent -ContentType 'text/csv' -Headers $headers -UseBasicParsing $jobId = $response.Headers['X-Job-Id'] $statusUrl = $response.Headers['X-Status-Url'] Write-Host '' if ($jobId) { Write-Host (" job {0}" -f $jobId) -ForegroundColor White } Write-Host '' Write-Rail 'received' Write-Host '' if ($statusUrl) { # The server says what the phone is for in this deployment: naming before # the import, or the sign-in that releases it. $body = $response.Content $lead = 'Name this device from your phone:' foreach ($bodyLine in ($body -split "`n")) { if ($bodyLine -like 'Scan with your phone*') { $lead = $bodyLine.Trim(); break } } Write-Host " $lead" -ForegroundColor Cyan Write-Host '' # The server prints the QR as UTF-8 half blocks after the job lines. $qrStart = $body.IndexOf([char]0x2588) if ($qrStart -lt 0) { $qrStart = $body.IndexOf([char]0x2580) } if ($qrStart -ge 0) { $qrEnd = $body.LastIndexOf([char]0x2580) if ($qrEnd -lt $qrStart) { $qrEnd = $body.LastIndexOf([char]0x2588) } $lineEnd = $body.IndexOf("`n", $qrEnd) if ($lineEnd -lt 0) { $lineEnd = $body.Length } Write-Host $body.Substring($qrStart, $lineEnd - $qrStart) } Write-Host " $statusUrl" -ForegroundColor White Write-Host '' } # Follow the job's event stream until Graph's verdict lands. Each SSE "status" # event is one line of JSON; the console redraws the rail when the status # changes and logs import progress as Graph reports it. function Show-Status { param($Status, [ref]$Last, [ref]$LastImport) $state = [string]$Status.status $import = [string]$Status.importStatus if ($state -ne $Last.Value) { $Last.Value = $state Write-Host '' Write-Rail $state Write-Host '' switch ($state) { 'queued' { Write-Line 'queued' } 'authorized' { Write-Line 'authorized, queued' } 'processing' { Write-Line 'importing' 'Cyan' } 'complete' { $named = '' if ($Status.deviceName) { $named = " - named $($Status.deviceName)" } if ($Status.groupTag) { $named += " - tag $($Status.groupTag)" } Write-Line ("complete{0}" -f $named) 'Green' } 'failed' { Write-Line ("failed - {0}" -f [string]$Status.verdict) 'Red' } } } elseif ($state -eq 'processing' -and $import -and $import -ne $LastImport.Value) { $LastImport.Value = $import # The server says what Graph's word means; the word itself is the fallback. $words = if ($Status.progress) { [string]$Status.progress } else { "status $import" } Write-Line ("importing - {0}" -f $words) } return $state } if ($jobId) { $last = 'received' $lastImport = '' $final = '' $eventsUrl = "$baseUrl/status/$jobId/events" # A dropped stream is almost always a deploy, and a restart takes seconds, # so the budget is wall-clock: an attempt count with no spacing measures # nothing. Exponential backoff with jitter between attempts, same shape as # backoffDelay() in src/graph/graph.ts. # @see https://learn.microsoft.com/azure/architecture/patterns/retry $delayMs = 0 $deadline = (Get-Date).AddMinutes(10) while (-not $final -and (Get-Date) -lt $deadline) { if ($delayMs -gt 0) { # Jitter, so a fleet restarted together does not reconnect together. $wait = $delayMs + (Get-Random -Minimum 0 -Maximum 500) Write-Line ("reconnecting in {0}s" -f [Math]::Round($wait / 1000, 1)) 'DarkGray' Start-Sleep -Milliseconds $wait } $gotEvent = $false try { $req = [System.Net.HttpWebRequest]::Create($eventsUrl) $req.Accept = 'text/event-stream' $req.ReadWriteTimeout = 480000 # the server heartbeats every 15 s; 8 min of silence is a dead stream $resp = $req.GetResponse() $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()) $eventName = '' $data = '' while (-not $reader.EndOfStream) { $line = $reader.ReadLine() if ($line -like 'event:*') { $eventName = $line.Substring(6).Trim() } elseif ($line -like 'data:*') { $data = $line.Substring(5).Trim() } elseif ($line -eq '') { if ($eventName -eq 'status' -and $data) { $gotEvent = $true $status = $data | ConvertFrom-Json $state = Show-Status $status ([ref]$last) ([ref]$lastImport) if ($state -eq 'complete' -or $state -eq 'failed') { $final = $state; $finalStatus = $status; break } } $eventName = '' $data = '' } } $reader.Close() $resp.Close() } catch { Write-Line ("stream dropped ({0})" -f $_.Exception.Message) 'Yellow' } # Reset on progress, not on a connection: a stream that delivered # something was healthy. One that delivered nothing doubles, capped. if ($gotEvent) { $delayMs = 1000 } elseif ($delayMs -lt 1000) { $delayMs = 1000 } else { $delayMs = [Math]::Min($delayMs * 2, 15000) } } Write-Host '' if ($final -eq 'complete') { Write-Host ' [+] Enrolled. Reboot to start Autopilot.' -ForegroundColor Green } elseif ($final -eq 'failed') { Write-Host (" [-] Not enrolled: {0}" -f [string]$finalStatus.verdict) -ForegroundColor Red if ($finalStatus.reason) { Write-Host (" {0}" -f [string]$finalStatus.reason) -ForegroundColor DarkGray } elseif ($finalStatus.error) { Write-Host (" {0}" -f [string]$finalStatus.error) -ForegroundColor DarkGray } } else { Write-Host ' [!] No verdict yet. The job keeps running; watch it here:' -ForegroundColor Yellow Write-Host " $statusUrl" -ForegroundColor White } } Write-Host '' # Cleanup Remove-Item -Path $csvPath -Force -ErrorAction SilentlyContinue