@trillboards/edge-sdk 0.2.24 → 0.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -202,6 +202,7 @@ Copy-Item -Force "$Here\trillboards-hidden-run.vbs" "$Payload\bin\trillboards-hi
202
202
  Copy-Item -Force "$Here\ensure-sensing.ps1" "$Payload\bin\ensure-sensing.ps1"
203
203
  Copy-Item -Force "$Here\run-connector.ps1" "$Payload\bin\run-connector.ps1"
204
204
  Copy-Item -Force "$Here\register-task.ps1" "$Payload\bin\register-task.ps1"
205
+ Copy-Item -Force "$Here\enroll-connector.ps1" "$Payload\bin\enroll-connector.ps1"
205
206
  Copy-Item -Force "$Here\stop-existing.ps1" "$Payload\bin\stop-existing.ps1"
206
207
  Copy-Item -Force "$Here\install-update.ps1" "$Payload\bin\install-update.ps1"
207
208
  # The in-field updater. Ships in bin\ so an INSTALLED connector can drive its own
@@ -67,8 +67,81 @@ $Www = Join-Path $Root 'www'
67
67
  $Certs = New-Object System.Collections.Generic.List[string]
68
68
  $LocalUser = 'tbupd'
69
69
  $Failures = 0
70
+ $StartedAt = Get-Date
70
71
 
71
- function Say([string]$m) { Write-Host "[update-applier] $m" }
72
+ <#
73
+ A SELF-IMPOSED DEADLINE, because the first run of this harness did not fail -
74
+ it ate the whole job. It printed ONE line, ran for 11 minutes, and the JOB hit
75
+ its 15-minute cap, so every other proof in that step lost its budget too and
76
+ the log said only "The operation was canceled" - naming no cause and proving
77
+ nothing. Not one arm had run.
78
+
79
+ A harness that cannot finish must say so ITSELF, at the point it gives up, and
80
+ loudly enough to be actionable. Every phase below also prints its elapsed
81
+ time, because the reason that first failure was undiagnosable was that there
82
+ was no output between "origin listening" and the first arm.
83
+ #>
84
+ $DeadlineMinutes = 8
85
+
86
+ function Elapsed { ((Get-Date) - $script:StartedAt).TotalSeconds }
87
+ function Say([string]$m) { Write-Host ("[update-applier {0,6:N1}s] {1}" -f (Elapsed), $m) }
88
+
89
+ $script:CurrentPhase = 'startup'
90
+
91
+ function Fail-Deadline([string]$Phase, [string]$When) {
92
+ Write-Host "FAIL - the harness exceeded its $($script:DeadlineMinutes)-minute deadline $When`: $Phase"
93
+ Write-Host ' HARNESS BUDGET failure, not a product failure. The arms above ran;'
94
+ Write-Host ' the ones below never started. Read the per-phase timings to find'
95
+ Write-Host ' what got slow before assuming a defect in apply-update.ps1.'
96
+ Cleanup
97
+ exit 1
98
+ }
99
+
100
+ function Assert-WithinDeadline([string]$Phase) {
101
+ $script:CurrentPhase = $Phase
102
+ if (((Get-Date) - $script:StartedAt).TotalMinutes -lt $script:DeadlineMinutes) { return }
103
+ Fail-Deadline $Phase 'before starting'
104
+ }
105
+
106
+ function Remaining-DeadlineSeconds {
107
+ [Math]::Max(0, ($script:DeadlineMinutes * 60) - (Elapsed))
108
+ }
109
+
110
+ <#
111
+ Wait for a process, but never past the harness deadline.
112
+
113
+ Assert-WithinDeadline can only sample time BETWEEN phases, so on its own it
114
+ cannot stop a phase that is ALREADY blocked - and that is precisely the
115
+ failure this harness exists to prevent. The first run spent eleven minutes
116
+ inside a single call; the job's 15-minute cap ended it, the harness did not,
117
+ and the log named no cause. A deadline that can only fire between the things
118
+ it is bounding is not a deadline.
119
+
120
+ So every blocking wait goes through here, bounded by the budget REMAINING at
121
+ the moment the wait starts. The deadline therefore stays a whole-harness
122
+ deadline rather than becoming a per-call timeout: eight slow phases cannot
123
+ each consume eight minutes.
124
+
125
+ On expiry we kill the process before reporting, so the cleanup that follows
126
+ is not itself blocked by the thing that hung.
127
+ #>
128
+ function Wait-Bounded {
129
+ param([System.Diagnostics.Process]$Process, [string]$Phase = $script:CurrentPhase)
130
+ $budget = Remaining-DeadlineSeconds
131
+ if ($budget -le 0) {
132
+ try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch {}
133
+ Fail-Deadline $Phase 'before starting'
134
+ }
135
+ $exited = $false
136
+ try { $exited = $Process.WaitForExit([int]([Math]::Min(2147483, $budget) * 1000)) } catch { $exited = $true }
137
+ if (-not $exited) {
138
+ try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch {}
139
+ try { $Process.WaitForExit(5000) | Out-Null } catch {}
140
+ Fail-Deadline $Phase 'while running'
141
+ }
142
+ try { $Process.Refresh() } catch {}
143
+ return $Process
144
+ }
72
145
  function Pass([string]$m) { Write-Host "PASS - $m" }
