@trillboards/edge-sdk 0.2.19 → 0.2.20
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.
- package/deploy/windows/README.md +41 -0
- package/deploy/windows/apply-update.ps1 +530 -0
- package/deploy/windows/build-windows.ps1 +12 -0
- package/deploy/windows/ci/verify-update-applier.ps1 +621 -0
- package/dist/ConnectorStatusClient.d.ts +104 -0
- package/dist/ConnectorStatusClient.d.ts.map +1 -0
- package/dist/ConnectorStatusClient.js +100 -0
- package/dist/ConnectorStatusClient.js.map +1 -0
- package/dist/EdgeAgent.js +1 -1
- package/dist/autoSense.d.ts +14 -0
- package/dist/autoSense.d.ts.map +1 -1
- package/dist/autoSense.js +56 -2
- package/dist/autoSense.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +18 -0
- package/dist/cli.js.map +1 -1
- package/dist/connectorUpdate.d.ts +369 -0
- package/dist/connectorUpdate.d.ts.map +1 -0
- package/dist/connectorUpdate.js +701 -0
- package/dist/connectorUpdate.js.map +1 -0
- package/dist/connectorUpdateApply.d.ts +121 -0
- package/dist/connectorUpdateApply.d.ts.map +1 -0
- package/dist/connectorUpdateApply.js +237 -0
- package/dist/connectorUpdateApply.js.map +1 -0
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/sensingRunMode.d.ts +11 -0
- package/dist/sensingRunMode.d.ts.map +1 -1
- package/dist/sensingRunMode.js.map +1 -1
- package/package.json +1 -1
package/deploy/windows/README.md
CHANGED
|
@@ -93,3 +93,44 @@ stable download URL never changes while field tests remain artifact-verifiable.
|
|
|
93
93
|
- The legacy `install-service.ps1` (local-inference: requires system Node + npm
|
|
94
94
|
install + ONNX models + a hand-edited deviceToken) is a different, older path.
|
|
95
95
|
This local-first installer supersedes it for BYO-CCTV onboarding.
|
|
96
|
+
|
|
97
|
+
## Auto-update (since 0.2.20)
|
|
98
|
+
|
|
99
|
+
An installed connector keeps itself current. It is the only consumer of
|
|
100
|
+
`trillboards-connector-windows.version.json`, which until now was published,
|
|
101
|
+
correct, and read by nothing.
|
|
102
|
+
|
|
103
|
+
- **Check** — `src/connectorUpdate.ts` reads the manifest every 6 h (first check
|
|
104
|
+
60 s after start, so a PC that reboots nightly still reports) and posts the
|
|
105
|
+
running version plus the advertised one to
|
|
106
|
+
`POST /v2/earner/cameras/connector-status`. A MISSING manifest answers
|
|
107
|
+
`HTTP 200 text/html` on this CDN, so the client validates every field rather
|
|
108
|
+
than the status code, and an unreadable manifest reports UNKNOWN — never
|
|
109
|
+
"up to date".
|
|
110
|
+
- **Payload** — a code-only `bin\` + `lib\` zip, a small fraction of the 2.6 GB
|
|
111
|
+
customer installer, because the ~2.4 GB Gemma weights already have their own
|
|
112
|
+
hourly SHA-256-verified OTA channel. The release step prints the measured size;
|
|
113
|
+
the macOS equivalent measured 207 MB of a 2.27 GB pkg. Published under a
|
|
114
|
+
content-addressed prefix so a cached payload can never disagree with its
|
|
115
|
+
catalog.
|
|
116
|
+
- **Verify** — `apply-update.ps1 -Stage` checks the Authenticode signature AND
|
|
117
|
+
the signer CN of a signed catalog, binds it to the manifest's version, then
|
|
118
|
+
checks the payload's size and SHA-256 against it, then that every executable
|
|
119
|
+
in the expanded tree is signed. The signed `.ps1` catalog exists because a
|
|
120
|
+
`.zip` and a `.json` cannot carry Authenticode, and a hash arriving over the
|
|
121
|
+
channel it protects is a corruption check, not an authenticity check.
|
|
122
|
+
- **Apply** — `apply-update.ps1 -Commit` hands the swap to `install-update.ps1`
|
|
123
|
+
unchanged. The connector picks the moment: it waits until it holds no
|
|
124
|
+
in-flight analysis and no flagged frames, because `stop()` discards held
|
|
125
|
+
candidates. After 2 h without a quiet moment it applies anyway and records
|
|
126
|
+
which branch it took.
|
|
127
|
+
- **Task shape** — re-registration produces the best shape the CALLER can make,
|
|
128
|
+
and a headless S4U/boot shape needs an elevated caller. The applier probes
|
|
129
|
+
first and REFUSES the update rather than silently returning a headless machine
|
|
130
|
+
to logon-only; it also re-classifies and restores after the swap.
|
|
131
|
+
|
|
132
|
+
Outcomes land in `<install>\.auto-update\outcome.json` and are reported on the
|
|
133
|
+
next start, so a refused update is visible instead of a machine that quietly
|
|
134
|
+
stopped receiving fixes. Proved on `windows-latest` by
|
|
135
|
+
`ci/verify-update-applier.ps1`, where every negative arm carries a control that
|
|
136
|
+
must fire. Full detail: `docs/runbooks/cctv-connector-windows-gtm-readiness.md` §5.
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
apply-update.ps1 - download, VERIFY, and apply a connector update in the field.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
The half the product was missing. Every release publishes a signed installer
|
|
7
|
+
and a version manifest to the CDN, and nothing read either: a field fix
|
|
8
|
+
reached a customer only when that customer re-downloaded 2.6 GB and re-ran the
|
|
9
|
+
installer by hand. The connector now reads the manifest (see
|
|
10
|
+
src/connectorUpdate.ts) and drives THIS script when it finds itself behind.
|
|
11
|
+
|
|
12
|
+
Two phases, because a store's uplink is slow and sensing must not stop while
|
|
13
|
+
bytes are moving:
|
|
14
|
+
|
|
15
|
+
-Stage download + verify + extract, with the connector still running and
|
|
16
|
+
still sensing. Touches nothing in the live install. Ends by
|
|
17
|
+
writing staged.json, which is the connector's signal that a
|
|
18
|
+
verified tree is on disk and waiting.
|
|
19
|
+
-Commit swap it in. Called only after the connector has decided it is at a
|
|
20
|
+
safe point, and only ever by a connector that is about to exit.
|
|
21
|
+
|
|
22
|
+
==========================================================================
|
|
23
|
+
WHAT IS VERIFIED BEFORE ANY BYTE IS APPLIED, AND WHY IN THIS ORDER
|
|
24
|
+
==========================================================================
|
|
25
|
+
An updater that installs an unverified payload is a remote-code-execution
|
|
26
|
+
channel into every customer's store. So the payload is not trusted because it
|
|
27
|
+
came from our CDN - it is trusted because Microsoft's root CA says we signed
|
|
28
|
+
it.
|
|
29
|
+
|
|
30
|
+
1. AUTHENTICODE on the catalog. `Get-AuthenticodeSignature` must return
|
|
31
|
+
Valid AND the signer subject must be the expected publisher. Status alone
|
|
32
|
+
is not enough: a certificate minted by anyone and installed in the
|
|
33
|
+
machine's trusted roots also returns Valid, so the SUBJECT is what pins
|
|
34
|
+
this to us. Both halves have a control in CI that must fail.
|
|
35
|
+
2. VERSION BINDING. The catalog names the version it describes and it must
|
|
36
|
+
equal the version the manifest advertised. Without this, a catalog+payload
|
|
37
|
+
pair from an older release could be replayed against a newer manifest.
|
|
38
|
+
3. SIZE, then SHA-256, of the payload archive against the catalog. The catalog
|
|
39
|
+
is signed and the archive is not, so this is the step that extends
|
|
40
|
+
Authenticode's guarantee over every byte of the payload - including
|
|
41
|
+
lib\edge-sdk\dist\cli.js, which is the code that matters most and which
|
|
42
|
+
cannot carry a signature of its own.
|
|
43
|
+
|
|
44
|
+
Why a signed CATALOG rather than the manifest's own sha256: the manifest is
|
|
45
|
+
plain JSON on the same channel as the payload, so an attacker who can serve
|
|
46
|
+
one can serve the other. A hash that arrives over the channel it is meant to
|
|
47
|
+
protect is a CORRUPTION check, not an authenticity check. A .ps1 can carry
|
|
48
|
+
Authenticode; a .json and a .zip cannot.
|
|
49
|
+
|
|
50
|
+
4. INNER SIGNATURES. Every .ps1/.vbs and ffmpeg.exe in the extracted tree, and
|
|
51
|
+
node.exe's vendor signature, must be Valid. The hash above already proves
|
|
52
|
+
the archive is the one we built, so this cannot catch an attacker - it
|
|
53
|
+
catches US, if a build ever regresses and ships an unsigned script that
|
|
54
|
+
would then fail to run on a customer under GPO AllSigned.
|
|
55
|
+
|
|
56
|
+
==========================================================================
|
|
57
|
+
WHAT IS DELIBERATELY NOT REINVENTED
|
|
58
|
+
==========================================================================
|
|
59
|
+
The swap itself is install-update.ps1, unchanged. It already solves the hard
|
|
60
|
+
parts, all of them incident-derived: stop-existing.ps1 DISABLES the task
|
|
61
|
+
before stopping it (a task registered -RestartCount 99 otherwise relaunches
|
|
62
|
+
node.exe straight into the middle of the swap), Assert-BinariesUnlocked proves
|
|
63
|
+
node.exe and ffmpeg.exe are WRITABLE rather than merely process-free (an AV
|
|
64
|
+
scanner holding a handle is caught before the installer commits), the task is
|
|
65
|
+
re-enabled on EVERY exit path, and bin/lib roll back from .previous on
|
|
66
|
+
failure. This script calls it and adds nothing to it.
|
|
67
|
+
|
|
68
|
+
==========================================================================
|
|
69
|
+
THE ONE THING THIS SCRIPT ADDS TO THE SWAP: A TASK-SHAPE GUARD
|
|
70
|
+
==========================================================================
|
|
71
|
+
install-update.ps1 re-registers the scheduled task by running the NEW payload's
|
|
72
|
+
register-task.ps1 - which is correct, because the new version's registration
|
|
73
|
+
logic is the one that should win. But register-task.ps1 registers the best
|
|
74
|
+
shape THE CALLER CAN CREATE, and a headless (S4U / boot-triggered) shape can
|
|
75
|
+
only be created by a caller that is BOTH elevated AND the account itself.
|
|
76
|
+
This script runs from the connector's own task, which is RunLevel Limited. So
|
|
77
|
+
a machine whose owner accepted one UAC prompt to become headless would be
|
|
78
|
+
silently re-registered logon-only by an unattended update - and the loss would
|
|
79
|
+
not be visible until the next unattended reboot, days later, with the machine
|
|
80
|
+
up and no error anywhere.
|
|
81
|
+
|
|
82
|
+
So, before the swap: if the CURRENT task is headless, register a throwaway
|
|
83
|
+
probe task carrying the same principal (and boot trigger, when present). If
|
|
84
|
+
the probe is refused, re-registration would downgrade this machine, and the
|
|
85
|
+
update is REJECTED rather than applied. Nothing is touched; the connector
|
|
86
|
+
stays on the older version and the server is told exactly why. After the swap
|
|
87
|
+
the shape is re-classified anyway and restored from the captured definition if
|
|
88
|
+
it moved - belt as well as braces, because the probe predicts and the
|
|
89
|
+
re-classification measures.
|
|
90
|
+
|
|
91
|
+
The probe task is registered DISABLED-by-construction (no trigger it can fire
|
|
92
|
+
during the probe, an inert action) and removed in a finally.
|
|
93
|
+
|
|
94
|
+
.OUTPUTS
|
|
95
|
+
Exit 0 on success. Non-zero on any failure. Every terminal state also writes
|
|
96
|
+
<InstallDir>\.auto-update\outcome.json, which the connector reads on its next
|
|
97
|
+
start and reports to the server - so a failed update is visible in the fleet
|
|
98
|
+
view rather than being a machine that quietly stopped receiving fixes.
|
|
99
|
+
#>
|
|
100
|
+
[CmdletBinding()]
|
|
101
|
+
param(
|
|
102
|
+
[Parameter(Mandatory = $true)][string]$InstallDir,
|
|
103
|
+
[switch]$Stage,
|
|
104
|
+
[switch]$Commit,
|
|
105
|
+
# Stage-phase inputs, all read from the published manifest by the connector.
|
|
106
|
+
[string]$UpdateUrl,
|
|
107
|
+
[string]$CatalogUrl,
|
|
108
|
+
[string]$ExpectedVersion,
|
|
109
|
+
[string]$CurrentVersion,
|
|
110
|
+
# The publisher every shipped Trillboards artifact carries. Overridable ONLY so
|
|
111
|
+
# CI can prove the check with a throwaway certificate; a static test asserts
|
|
112
|
+
# this default, so a CI harness can never prove the wrong publisher.
|
|
113
|
+
[string]$ExpectedSigner = 'Aethercorp Inc',
|
|
114
|
+
# Escape hatch for the CI control that must FAIL: with this set, a degraded
|
|
115
|
+
# task shape is measured and reported but not repaired. Never set in the field.
|
|
116
|
+
[switch]$SkipTaskShapeRestore
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
$ErrorActionPreference = 'Stop'
|
|
120
|
+
# Invoke-WebRequest renders a progress bar per chunk in Windows PowerShell 5.1,
|
|
121
|
+
# which dominates the wall time of a 200 MB download on a slow uplink.
|
|
122
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
123
|
+
|
|
124
|
+
$TaskName = 'TrillboardsEdge'
|
|
125
|
+
$ProbeTaskName = 'TrillboardsEdgeUpdateProbe'
|
|
126
|
+
$UpdateDir = Join-Path $InstallDir '.auto-update'
|
|
127
|
+
$PayloadDir = Join-Path $UpdateDir 'payload'
|
|
128
|
+
$ArchivePath = Join-Path $UpdateDir 'payload.zip'
|
|
129
|
+
$CatalogPath = Join-Path $UpdateDir 'catalog.ps1'
|
|
130
|
+
$StagedMarker = Join-Path $UpdateDir 'staged.json'
|
|
131
|
+
$OutcomePath = Join-Path $UpdateDir 'outcome.json'
|
|
132
|
+
$AttemptedAt = (Get-Date).ToUniversalTime().ToString('o')
|
|
133
|
+
|
|
134
|
+
function Log([string]$Message) { Write-Host "[apply-update] $Message" }
|
|
135
|
+
|
|
136
|
+
<#
|
|
137
|
+
Write the terminal state, atomically, then leave. The connector reads this on
|
|
138
|
+
its next start; a torn file would be read as a malformed outcome and dropped,
|
|
139
|
+
so tmp+rename rather than a direct write.
|
|
140
|
+
|
|
141
|
+
Vocabulary, and it is load-bearing because the server persists it:
|
|
142
|
+
rejected - we refused. NOTHING was changed. Hash, signature, disk, or a
|
|
143
|
+
swap that would have downgraded the task shape.
|
|
144
|
+
failed - we tried and the transaction did not complete. install-update.ps1
|
|
145
|
+
rolled back, or the shape moved and could not be restored.
|
|
146
|
+
success - the new version is live and the task shape is intact.
|
|
147
|
+
#>
|
|
148
|
+
function Write-Outcome([string]$Result, [string]$Detail) {
|
|
149
|
+
try {
|
|
150
|
+
if (-not (Test-Path -LiteralPath $UpdateDir)) {
|
|
151
|
+
New-Item -ItemType Directory -Force -Path $UpdateDir | Out-Null
|
|
152
|
+
}
|
|
153
|
+
$outcome = [ordered]@{
|
|
154
|
+
attemptedAt = $AttemptedAt
|
|
155
|
+
fromVersion = $CurrentVersion
|
|
156
|
+
toVersion = $ExpectedVersion
|
|
157
|
+
result = $Result
|
|
158
|
+
detail = $Detail
|
|
159
|
+
}
|
|
160
|
+
$json = $outcome | ConvertTo-Json -Compress
|
|
161
|
+
$tmp = "$OutcomePath.tmp"
|
|
162
|
+
[System.IO.File]::WriteAllText($tmp, $json, (New-Object System.Text.UTF8Encoding($false)))
|
|
163
|
+
Move-Item -LiteralPath $tmp -Destination $OutcomePath -Force
|
|
164
|
+
} catch {
|
|
165
|
+
Log "could not record the outcome: $($_.Exception.Message)"
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function Fail([string]$Result, [string]$Detail) {
|
|
170
|
+
Log "$Result - $Detail"
|
|
171
|
+
Write-Outcome $Result $Detail
|
|
172
|
+
exit 1
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
<#
|
|
176
|
+
Free space on the volume the install lives on.
|
|
177
|
+
|
|
178
|
+
Get-PSDrive rather than wmic: `wmic` is REMOVED on recent Windows 11, and the
|
|
179
|
+
connector's own diagnose path already had to grow a Get-PSDrive fallback for
|
|
180
|
+
exactly that. Returns $null when it cannot be measured, and the caller treats
|
|
181
|
+
"cannot measure" as "do not proceed" - a preflight that passes because it
|
|
182
|
+
could not look is the disk check that reported `pass` on a full disk.
|
|
183
|
+
#>
|
|
184
|
+
function Get-FreeBytes([string]$Path) {
|
|
185
|
+
try {
|
|
186
|
+
$qualifier = Split-Path -Qualifier ([System.IO.Path]::GetFullPath($Path))
|
|
187
|
+
$drive = Get-PSDrive -Name $qualifier.TrimEnd(':') -ErrorAction Stop
|
|
188
|
+
if ($null -eq $drive.Free) { return $null }
|
|
189
|
+
return [int64]$drive.Free
|
|
190
|
+
} catch {
|
|
191
|
+
return $null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function Assert-SignedBy([string]$Path, [string]$Subject, [string]$What) {
|
|
196
|
+
$sig = Get-AuthenticodeSignature -LiteralPath $Path
|
|
197
|
+
if ($sig.Status -ne 'Valid') {
|
|
198
|
+
Fail 'rejected' "$What signature is not Valid (status $($sig.Status)): $($sig.StatusMessage)"
|
|
199
|
+
}
|
|
200
|
+
$signer = ''
|
|
201
|
+
if ($sig.SignerCertificate) { $signer = [string]$sig.SignerCertificate.Subject }
|
|
202
|
+
# Match the CN specifically. A substring match anywhere in the subject would
|
|
203
|
+
# accept a certificate that merely mentions the name in its O or OU.
|
|
204
|
+
if ($signer -notmatch ("(^|,)\s*CN=" + [regex]::Escape($Subject) + "\s*(,|$)")) {
|
|
205
|
+
Fail 'rejected' "$What is signed by an unexpected publisher: '$signer'"
|
|
206
|
+
}
|
|
207
|
+
Log "$What signature OK (Valid, CN=$Subject)"
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# STAGE
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
function Invoke-Stage {
|
|
214
|
+
if ([string]::IsNullOrWhiteSpace($UpdateUrl)) { Fail 'rejected' '-UpdateUrl is required to stage' }
|
|
215
|
+
if ([string]::IsNullOrWhiteSpace($CatalogUrl)) { Fail 'rejected' '-CatalogUrl is required to stage' }
|
|
216
|
+
if ([string]::IsNullOrWhiteSpace($ExpectedVersion)) { Fail 'rejected' '-ExpectedVersion is required to stage' }
|
|
217
|
+
|
|
218
|
+
# Clear the previous attempt's working files but KEEP outcome.json. The
|
|
219
|
+
# connector reports that record on its next start, and a report can fail
|
|
220
|
+
# (offline, rotated token); wiping it here would erase the only evidence that
|
|
221
|
+
# an update was ever refused on this machine.
|
|
222
|
+
New-Item -ItemType Directory -Force -Path $UpdateDir | Out-Null
|
|
223
|
+
foreach ($stale in @($PayloadDir, $ArchivePath, $CatalogPath, $StagedMarker)) {
|
|
224
|
+
if (Test-Path -LiteralPath $stale) { Remove-Item -LiteralPath $stale -Recurse -Force }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
Log "fetching the update catalog: $CatalogUrl"
|
|
228
|
+
try {
|
|
229
|
+
Invoke-WebRequest -Uri $CatalogUrl -OutFile $CatalogPath -UseBasicParsing -TimeoutSec 60
|
|
230
|
+
} catch {
|
|
231
|
+
Fail 'rejected' "could not fetch the update catalog: $($_.Exception.Message)"
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
Assert-SignedBy $CatalogPath $ExpectedSigner 'update catalog'
|
|
235
|
+
|
|
236
|
+
# PARSED, never executed. The signature covers these bytes, so reading them is
|
|
237
|
+
# safe - but running a downloaded script to learn a hash would give the
|
|
238
|
+
# catalog far more authority than it needs, and there is nothing in it that
|
|
239
|
+
# requires an interpreter.
|
|
240
|
+
$catalogText = [System.IO.File]::ReadAllText($CatalogPath)
|
|
241
|
+
$mVersion = [regex]::Match($catalogText, "(?m)^\s*\`$TrillboardsUpdateVersion\s*=\s*'([^']+)'")
|
|
242
|
+
$mSha = [regex]::Match($catalogText, "(?m)^\s*\`$TrillboardsUpdateSha256\s*=\s*'([0-9a-f]{64})'")
|
|
243
|
+
$mSize = [regex]::Match($catalogText, "(?m)^\s*\`$TrillboardsUpdateSizeBytes\s*=\s*(\d+)")
|
|
244
|
+
if (-not ($mVersion.Success -and $mSha.Success -and $mSize.Success)) {
|
|
245
|
+
Fail 'rejected' 'the update catalog is signed but does not declare version, sha256 and sizeBytes'
|
|
246
|
+
}
|
|
247
|
+
$catalogVersion = $mVersion.Groups[1].Value
|
|
248
|
+
$catalogSha = $mSha.Groups[1].Value
|
|
249
|
+
$catalogSize = [int64]$mSize.Groups[1].Value
|
|
250
|
+
|
|
251
|
+
if ($catalogVersion -ne $ExpectedVersion) {
|
|
252
|
+
Fail 'rejected' "catalog describes $catalogVersion but the manifest advertised $ExpectedVersion"
|
|
253
|
+
}
|
|
254
|
+
if ($catalogSize -le 0) { Fail 'rejected' 'the catalog declares a non-positive payload size' }
|
|
255
|
+
|
|
256
|
+
# Preflight against REAL free space, before a single payload byte is written.
|
|
257
|
+
# x3: the archive, its expansion, and headroom for the .previous copy the swap
|
|
258
|
+
# keeps for rollback. Refusing here leaves a machine that is merely stale;
|
|
259
|
+
# discovering it mid-download leaves a machine that is stale AND full.
|
|
260
|
+
$required = $catalogSize * 3
|
|
261
|
+
$free = Get-FreeBytes $InstallDir
|
|
262
|
+
if ($null -eq $free) { Fail 'rejected' 'could not measure free disk space' }
|
|
263
|
+
if ($free -lt $required) {
|
|
264
|
+
Fail 'rejected' ("not enough free disk space: need {0} MB, have {1} MB" -f [math]::Round($required / 1MB), [math]::Round($free / 1MB))
|
|
265
|
+
}
|
|
266
|
+
Log ("disk preflight OK: need {0} MB, have {1} MB" -f [math]::Round($required / 1MB), [math]::Round($free / 1MB))
|
|
267
|
+
|
|
268
|
+
Log "downloading the update payload: $UpdateUrl"
|
|
269
|
+
try {
|
|
270
|
+
Invoke-WebRequest -Uri $UpdateUrl -OutFile $ArchivePath -UseBasicParsing -TimeoutSec 3600
|
|
271
|
+
} catch {
|
|
272
|
+
# A disk that fills DURING the download lands here, and the partial file
|
|
273
|
+
# must not survive: a resumed or retried run would otherwise hash a
|
|
274
|
+
# truncated archive against a full-length expectation forever.
|
|
275
|
+
Remove-Item -LiteralPath $ArchivePath -Force -ErrorAction SilentlyContinue
|
|
276
|
+
Fail 'rejected' "could not download the update payload: $($_.Exception.Message)"
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
$actualSize = (Get-Item -LiteralPath $ArchivePath).Length
|
|
280
|
+
if ($actualSize -ne $catalogSize) {
|
|
281
|
+
Remove-Item -LiteralPath $ArchivePath -Force -ErrorAction SilentlyContinue
|
|
282
|
+
Fail 'rejected' "payload size mismatch: expected $catalogSize bytes, got $actualSize"
|
|
283
|
+
}
|
|
284
|
+
$actualSha = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
285
|
+
if ($actualSha -ne $catalogSha) {
|
|
286
|
+
Remove-Item -LiteralPath $ArchivePath -Force -ErrorAction SilentlyContinue
|
|
287
|
+
Fail 'rejected' "payload SHA-256 mismatch: expected $catalogSha, got $actualSha"
|
|
288
|
+
}
|
|
289
|
+
Log "payload verified: $actualSize bytes, sha256 $actualSha"
|
|
290
|
+
|
|
291
|
+
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
292
|
+
try {
|
|
293
|
+
[System.IO.Compression.ZipFile]::ExtractToDirectory($ArchivePath, $PayloadDir)
|
|
294
|
+
} catch {
|
|
295
|
+
Fail 'rejected' "could not expand the update payload: $($_.Exception.Message)"
|
|
296
|
+
}
|
|
297
|
+
Remove-Item -LiteralPath $ArchivePath -Force -ErrorAction SilentlyContinue
|
|
298
|
+
|
|
299
|
+
foreach ($rel in @('bin\node.exe', 'bin\install-update.ps1', 'bin\stop-existing.ps1', 'bin\register-task.ps1', 'lib\edge-sdk\dist\cli.js')) {
|
|
300
|
+
if (-not (Test-Path -LiteralPath (Join-Path $PayloadDir $rel))) {
|
|
301
|
+
Fail 'rejected' "the expanded payload is missing $rel"
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
Assert-PayloadSignatures $PayloadDir
|
|
306
|
+
|
|
307
|
+
# LAST, and atomically. staged.json is the connector's only signal that a
|
|
308
|
+
# verified tree is waiting, so it must be impossible for a half-staged tree to
|
|
309
|
+
# look ready.
|
|
310
|
+
$staged = [ordered]@{
|
|
311
|
+
version = $ExpectedVersion
|
|
312
|
+
sha256 = $catalogSha
|
|
313
|
+
stagedAt = (Get-Date).ToUniversalTime().ToString('o')
|
|
314
|
+
} | ConvertTo-Json -Compress
|
|
315
|
+
$tmp = "$StagedMarker.tmp"
|
|
316
|
+
[System.IO.File]::WriteAllText($tmp, $staged, (New-Object System.Text.UTF8Encoding($false)))
|
|
317
|
+
Move-Item -LiteralPath $tmp -Destination $StagedMarker -Force
|
|
318
|
+
Log "staged $ExpectedVersion - waiting for the connector to reach a safe point"
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
<#
|
|
322
|
+
Every executable in the staged tree carries a Valid signature.
|
|
323
|
+
|
|
324
|
+
This cannot stop an attacker - the archive hash already did that - and it is
|
|
325
|
+
not here for one. It stops US shipping a tree that a customer under GPO
|
|
326
|
+
AllSigned could not run: an unsigned .ps1 in the payload would install fine
|
|
327
|
+
and then fail at every logon, silently, on exactly the managed estates the
|
|
328
|
+
signing work was done for.
|
|
329
|
+
|
|
330
|
+
node.exe is checked but NOT expected to carry our publisher: it keeps the
|
|
331
|
+
OpenJS Foundation's signature on purpose, because re-signing it would replace
|
|
332
|
+
the vendor's provenance with ours.
|
|
333
|
+
#>
|
|
334
|
+
function Assert-PayloadSignatures([string]$Root) {
|
|
335
|
+
$bad = 0
|
|
336
|
+
$checked = 0
|
|
337
|
+
$files = Get-ChildItem -LiteralPath (Join-Path $Root 'bin') -File -ErrorAction SilentlyContinue |
|
|
338
|
+
Where-Object { $_.Extension -in @('.ps1', '.vbs', '.exe') }
|
|
339
|
+
foreach ($f in $files) {
|
|
340
|
+
$checked++
|
|
341
|
+
$sig = Get-AuthenticodeSignature -LiteralPath $f.FullName
|
|
342
|
+
if ($sig.Status -ne 'Valid') {
|
|
343
|
+
$bad++
|
|
344
|
+
Log " UNSIGNED OR INVALID: $($f.Name) ($($sig.Status))"
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
# A derivation that found nothing must fail rather than pass silently - the
|
|
348
|
+
# shipped bin\ carries at least the eight .ps1, three .vbs and two .exe the
|
|
349
|
+
# release signs, so a count this low means the tree is not what we think.
|
|
350
|
+
if ($checked -lt 10) {
|
|
351
|
+
Fail 'rejected' "only $checked executable artifact(s) found in the staged bin\ - the payload is not the shipped shape"
|
|
352
|
+
}
|
|
353
|
+
if ($bad -gt 0) {
|
|
354
|
+
Fail 'rejected' "$bad of $checked executable artifact(s) in the staged payload are unsigned or invalid"
|
|
355
|
+
}
|
|
356
|
+
Log "all $checked executable artifacts in the staged payload are signed"
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# TASK SHAPE
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
<#
|
|
363
|
+
Classify the registered task the way the product cares about it.
|
|
364
|
+
|
|
365
|
+
"Headless" is TWO independent properties and it needs both: a trigger that
|
|
366
|
+
fires with nobody logged on (a boot trigger or a repetition), AND a principal
|
|
367
|
+
that does not need an interactive session. Either one alone still waits for a
|
|
368
|
+
human to sign in.
|
|
369
|
+
#>
|
|
370
|
+
function Get-TaskShape([string]$Name) {
|
|
371
|
+
$task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
|
372
|
+
if (-not $task) { return $null }
|
|
373
|
+
$xml = Export-ScheduledTask -TaskName $Name
|
|
374
|
+
$logonType = 'unknown'
|
|
375
|
+
$m = [regex]::Match($xml, '<LogonType>([^<]+)</LogonType>')
|
|
376
|
+
if ($m.Success) { $logonType = $m.Groups[1].Value }
|
|
377
|
+
$userId = ''
|
|
378
|
+
$mu = [regex]::Match($xml, '<Principal[^>]*>\s*<UserId>([^<]+)</UserId>')
|
|
379
|
+
if (-not $mu.Success) { $mu = [regex]::Match($xml, '<UserId>([^<]+)</UserId>') }
|
|
380
|
+
if ($mu.Success) { $userId = $mu.Groups[1].Value }
|
|
381
|
+
$hasBoot = $xml -match '<BootTrigger>'
|
|
382
|
+
$hasRepetition = $xml -match '<Repetition>'
|
|
383
|
+
return [pscustomobject]@{
|
|
384
|
+
Xml = $xml
|
|
385
|
+
LogonType = $logonType
|
|
386
|
+
UserId = $userId
|
|
387
|
+
HasBootTrigger = [bool]$hasBoot
|
|
388
|
+
LogonFree = [bool]($hasBoot -or $hasRepetition)
|
|
389
|
+
NonInteractive = ($logonType -ne 'InteractiveToken')
|
|
390
|
+
Headless = ([bool]($hasBoot -or $hasRepetition)) -and ($logonType -ne 'InteractiveToken')
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function Format-TaskShape($Shape) {
|
|
395
|
+
if ($null -eq $Shape) { return 'absent' }
|
|
396
|
+
return ("logonType=$($Shape.LogonType) logonFreeTrigger=$($Shape.LogonFree) headless=$($Shape.Headless)")
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
<#
|
|
400
|
+
Can THIS caller still create the shape this machine already has?
|
|
401
|
+
|
|
402
|
+
Registers a throwaway task with the same principal, and the boot trigger when
|
|
403
|
+
the current shape has one, then deletes it. It cannot fire while it exists: a
|
|
404
|
+
boot trigger needs a boot, and when the current shape has none the probe is
|
|
405
|
+
registered with NO trigger at all. The action is inert regardless.
|
|
406
|
+
|
|
407
|
+
Returns $true when the probe landed, $false when Windows refused it.
|
|
408
|
+
#>
|
|
409
|
+
function Test-CanRegisterShape($Shape) {
|
|
410
|
+
if ($null -eq $Shape) { return $true }
|
|
411
|
+
$triggers = @()
|
|
412
|
+
if ($Shape.HasBootTrigger) { $triggers = @(New-ScheduledTaskTrigger -AtStartup) }
|
|
413
|
+
$userId = $Shape.UserId
|
|
414
|
+
if ([string]::IsNullOrWhiteSpace($userId)) {
|
|
415
|
+
$userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
416
|
+
}
|
|
417
|
+
# InteractiveToken is what Export writes; the cmdlet spells it 'Interactive'.
|
|
418
|
+
$logonType = $Shape.LogonType
|
|
419
|
+
if ($logonType -eq 'InteractiveToken') { $logonType = 'Interactive' }
|
|
420
|
+
try {
|
|
421
|
+
Unregister-ScheduledTask -TaskName $ProbeTaskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
422
|
+
$action = New-ScheduledTaskAction -Execute (Join-Path $env:WINDIR 'System32\cmd.exe') -Argument '/c exit'
|
|
423
|
+
$principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType $logonType -RunLevel Limited
|
|
424
|
+
if ($triggers.Count -gt 0) {
|
|
425
|
+
Register-ScheduledTask -TaskName $ProbeTaskName -Action $action -Trigger $triggers `
|
|
426
|
+
-Principal $principal -Description 'Trillboards update pre-flight probe' -ErrorAction Stop | Out-Null
|
|
427
|
+
} else {
|
|
428
|
+
Register-ScheduledTask -TaskName $ProbeTaskName -Action $action `
|
|
429
|
+
-Principal $principal -Description 'Trillboards update pre-flight probe' -ErrorAction Stop | Out-Null
|
|
430
|
+
}
|
|
431
|
+
return $true
|
|
432
|
+
} catch {
|
|
433
|
+
Log "task-shape probe refused: $($_.Exception.Message)"
|
|
434
|
+
return $false
|
|
435
|
+
} finally {
|
|
436
|
+
Unregister-ScheduledTask -TaskName $ProbeTaskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
# COMMIT
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
function Invoke-Commit {
|
|
444
|
+
if (-not (Test-Path -LiteralPath $StagedMarker)) {
|
|
445
|
+
Fail 'rejected' 'no staged update is present'
|
|
446
|
+
}
|
|
447
|
+
$staged = $null
|
|
448
|
+
try {
|
|
449
|
+
$staged = Get-Content -LiteralPath $StagedMarker -Raw | ConvertFrom-Json
|
|
450
|
+
} catch {
|
|
451
|
+
Fail 'rejected' 'the staged marker is unreadable'
|
|
452
|
+
}
|
|
453
|
+
if (-not $staged.version) { Fail 'rejected' 'the staged marker names no version' }
|
|
454
|
+
if ($ExpectedVersion -and $staged.version -ne $ExpectedVersion) {
|
|
455
|
+
Fail 'rejected' "staged $($staged.version) but was asked to commit $ExpectedVersion"
|
|
456
|
+
}
|
|
457
|
+
if (-not $ExpectedVersion) { $script:ExpectedVersion = $staged.version }
|
|
458
|
+
if (-not (Test-Path -LiteralPath (Join-Path $PayloadDir 'bin\install-update.ps1'))) {
|
|
459
|
+
Fail 'rejected' 'the staged payload is gone'
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
$before = Get-TaskShape $TaskName
|
|
463
|
+
Log "task shape before: $(Format-TaskShape $before)"
|
|
464
|
+
|
|
465
|
+
# The predictive half. Only a machine that HAS a headless shape has one to
|
|
466
|
+
# lose, so a logon-only install skips the probe entirely and updates freely.
|
|
467
|
+
if ($null -ne $before -and $before.Headless) {
|
|
468
|
+
if (-not (Test-CanRegisterShape $before)) {
|
|
469
|
+
Fail 'rejected' 'applying this update would re-register the scheduled task logon-only, and this machine is currently headless'
|
|
470
|
+
}
|
|
471
|
+
Log 'task-shape probe OK - the headless shape can be re-created by this caller'
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
# Close the relaunch race before it can happen. The connector exits 0 to hand
|
|
475
|
+
# us the machine, and a task carrying a repetition trigger can start a fresh
|
|
476
|
+
# one in the seconds it takes install-update.ps1 to validate the staged tree -
|
|
477
|
+
# which then holds bin\node.exe open and turns a healthy update into a
|
|
478
|
+
# rollback. stop-existing.ps1 disables the task too, but only AFTER that
|
|
479
|
+
# validation, so the window exists. Nothing between here and the call can
|
|
480
|
+
# fail without install-update.ps1 having run, and that script re-enables the
|
|
481
|
+
# task on every one of its exit paths.
|
|
482
|
+
Disable-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null
|
|
483
|
+
|
|
484
|
+
$installUpdate = Join-Path $PayloadDir 'bin\install-update.ps1'
|
|
485
|
+
try {
|
|
486
|
+
# install-update.ps1 owns the whole swap transaction, including disabling the
|
|
487
|
+
# task before stopping it, proving the binaries are writable, the .previous
|
|
488
|
+
# rollback, and re-enabling the task on every exit path. It throws on
|
|
489
|
+
# failure and $ErrorActionPreference = 'Stop' turns that into ours.
|
|
490
|
+
& $installUpdate -InstallDir $InstallDir -StagingDir $PayloadDir
|
|
491
|
+
} catch {
|
|
492
|
+
Fail 'failed' "the swap failed and the previous version was restored: $($_.Exception.Message)"
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
# The measured half. The probe predicts; this observes.
|
|
496
|
+
$after = Get-TaskShape $TaskName
|
|
497
|
+
Log "task shape after: $(Format-TaskShape $after)"
|
|
498
|
+
$degraded = ($null -ne $before -and $before.Headless -and (($null -eq $after) -or (-not $after.Headless)))
|
|
499
|
+
if ($degraded -and -not $SkipTaskShapeRestore) {
|
|
500
|
+
Log 'the swap left a NON-headless task - restoring the captured definition'
|
|
501
|
+
try {
|
|
502
|
+
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
503
|
+
Register-ScheduledTask -TaskName $TaskName -Xml $before.Xml -ErrorAction Stop | Out-Null
|
|
504
|
+
} catch {
|
|
505
|
+
Log "restore failed: $($_.Exception.Message)"
|
|
506
|
+
}
|
|
507
|
+
$after = Get-TaskShape $TaskName
|
|
508
|
+
Log "task shape after restore: $(Format-TaskShape $after)"
|
|
509
|
+
$degraded = ($null -eq $after) -or (-not $after.Headless)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
Remove-Item -LiteralPath $StagedMarker -Force -ErrorAction SilentlyContinue
|
|
513
|
+
Remove-Item -LiteralPath $PayloadDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
514
|
+
|
|
515
|
+
if ($degraded) {
|
|
516
|
+
# The code IS updated, so this is not a rollback case - but the machine is
|
|
517
|
+
# now worse in a way that only shows up at the next unattended reboot, which
|
|
518
|
+
# is precisely the class of failure that must never be silent.
|
|
519
|
+
Fail 'failed' 'the update applied but the scheduled task is no longer headless'
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
Write-Outcome 'success' $null
|
|
523
|
+
Log "updated to $ExpectedVersion"
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
# ---------------------------------------------------------------------------
|
|
527
|
+
if ($Stage -and $Commit) { Fail 'rejected' 'pass exactly one of -Stage or -Commit' }
|
|
528
|
+
if ($Stage) { Invoke-Stage; exit 0 }
|
|
529
|
+
if ($Commit) { Invoke-Commit; exit 0 }
|
|
530
|
+
Fail 'rejected' 'pass -Stage or -Commit'
|
|
@@ -204,6 +204,10 @@ 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
205
|
Copy-Item -Force "$Here\stop-existing.ps1" "$Payload\bin\stop-existing.ps1"
|
|
206
206
|
Copy-Item -Force "$Here\install-update.ps1" "$Payload\bin\install-update.ps1"
|
|
207
|
+
# The in-field updater. Ships in bin\ so an INSTALLED connector can drive its own
|
|
208
|
+
# replacement; being here is also what puts it in the Authenticode signing set,
|
|
209
|
+
# which list-shipped-scripts.ps1 derives from these Copy-Item calls.
|
|
210
|
+
Copy-Item -Force "$Here\apply-update.ps1" "$Payload\bin\apply-update.ps1"
|
|
207
211
|
|
|
208
212
|
# Construct the real customer window with Windows PowerShell before packaging.
|
|
209
213
|
# -SmokeTest creates and validates every control but never displays the form or
|
|
@@ -249,4 +253,12 @@ Log "Built: $out ($sizeMb MB)"
|
|
|
249
253
|
# failed downstream on `Test-Path $null` with a misleading error. GITHUB_OUTPUT
|
|
250
254
|
# is unambiguous and survives stream pollution.
|
|
251
255
|
if ($env:GITHUB_OUTPUT) { "exe=$out" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append }
|
|
256
|
+
# The staged tree is ALSO the auto-update payload: bin\ + lib\ is exactly the
|
|
257
|
+
# shape install-update.ps1 takes as its -StagingDir, and it carries no Gemma
|
|
258
|
+
# weights - those have their own hourly, SHA-256-verified OTA channel. The
|
|
259
|
+
# release job zips this and publishes it beside the manifest so a field fix does
|
|
260
|
+
# not cost every store 2.6 GB. The exact ratio is MEASURED and printed by the
|
|
261
|
+
# release step rather than asserted here; the macOS equivalent measured 207 MB
|
|
262
|
+
# of a 2.27 GB pkg (9.1%), which is the order to expect, not a Windows number.
|
|
263
|
+
if ($env:GITHUB_OUTPUT) { "payload=$Payload" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append }
|
|
252
264
|
Write-Output $out
|