73
146
  function Bad([string]$m) { Write-Host "FAIL - $m"; $script:Failures++ }
74
147
  function Die([string]$m) { Write-Host "FAIL - $m"; Cleanup; exit 1 }
@@ -158,27 +231,70 @@ function Sign-File([string]$Path, $Cert) {
158
231
  # ---------------------------------------------------------------------------
159
232
  # Fixtures
160
233
  # ---------------------------------------------------------------------------
234
+ <#
235
+ BUILT ONCE, REUSED. The first version of this harness rebuilt the whole tree
236
+ per arm: 4 trees x (copy the runner's ~110 MB node.exe + Authenticode-sign 12
237
+ scripts + zip the lot). On windows-latest that produced ONE line of output and
238
+ then nothing for 11 minutes, until the job hit its 15-minute cap and was
239
+ cancelled - so not one arm ever ran, and the step looked hung rather than slow
240
+ because there was no progress output between phases at all.
241
+
242
+ Two fixes, and the second is the one that matters for the next person:
243
+
244
+ 1. Sign the shipped script set ONCE into a cache and COPY the signed bytes
245
+ into each tree. Authenticode signing is the expensive part, not the file
246
+ copy, and the signatures are identical across trees by construction.
247
+ 2. bin\node.exe is a small system PE re-signed with the harness certificate,
248
+ NOT the real 110 MB node. The applier only needs it to be PRESENT and
249
+ VALID; the two arms that actually EXECUTE node ask for -RealNode. Re-signing
250
+ also removes the Microsoft CRL distribution point, so the applier's
251
+ Get-AuthenticodeSignature calls resolve against a self-signed chain with
252
+ nothing to look up instead of reaching for the network 13 times per stage.
253
+ #>
254
+ $SignedCache = $null
255
+
256
+ function Initialize-SignedCache($SignCert) {
257
+ if ($script:SignedCache) { return }
258
+ $script:SignedCache = Join-Path $Root 'signed-cache'
259
+ New-Item -ItemType Directory -Force -Path $script:SignedCache | Out-Null
260
+ $t0 = Get-Date
261
+ foreach ($f in @(& (Join-Path $Here 'list-shipped-scripts.ps1') -Kind signable -AbsolutePath)) {
262
+ $dest = Join-Path $script:SignedCache (Split-Path -Leaf $f)
263
+ Copy-Item -LiteralPath $f -Destination $dest -Force
264
+ Sign-File $dest $SignCert
265
+ }
266
+ # A small, already-PE stub that becomes bin\node.exe. where.exe is ~40 KB and
267
+ # is a real signed PE, so re-signing it with the harness cert succeeds and the
268
+ # result carries OUR chain rather than Microsoft's.
269
+ $stub = Join-Path $script:SignedCache 'node.exe'
270
+ Copy-Item -LiteralPath (Join-Path $env:WINDIR 'System32\where.exe') -Destination $stub -Force
271
+ Sign-File $stub $SignCert
272
+ Say ("signed-script cache built in {0:N1}s" -f ((Get-Date) - $t0).TotalSeconds)
273
+ }
274
+
161
275
  <#
162
276
  A tree shaped exactly like the shipped connector: bin\ with the real scripts
163
277
  (so the applier's "at least 10 signed executables" floor is met by the real
164
278
  file set, not a stub), a node.exe, and a cli.js that answers `version` the way
165
279
  install-update.ps1 requires.
166
280
  #>
167
- function New-PayloadTree([string]$Dest, $SignCert, [switch]$LogonOnlyRegisterTask, [switch]$WithUnsignedScript) {
281
+ function New-PayloadTree([string]$Dest, $SignCert, [switch]$LogonOnlyRegisterTask, [switch]$WithUnsignedScript, [switch]$RealNode) {
282
+ Initialize-SignedCache $SignCert
283
+ $t0 = Get-Date
168
284
  if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
169
285
  $bin = Join-Path $Dest 'bin'
170
286
  $dist = Join-Path $Dest 'lib\edge-sdk\dist'
171
287
  New-Item -ItemType Directory -Force -Path $bin, $dist | Out-Null
172
288
 
173
- foreach ($f in @(& (Join-Path $Here 'list-shipped-scripts.ps1') -Kind signable -AbsolutePath)) {
174
- Copy-Item -LiteralPath $f -Destination (Join-Path $bin (Split-Path -Leaf $f)) -Force
175
- }
289
+ Copy-Item -Path (Join-Path $script:SignedCache '*') -Destination $bin -Force
290
+
176
291
  if ($LogonOnlyRegisterTask) {
177
292
  # A register-task.ps1 that deliberately produces the PRE-headless shape.
178
293
  # This is how arm J reproduces, on an elevated runner, the downgrade a
179
294
  # non-elevated field caller would suffer: the cause differs, the observable
180
295
  # (the task comes back logon-only) is identical, and that observable is what
181
- # the guard keys on.
296
+ # the guard keys on. Signed, or the payload would be refused by the
297
+ # signature check before it could ever reach install-update.ps1.
182
298
  $downgrade = @'
183
299
  [CmdletBinding()]
184
300
  param([Parameter(Mandatory = $true)][string]$InstallDir, [string]$Mode = "auto-sense")
@@ -190,29 +306,28 @@ $principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -
190
306
  Unregister-ScheduledTask -TaskName "TrillboardsEdge" -Confirm:$false -ErrorAction SilentlyContinue
191
307
  Register-ScheduledTask -TaskName "TrillboardsEdge" -Action $action -Trigger $trigger -Principal $principal | Out-Null
192
308
  '@
193
- [System.IO.File]::WriteAllText((Join-Path $bin 'register-task.ps1'), $downgrade, (New-Object System.Text.UTF8Encoding($false)))
309
+ $rt = Join-Path $bin 'register-task.ps1'
310
+ [System.IO.File]::WriteAllText($rt, $downgrade, (New-Object System.Text.UTF8Encoding($false)))
311
+ Sign-File $rt $SignCert
194
312
  }
195
313
  if ($WithUnsignedScript) {
196
314
  [System.IO.File]::WriteAllText((Join-Path $bin 'rogue.ps1'), "exit 0`r`n", (New-Object System.Text.UTF8Encoding($false)))
197
315
  }
316
+ if ($RealNode) {
317
+ # Only the arms that actually RUN the connector need a real interpreter.
318
+ Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
319
+ $nodeSig = Get-AuthenticodeSignature -LiteralPath (Join-Path $bin 'node.exe')
320
+ if ($nodeSig.Status -ne 'Valid') { Sign-File (Join-Path $bin 'node.exe') $SignCert }
321
+ }
198
322
 
199
- Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
200
323
  [System.IO.File]::WriteAllText(
201
324
  (Join-Path $dist 'cli.js'),
202
325
  "if (process.argv[2] === 'version') { console.log('@trillboards/edge-sdk v9.9.9'); process.exit(0); }`r`nsetInterval(function(){}, 1000);`r`n",
203
326
  (New-Object System.Text.UTF8Encoding($false)))
204
-
205
- foreach ($f in @(Get-ChildItem -LiteralPath $bin -File | Where-Object { $_.Name -ne 'rogue.ps1' })) {
206
- if ($f.Extension -in @('.ps1', '.vbs')) { Sign-File $f.FullName $SignCert }
207
- }
208
- # The runner's node.exe is the official OpenJS build and already Valid; sign a
209
- # copy only if it is not, so the floor check measures signatures rather than
210
- # the runner image.
211
- $nodeSig = Get-AuthenticodeSignature -LiteralPath (Join-Path $bin 'node.exe')
212
- if ($nodeSig.Status -ne 'Valid') { Sign-File (Join-Path $bin 'node.exe') $SignCert }
327
+ Say ("payload tree $(Split-Path -Leaf $Dest) built in {0:N1}s (realNode=$RealNode)" -f ((Get-Date) - $t0).TotalSeconds)
213
328
  }
214
329
 
215
- function New-InstallTree([string]$Dest) {
330
+ function New-InstallTree([string]$Dest, [switch]$RealNode) {
216
331
  if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
217
332
  $bin = Join-Path $Dest 'bin'
218
333
  $dist = Join-Path $Dest 'lib\edge-sdk\dist'
@@ -220,7 +335,14 @@ function New-InstallTree([string]$Dest) {
220
335
  foreach ($f in @('stop-existing.ps1', 'register-task.ps1', 'install-update.ps1', 'apply-update.ps1', 'run-connector.ps1', 'trillboards-edge-task.vbs')) {
221
336
  Copy-Item -LiteralPath (Join-Path $Deploy $f) -Destination (Join-Path $bin $f) -Force
222
337
  }
223
- Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
338
+ # Same reasoning as the payload tree: only the arms that RUN the connector
339
+ # need a real interpreter, and copying ~110 MB per arm is what blew the job
340
+ # budget the first time this harness ran.
341
+ if ($RealNode) {
342
+ Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
343
+ } elseif ($script:SignedCache -and (Test-Path -LiteralPath (Join-Path $script:SignedCache 'node.exe'))) {
344
+ Copy-Item -LiteralPath (Join-Path $script:SignedCache 'node.exe') -Destination (Join-Path $bin 'node.exe') -Force
345
+ }
224
346
  [System.IO.File]::WriteAllText((Join-Path $dist 'cli.js'), "setInterval(function(){}, 1000);`r`n", (New-Object System.Text.UTF8Encoding($false)))
225
347
  }
226
348
 
@@ -242,7 +364,8 @@ function New-Catalog([string]$Path, [string]$Version, [string]$Sha, [long]$Size)
242
364
  function Invoke-Applier([string]$InstallDir, [string[]]$Args) {
243
365
  $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
244
366
  $all = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $Applier, '-InstallDir', $InstallDir) + $Args
245
- $p = Start-Process -FilePath $ps -ArgumentList $all -NoNewWindow -PassThru -Wait
367
+ $p = Start-Process -FilePath $ps -ArgumentList $all -NoNewWindow -PassThru
368
+ $null = Wait-Bounded -Process $p
246
369
  $updateDir = Join-Path $InstallDir '.auto-update'
247
370
  $outcome = $null
248
371
  $outcomePath = Join-Path $updateDir 'outcome.json'
@@ -269,15 +392,18 @@ function Invoke-Applier([string]$InstallDir, [string[]]$Args) {
269
392
  }
270
393
 
271
394
  function Publish-Payload([string]$TreeDir, [string]$Name) {
395
+ $t0 = Get-Date
272
396
  $zip = Join-Path $Www $Name
273
397
  if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }
274
398
  Add-Type -AssemblyName System.IO.Compression.FileSystem
275
399
  [System.IO.Compression.ZipFile]::CreateFromDirectory($TreeDir, $zip, [System.IO.Compression.CompressionLevel]::Fastest, $false)
276
- return [pscustomobject]@{
400
+ $out = [pscustomobject]@{
277
401
  Path = $zip
278
402
  Bytes = (Get-Item -LiteralPath $zip).Length
279
403
  Sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
280
404
  }
405
+ Say ("zipped $Name : {0:N1} MB in {1:N1}s" -f ($out.Bytes / 1MB), ((Get-Date) - $t0).TotalSeconds)
406
+ return $out
281
407
  }
282
408
 
283
409
  function Get-Shape([string]$Name) {
@@ -318,15 +444,18 @@ try {
318
444
  New-Item -ItemType Directory -Force -Path $Root, $Www | Out-Null
319
445
  Start-Origin
320
446
 
447
+ Say 'minting two throwaway code-signing certificates'
321
448
  $goodCert = New-HarnessCert 'Trillboards CI Update Harness'
322
449
  $otherCert = New-HarnessCert 'Definitely Not Trillboards'
323
450
  $signer = 'Trillboards CI Update Harness'
324
451
  $version = '9.9.9'
452
+ Say 'certificates ready'
325
453
 
326
454
  $payloadTree = Join-Path $Root 'payload-src'
327
455
  New-PayloadTree -Dest $payloadTree -SignCert $goodCert
328
456
  $good = Publish-Payload $payloadTree 'payload.zip'
329
- Say ("payload: {0} bytes, sha {1}" -f $good.Bytes, $good.Sha)
457
+ Say ("reference payload: {0} bytes, sha {1}" -f $good.Bytes, $good.Sha)
458
+ Assert-WithinDeadline 'building the reference payload'
330
459
 
331
460
  $catalogGood = Join-Path $Www 'catalog-good.ps1'
332
461
  New-Catalog $catalogGood $version $good.Sha $good.Bytes
@@ -336,6 +465,7 @@ try {
336
465
 
337
466
  # ---- A: HAPPY PATH. The control for every arm below it -------------------
338
467
  Say '--- arm A: happy path (the shared control) ---'
468
+ Assert-WithinDeadline 'arm A: happy path (the shared control)'
339
469
  $installA = Join-Path $Root 'install-a'
340
470
  New-InstallTree $installA
341
471
  $a = Invoke-Applier $installA @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-good.ps1",
@@ -349,6 +479,7 @@ try {
349
479
 
350
480
  # ---- B: WRONG HASH -------------------------------------------------------
351
481
  Say '--- arm B: the payload does not match the signed hash ---'
482
+ Assert-WithinDeadline 'arm B: the payload does not match the signed hash'
352
483
  $tampered = Join-Path $Www 'payload-tampered.zip'
353
484
  Copy-Item -LiteralPath $good.Path -Destination $tampered -Force
354
485
  $bytes = [System.IO.File]::ReadAllBytes($tampered)
@@ -366,6 +497,7 @@ try {
366
497
 
367
498
  # ---- C: SHORT BODY -------------------------------------------------------
368
499
  Say '--- arm C: the download is short (what a disk filling mid-download looks like) ---'
500
+ Assert-WithinDeadline 'arm C: the download is short (what a disk filling mid-download looks like)'
369
501
  $short = Join-Path $Www 'payload-short.zip'
370
502
  # A real byte[] copy, not a PowerShell range slice: `$bytes[0..n]` boxes every
371
503
  # element into an Object[], which WriteAllBytes will not take and which is
@@ -385,6 +517,7 @@ try {
385
517
 
386
518
  # ---- D: UNSIGNED CATALOG -------------------------------------------------
387
519
  Say '--- arm D: the catalog is not signed at all ---'
520
+ Assert-WithinDeadline 'arm D: the catalog is not signed at all'
388
521
  $catalogUnsigned = Join-Path $Www 'catalog-unsigned.ps1'
389
522
  New-Catalog $catalogUnsigned $version $good.Sha $good.Bytes
390
523
  if ((Get-AuthenticodeSignature -LiteralPath $catalogUnsigned).Status -eq 'Valid') {
@@ -400,6 +533,7 @@ try {
400
533
 
401
534
  # ---- E: WRONG PUBLISHER (the sharpest control) ---------------------------
402
535
  Say '--- arm E: the catalog is VALIDLY signed, by somebody else ---'
536
+ Assert-WithinDeadline 'arm E: the catalog is VALIDLY signed, by somebody else'
403
537
  $catalogOther = Join-Path $Www 'catalog-other.ps1'
404
538
  New-Catalog $catalogOther $version $good.Sha $good.Bytes
405
539
  Sign-File $catalogOther $otherCert
@@ -417,6 +551,7 @@ try {
417
551
 
418
552
  # ---- F: SIGNED THEN TAMPERED --------------------------------------------
419
553
  Say '--- arm F: the catalog was signed, then edited ---'
554
+ Assert-WithinDeadline 'arm F: the catalog was signed, then edited'
420
555
  $catalogEdited = Join-Path $Www 'catalog-edited.ps1'
421
556
  Copy-Item -LiteralPath $catalogGood -Destination $catalogEdited -Force
422
557
  Add-Content -LiteralPath $catalogEdited -Value '# an edit after signing'
@@ -431,6 +566,7 @@ try {
431
566
 
432
567
  # ---- G: DISK PREFLIGHT ---------------------------------------------------
433
568
  Say '--- arm G: the declared payload does not fit on the volume ---'
569
+ Assert-WithinDeadline 'arm G: the declared payload does not fit on the volume'
434
570
  $huge = Join-Path $Www 'catalog-huge.ps1'
435
571
  New-Catalog $huge $version $good.Sha 900000000000
436
572
  Sign-File $huge $goodCert
@@ -445,6 +581,7 @@ try {
445
581
 
446
582
  # ---- H: UNSIGNED SCRIPT INSIDE THE PAYLOAD -------------------------------
447
583
  Say '--- arm H: the payload contains an unsigned script ---'
584
+ Assert-WithinDeadline 'arm H: the payload contains an unsigned script'
448
585
  $rogueTree = Join-Path $Root 'payload-rogue'
449
586
  New-PayloadTree -Dest $rogueTree -SignCert $goodCert -WithUnsignedScript
450
587
  $rogue = Publish-Payload $rogueTree 'payload-rogue.zip'
@@ -468,9 +605,9 @@ try {
468
605
  )) {
469
606
  Say "--- arm $($arm.Name): commit over a RUNNING connector, with a payload that would re-register logon-only ---"
470
607
  $installJ = Join-Path $Root "install-$($arm.Name)"
471
- New-InstallTree $installJ
608
+ New-InstallTree $installJ -RealNode
472
609
  $downTree = Join-Path $Root "payload-down-$($arm.Name)"
473
- New-PayloadTree -Dest $downTree -SignCert $goodCert -LogonOnlyRegisterTask
610
+ New-PayloadTree -Dest $downTree -SignCert $goodCert -LogonOnlyRegisterTask -RealNode
474
611
  $down = Publish-Payload $downTree "payload-down-$($arm.Name).zip"
475
612
  $catDown = Join-Path $Www "catalog-down-$($arm.Name).ps1"
476
613
  New-Catalog $catDown $version $down.Sha $down.Bytes
@@ -521,8 +658,10 @@ try {
521
658
  # ---- K: the would-downgrade preflight, from an UNELEVATED caller ---------
522
659
  if ($SkipUnelevatedArm) {
523
660
  Say '--- arm K SKIPPED by request ---'
661
+ Assert-WithinDeadline 'arm K SKIPPED by request'
524
662
  } else {
525
663
  Say '--- arm K: an unelevated caller must REFUSE rather than downgrade ---'
664
+ Assert-WithinDeadline 'arm K: an unelevated caller must REFUSE rather than downgrade'
526
665
  $installK = Join-Path $Root 'install-k'
527
666
  New-InstallTree $installK
528
667
  $downTree = Join-Path $Root 'payload-k'
@@ -563,8 +702,9 @@ try {
563
702
  $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
564
703
  # Materialise the profile once, so the run under test is not measuring
565
704
  # first-logon profile creation.
566
- Start-Process -FilePath $env:ComSpec -ArgumentList '/c', 'exit' -Credential $cred -LoadUserProfile `
567
- -WorkingDirectory 'C:\' -Wait -ErrorAction Stop
705
+ $warm = Start-Process -FilePath $env:ComSpec -ArgumentList '/c', 'exit' -Credential $cred -LoadUserProfile `
706
+ -WorkingDirectory 'C:\' -PassThru -ErrorAction Stop
707
+ $null = Wait-Bounded -Process $warm -Phase 'arm K: materialising the local profile'
568
708
 
569
709
  # Drive the COPY inside the install tree, not the one in the repo
570
710
  # workspace: the ACL above covers the install tree, and the runner's
@@ -572,10 +712,11 @@ try {
572
712
  $applierCopy = Join-Path $installK 'bin\apply-update.ps1'
573
713
  $outK = Join-Path $Root 'k-stdout.txt'
574
714
  $errK = Join-Path $Root 'k-stderr.txt'
575
- $null = Start-Process -FilePath $ps -Credential $cred -LoadUserProfile -WorkingDirectory 'C:\' -PassThru -Wait `
715
+ $pk = Start-Process -FilePath $ps -Credential $cred -LoadUserProfile -WorkingDirectory 'C:\' -PassThru `
576
716
  -RedirectStandardOutput $outK -RedirectStandardError $errK `
577
717
  -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $applierCopy,
578
718
  '-InstallDir', $installK, '-Commit', '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8')
719
+ $null = Wait-Bounded -Process $pk
579
720
  foreach ($f in @($outK, $errK)) {
580
721
  if (Test-Path -LiteralPath $f) {
581
722
  $t = Get-Content -Raw -LiteralPath $f
@@ -0,0 +1,169 @@
1
+ <#
2
+ enroll-connector.ps1 - UNATTENDED provisioning step for an MDM/Intune/SCCM
3
+ deployment. Invoked by installer.nsi when /ENROLLMENT_TOKEN= was supplied.
4
+
5
+ WHAT IT DOES
6
+ 1. Writes the fleet enrollment credential to $InstallDir\trillboards.enrollment
7
+ with an ACL that grants the installing user ONLY (inheritance disabled),
8
+ because a plain %LOCALAPPDATA% file inherits whatever the parent grants
9
+ and this is the one credential in the package that can enroll machines.
10
+ 2. Runs `trillboards-edge enroll`, which exchanges it for THIS machine's own
11
+ credential, writes that into trillboards.config.json, and DELETES the
12
+ enrollment file.
13
+ 3. Deletes the file itself if the CLI could not (belt and braces - a fleet
14
+ credential left on 200 store PCs is the failure this whole design exists
15
+ to prevent).
16
+
17
+ EXIT CODES - these are the installer's, and they are deliberately NOT fatal to
18
+ the install:
19
+ 0 enrolled, or already enrolled (a re-push is idempotent and free)
20
+ 1 the credential was rejected: expired, revoked, or every enrollment it was
21
+ issued for is already used. A human must issue a new one.
22
+ 6 network/server failure. The credential was NOT spent; the scheduled task
23
+ retries on the next logon.
24
+
25
+ A failed enrollment leaves a correctly INSTALLED connector that is simply not
26
+ yet provisioned, which is recoverable by re-running this script or by the
27
+ ordinary interactive setup. Aborting the whole install instead would leave the
28
+ machine with nothing and no way to retry.
29
+ #>
30
+ [CmdletBinding()]
31
+ param(
32
+ [Parameter(Mandatory = $true)][string]$InstallDir,
33
+ [Parameter(Mandatory = $true)][string]$EnrollmentToken,
34
+ [string]$SiteLabel = "",
35
+ [string]$ApiBase = "",
36
+ # Optional recorder credentials. Passed straight through to the CLI and never
37
+ # written to disk or to the log - only their PRESENCE is logged.
38
+ [string]$NvrUser = "",
39
+ [string]$NvrPass = ""
40
+ )
41
+
42
+ $ErrorActionPreference = "Stop"
43
+
44
+ $logDir = Join-Path $env:LOCALAPPDATA "Trillboards\logs"
45
+ New-Item -ItemType Directory -Force -Path $logDir | Out-Null
46
+ $logPath = Join-Path $logDir "enroll.log"
47
+
48
+ function Write-Log([string]$Message) {
49
+ # NEVER log the token. Only its shape, so support can match it to a portal row
50
+ # without the log becoming a credential store.
51
+ $stamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
52
+ Add-Content -Path $logPath -Value "[$stamp] $Message"
53
+ }
54
+
55
+ $enrollmentFile = Join-Path $InstallDir "trillboards.enrollment"
56
+ $configPath = Join-Path $InstallDir "trillboards.config.json"
57
+
58
+ function Remove-EnrollmentFile {
59
+ if (Test-Path $enrollmentFile) {
60
+ try {
61
+ Remove-Item -Force $enrollmentFile
62
+ Write-Log "enrollment file removed"
63
+ } catch {
64
+ Write-Log "WARNING: could not remove enrollment file: $($_.Exception.Message)"
65
+ }
66
+ }
67
+ }
68
+
69
+ try {
70
+ if ([string]::IsNullOrWhiteSpace($EnrollmentToken)) {
71
+ Write-Log "no enrollment token supplied; nothing to do"
72
+ exit 0
73
+ }
74
+
75
+ $tokenShape = if ($EnrollmentToken.Length -gt 11) {
76
+ "$($EnrollmentToken.Substring(0,7))...$($EnrollmentToken.Substring($EnrollmentToken.Length-4))"
77
+ } else { "(short)" }
78
+ $credState = if ([string]::IsNullOrWhiteSpace($NvrUser)) { "absent" } else { "supplied" }
79
+ Write-Log "starting unattended enrollment (credential $tokenShape, site '$SiteLabel', recorder credentials $credState)"
80
+
81
+ New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
82
+ # -NoNewline so the CLI's trim() sees exactly the token; ASCII because the key
83
+ # alphabet is hex and a BOM would corrupt the first character.
84
+ [System.IO.File]::WriteAllText($enrollmentFile, $EnrollmentToken, [System.Text.UTF8Encoding]::new($false))
85
+
86
+ # Owner-only ACL with inheritance broken. %LOCALAPPDATA% is already per-user,
87
+ # but this file is the one credential that can enroll further machines, so it
88
+ # does not ride on a directory default that a group policy may widen.
89
+ try {
90
+ $acl = Get-Acl $enrollmentFile
91
+ $acl.SetAccessRuleProtection($true, $false) # break inheritance, drop inherited ACEs
92
+ $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
93
+ $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
94
+ $me, "FullControl", "None", "None", "Allow")
95
+ $acl.SetAccessRule($rule)
96
+ Set-Acl -Path $enrollmentFile -AclObject $acl
97
+ Write-Log "enrollment file ACL set to owner-only"
98
+ } catch {
99
+ # Non-fatal: the file is deleted seconds from now. Losing the ACL is worth a
100
+ # log line, not a failed rollout.
101
+ Write-Log "WARNING: could not tighten enrollment file ACL: $($_.Exception.Message)"
102
+ }
103
+
104
+ $node = Join-Path $InstallDir "bin\node.exe"
105
+ $cli = Join-Path $InstallDir "lib\edge-sdk\dist\cli.js"
106
+ if (-not (Test-Path $node) -or -not (Test-Path $cli)) {
107
+ # Retryable and the credential is UNSPENT - keep it so the next-logon retry
108
+ # can use it once the install finishes laying the tree down.
109
+ Write-Log "ERROR: connector is not installed at $InstallDir (retryable, credential preserved)"
110
+ exit 6
111
+ }
112
+
113
+ $cliArgs = @($cli, "enroll", "--config", $configPath, "--enrollment-file", $enrollmentFile)
114
+ if (-not [string]::IsNullOrWhiteSpace($SiteLabel)) { $cliArgs += @("--site", $SiteLabel) }
115
+ if (-not [string]::IsNullOrWhiteSpace($ApiBase)) { $cliArgs += @("--api-base", $ApiBase) }
116
+ if (-not [string]::IsNullOrWhiteSpace($NvrUser)) { $cliArgs += @("--nvr-user", $NvrUser) }
117
+ if (-not [string]::IsNullOrWhiteSpace($NvrPass)) { $cliArgs += @("--nvr-pass", $NvrPass) }
118
+
119
+ $oldPath = $env:PATH
120
+ $oldNodePath = $env:NODE_PATH
121
+ try {
122
+ $env:PATH = "$(Join-Path $InstallDir 'bin');$oldPath"
123
+ $env:NODE_PATH = Join-Path $InstallDir "lib\node_modules"
124
+ $output = (& $node @cliArgs 2>&1 | Out-String)
125
+ $code = $LASTEXITCODE
126
+ } finally {
127
+ $env:PATH = $oldPath
128
+ $env:NODE_PATH = $oldNodePath
129
+ }
130
+
131
+ # The CLI never prints the credential (see maskConnectorKey), so its output is
132
+ # safe to log verbatim and is the only diagnostic a field failure leaves.
133
+ Write-Log "enroll exit=$code output=$($output.Trim())"
134
+
135
+ # DELETE ONLY ON A TERMINAL OUTCOME.
136
+ #
137
+ # The CLI already deletes the file itself on success. The dangerous case is a
138
+ # RETRYABLE failure: an unconditional delete here would destroy the only
139
+ # persisted copy of the fleet credential on a transient outage during install,
140
+ # leaving that store permanently unprovisioned with nothing to retry from - the
141
+ # opposite of what the exit code says. So the file survives exit 6 and the
142
+ # next-logon retry in run-connector.ps1 picks it up.
143
+ #
144
+ # It IS deleted on a credential REJECTION (exit 1), because that token can
145
+ # never enroll anything again and leaving a dead credential on disk is pure
146
+ # exposure.
147
+ switch ($code) {
148
+ 0 { Remove-EnrollmentFile; Write-Log "enrollment complete, cameras registered"; exit 0 }
149
+ 5 {
150
+ # Provisioned, but no feeds. The credential is SPENT, so the enrollment
151
+ # file is done - what is missing is recorder credentials, not a retry.
152
+ Remove-EnrollmentFile
153
+ Write-Log "enrolled, but NO camera feeds registered (recorder credentials absent or no recorder found)"
154
+ exit 5
155
+ }
156
+ 1 { Remove-EnrollmentFile; Write-Log "enrollment credential REJECTED - a new one must be issued"; exit 1 }
157
+ 4 { Remove-EnrollmentFile; Write-Log "no enrollment token was readable"; exit 1 }
158
+ default {
159
+ Write-Log "enrollment deferred (retryable) - credential PRESERVED for the next-logon retry"
160
+ exit 6
161
+ }
162
+ }
163
+ } catch {
164
+ # An exception here is a local/environmental failure, not a rejected
165
+ # credential, so it is retryable and the token is preserved for the same
166
+ # reason as exit 6 above.
167
+ Write-Log "ERROR (retryable, credential preserved): $($_.Exception.Message)"
168
+ exit 6
169
+ }