@trillboards/edge-sdk 0.2.21 → 0.2.25

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.
Files changed (65) hide show
  1. package/deploy/macos/scripts/setup-litertlm.sh +30 -0
  2. package/deploy/windows/build-windows.ps1 +1 -0
  3. package/deploy/windows/e2e/test-enterprise-trust-store.ps1 +341 -0
  4. package/deploy/windows/enroll-connector.ps1 +169 -0
  5. package/deploy/windows/installer.nsi +102 -6
  6. package/deploy/windows/run-connector.ps1 +30 -0
  7. package/deploy/windows/setup-litertlm.ps1 +49 -1
  8. package/dist/AudienceAnalyzeClient.d.ts +32 -1
  9. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  10. package/dist/AudienceAnalyzeClient.js +34 -5
  11. package/dist/AudienceAnalyzeClient.js.map +1 -1
  12. package/dist/CloudSensingManager.d.ts +30 -0
  13. package/dist/CloudSensingManager.d.ts.map +1 -1
  14. package/dist/CloudSensingManager.js +61 -1
  15. package/dist/CloudSensingManager.js.map +1 -1
  16. package/dist/ConnectorCameraClient.d.ts +79 -0
  17. package/dist/ConnectorCameraClient.d.ts.map +1 -0
  18. package/dist/ConnectorCameraClient.js +114 -0
  19. package/dist/ConnectorCameraClient.js.map +1 -0
  20. package/dist/EdgeAgent.js +1 -1
  21. package/dist/EnrollClient.d.ts +81 -0
  22. package/dist/EnrollClient.d.ts.map +1 -0
  23. package/dist/EnrollClient.js +97 -0
  24. package/dist/EnrollClient.js.map +1 -0
  25. package/dist/cli.d.ts +49 -0
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +392 -0
  28. package/dist/cli.js.map +1 -1
  29. package/dist/config.d.ts +15 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +4 -0
  32. package/dist/config.js.map +1 -1
  33. package/dist/diagnose.d.ts +37 -4
  34. package/dist/diagnose.d.ts.map +1 -1
  35. package/dist/diagnose.js +155 -6
  36. package/dist/diagnose.js.map +1 -1
  37. package/dist/enterpriseNetwork.d.ts +130 -0
  38. package/dist/enterpriseNetwork.d.ts.map +1 -0
  39. package/dist/enterpriseNetwork.js +568 -0
  40. package/dist/enterpriseNetwork.js.map +1 -0
  41. package/dist/enterpriseProxy.d.ts +73 -0
  42. package/dist/enterpriseProxy.d.ts.map +1 -0
  43. package/dist/enterpriseProxy.js +406 -0
  44. package/dist/enterpriseProxy.js.map +1 -0
  45. package/dist/enterpriseTrust.d.ts +188 -0
  46. package/dist/enterpriseTrust.d.ts.map +1 -0
  47. package/dist/enterpriseTrust.js +615 -0
  48. package/dist/enterpriseTrust.js.map +1 -0
  49. package/dist/index.d.ts +10 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +29 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/localNetworkAccess.d.ts +166 -0
  54. package/dist/localNetworkAccess.d.ts.map +1 -0
  55. package/dist/localNetworkAccess.js +490 -0
  56. package/dist/localNetworkAccess.js.map +1 -0
  57. package/dist/sensingRunMode.d.ts +36 -0
  58. package/dist/sensingRunMode.d.ts.map +1 -1
  59. package/dist/sensingRunMode.js +63 -1
  60. package/dist/sensingRunMode.js.map +1 -1
  61. package/dist/transportFailure.d.ts +118 -0
  62. package/dist/transportFailure.d.ts.map +1 -0
  63. package/dist/transportFailure.js +267 -0
  64. package/dist/transportFailure.js.map +1 -0
  65. package/package.json +1 -1
@@ -168,6 +168,36 @@ if [ "$HOST_OS" = "Darwin" ] && [ "$HOST_ARCH" != "arm64" ]; then
168
168
  exit 0
169
169
  fi
170
170
 
171
+ # 0b. Enterprise TLS + proxy for uv.
172
+ #
173
+ # uv ships bundled Mozilla roots and does NOT consult the macOS keychain, so on
174
+ # a network that inspects TLS this step is the first thing that fails -- and it
175
+ # fails with a message about local AI, which is the wrong cause. UV_SYSTEM_CERTS
176
+ # switches it to the platform's native verifier; UV_NATIVE_TLS is the older,
177
+ # deprecated-but-still-read name for the same setting, exported too because
178
+ # `uv tool install` here is UNPINNED and the version that lands is whatever is
179
+ # current that day. An unknown env var is ignored, so both are safe; a CLI flag
180
+ # would fail outright on a version that does not have it.
181
+ export UV_SYSTEM_CERTS=1
182
+ export UV_NATIVE_TLS=1
183
+
184
+ # The proxy half: uv reads only the environment, never the macOS system proxy
185
+ # settings. Fill them in from scutil when the operator has not set them already.
186
+ if [ -z "${HTTPS_PROXY:-}" ] && [ -z "${https_proxy:-}" ] && [ -z "${ALL_PROXY:-}" ]; then
187
+ _proxy_conf="$(/usr/sbin/scutil --proxy 2>/dev/null || true)"
188
+ _proxy_enabled="$(printf '%s\n' "$_proxy_conf" | awk '/HTTPSEnable/ {print $3}')"
189
+ if [ "$_proxy_enabled" = "1" ]; then
190
+ _proxy_host="$(printf '%s\n' "$_proxy_conf" | awk '/HTTPSProxy/ {print $3}')"
191
+ _proxy_port="$(printf '%s\n' "$_proxy_conf" | awk '/HTTPSPort/ {print $3}')"
192
+ if [ -n "$_proxy_host" ]; then
193
+ HTTPS_PROXY="http://$_proxy_host${_proxy_port:+:$_proxy_port}"
194
+ export HTTPS_PROXY
195
+ export HTTP_PROXY="$HTTPS_PROXY"
196
+ log "using this machine's configured proxy for local AI downloads: $HTTPS_PROXY"
197
+ fi
198
+ fi
199
+ fi
200
+
171
201
  # 1. uv (PATH first; else official standalone installer, home-domain).
172
202
  export PATH="$LOCAL_BIN:$USER_HOME/.cargo/bin:$PATH"
173
203
  if ! command -v uv >/dev/null 2>&1; then
@@ -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
@@ -0,0 +1,341 @@
1
+ <#
2
+ RUNTIME PROOF: the connector reads THIS MACHINE'S Windows certificate store,
3
+ and still refuses a certificate that store does not vouch for.
4
+
5
+ WHY THIS EXISTS
6
+ ---------------
7
+ Node ships its own Mozilla-derived root list and does not consult the Windows
8
+ store, so behind a TLS-inspecting corporate proxy the connector could not
9
+ reach the API at all -- on the repair install path, silently, in a background
10
+ process with no window. The Node-side fix reads the Root stores at runtime.
11
+ Everything about that fix is Windows-specific and the merge-gating CI lane is
12
+ Linux, so on Linux the store reader is exercised only through an injected
13
+ seam. This script is the half that is NOT a seam: it imports a throwaway
14
+ certificate into the REAL LocalMachine\Root store and measures what the REAL
15
+ shipped code does with it.
16
+
17
+ WHAT IT PROVES, AND THE CONTROL FOR EACH
18
+ ----------------------------------------
19
+ A. BEFORE the import, the connector REFUSES the inspecting proxy's
20
+ certificate. <- the failing control. If this passes, the machine already
21
+ trusted the root and every later assertion is vacuous.
22
+ It also asserts the store read returned SOMETHING and the request really
23
+ went through the proxy, so B cannot pass for the wrong reason.
24
+ B. AFTER the import, the SAME request succeeds. Only the store changed.
25
+ C. AFTER the import, a certificate from a DIFFERENT root is STILL refused.
26
+ This is the assertion `rejectUnauthorized:false` would fail, and it is
27
+ the whole reason B is not evidence of a security regression.
28
+ D. The store read is not blocked by execution policy: it keeps working under
29
+ a machine-scope AllSigned GPO, which is the estate this work targets.
30
+ (The reader uses `powershell -Command`, and execution policy governs
31
+ script FILES; a regression to `-File` would fail exactly here.)
32
+
33
+ It REQUIRES elevation, and it must import into LocalMachine\Root specifically.
34
+ CurrentUser\Root is writable by the signed-in user in the ACL sense, which is
35
+ exactly what makes it a trap: a write to a CurrentUser TRUST-ANCHOR store is
36
+ gated behind an interactive "you are about to install a certificate from a
37
+ certification authority" consent dialog. On a headless runner nobody can answer
38
+ it, so Import-Certificate BLOCKS rather than fails -- it hung 23 minutes and ate
39
+ the job's entire timeout on 2026-08-15, reporting neither pass nor fail.
40
+ LocalMachine\Root is gated by administrator rights instead of by a dialog, so it
41
+ is non-interactive. That costs nothing here: CASE D already sets a machine-scope
42
+ execution policy, so this script has always needed admin, and hosted
43
+ windows-latest is elevated. It is also the more faithful test -- an AD GPO
44
+ pushes an enterprise root to LocalMachine\Root, and the connector runs as a
45
+ service, which has no CurrentUser store of its own.
46
+ The reader consults both LocalMachine\Root and CurrentUser\Root.
47
+
48
+ Certificates live in Cert:\CurrentUser\My + a temp dir for the life of the run
49
+ and are removed in the finally block. Nothing is printed.
50
+ #>
51
+ [CmdletBinding()]
52
+ param(
53
+ [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..\..\..")).Path
54
+ )
55
+
56
+ $ErrorActionPreference = "Stop"
57
+ $ProgressPreference = "SilentlyContinue"
58
+
59
+ $work = Join-Path $env:RUNNER_TEMP ("tb-trust-proof-" + [Guid]::NewGuid().ToString("N").Substring(0, 8))
60
+ $sdkDir = Join-Path $RepoRoot "trillboards-edge-sdk\packages\edge-sdk"
61
+ $TARGET_DNS = "api.trillboards.test"
62
+ $minted = @()
63
+ $trustedThumbprint = $null
64
+ $policyChanged = $false
65
+ # Prior MachinePolicy registry state, captured in CASE D and restored in finally.
66
+ $polKeyExisted = $false
67
+ $priorEnable = $null
68
+ $priorPolicy = $null
69
+
70
+ function Fail([string]$Message) {
71
+ Write-Host "FAIL: $Message"
72
+ exit 1
73
+ }
74
+
75
+ # New-SelfSignedCertificate is the native path here: no openssl on PATH is
76
+ # required (Git for Windows ships one but does not put it on PATH on the hosted
77
+ # images), and a -Signer chain is exactly the CA -> leaf shape we need.
78
+ function New-ProofCa([string]$CommonName) {
79
+ $ca = New-SelfSignedCertificate `
80
+ -Type Custom `
81
+ -Subject "CN=$CommonName" `
82
+ -KeyExportPolicy Exportable `
83
+ -KeyUsage CertSign, CRLSign `
84
+ -TextExtension @("2.5.29.19={text}CA=true&pathlength=0") `
85
+ -CertStoreLocation "Cert:\CurrentUser\My" `
86
+ -NotAfter (Get-Date).AddHours(2)
87
+ $script:minted += $ca.Thumbprint
88
+ return $ca
89
+ }
90
+
91
+ function New-ProofLeaf($Ca, [string]$Dns) {
92
+ $leaf = New-SelfSignedCertificate `
93
+ -Type Custom `
94
+ -Subject "CN=$Dns" `
95
+ -DnsName $Dns `
96
+ -KeyExportPolicy Exportable `
97
+ -Signer $Ca `
98
+ -CertStoreLocation "Cert:\CurrentUser\My" `
99
+ -NotAfter (Get-Date).AddHours(2)
100
+ $script:minted += $leaf.Thumbprint
101
+ return $leaf
102
+ }
103
+
104
+ try {
105
+ New-Item -ItemType Directory -Force -Path $work | Out-Null
106
+
107
+ Write-Host "== Minting a throwaway inspection CA and one for the negative control =="
108
+ $ca = New-ProofCa -CommonName "Trillboards Windows Trust Proof CA"
109
+ $otherCa = New-ProofCa -CommonName "Trillboards Windows Trust Proof OTHER CA"
110
+ $leaf = New-ProofLeaf -Ca $ca -Dns $TARGET_DNS
111
+ $otherLeaf = New-ProofLeaf -Ca $otherCa -Dns $TARGET_DNS
112
+
113
+ $pfxPassword = ConvertTo-SecureString -String ([Guid]::NewGuid().ToString("N")) -AsPlainText -Force
114
+ $pfxPlain = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
115
+ [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pfxPassword))
116
+ $leafPfx = Join-Path $work "leaf.pfx"
117
+ $otherPfx = Join-Path $work "otherleaf.pfx"
118
+ $caCer = Join-Path $work "ca.cer"
119
+ Export-PfxCertificate -Cert $leaf -FilePath $leafPfx -Password $pfxPassword | Out-Null
120
+ Export-PfxCertificate -Cert $otherLeaf -FilePath $otherPfx -Password $pfxPassword | Out-Null
121
+ Export-Certificate -Cert $ca -FilePath $caCer -Type CERT | Out-Null
122
+
123
+ # The probe: an inspecting proxy plus the SHIPPED agent, driven from Node so
124
+ # that what is measured is the real dist/ build and not a re-implementation.
125
+ $probe = Join-Path $work "probe.js"
126
+ $probeBody = @'
127
+ // Stands up a CONNECT proxy that terminates TLS itself (an inspection
128
+ // appliance), points the SHIPPED EnterpriseHttpsAgent at it, and reports what
129
+ // happened as one line of JSON. Trust anchors come from the REAL Windows store
130
+ // via the shipped reader -- nothing is injected.
131
+ const fs = require('fs');
132
+ const net = require('net');
133
+ const https = require('https');
134
+ const sdk = require(process.argv[2]);
135
+
136
+ const pfx = fs.readFileSync(process.argv[3]);
137
+ const passphrase = process.argv[4];
138
+ const TARGET = 'api.trillboards.test';
139
+
140
+ const inspector = https.createServer({ pfx, passphrase }, (_req, res) => {
141
+ res.writeHead(200, { 'content-type': 'application/json' });
142
+ res.end('{"ok":true}');
143
+ });
144
+
145
+ const proxy = net.createServer((socket) => {
146
+ socket.once('data', (chunk) => {
147
+ if (!chunk.toString('latin1').startsWith('CONNECT ')) { socket.end('HTTP/1.1 405\r\n\r\n'); return; }
148
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
149
+ inspector.emit('connection', socket);
150
+ });
151
+ socket.on('error', () => {});
152
+ });
153
+
154
+ proxy.listen(0, '127.0.0.1', () => {
155
+ const port = proxy.address().port;
156
+ process.env.HTTPS_PROXY = 'http://127.0.0.1:' + port;
157
+ // The REAL reader, against the REAL CurrentUser\Root + LocalMachine\Root.
158
+ sdk.resetEnterpriseNetworkCaches();
159
+ const posture = sdk.describeNetworkPosture('https://' + TARGET);
160
+ const agent = sdk.getEnterpriseAgent();
161
+ const done = (result) => {
162
+ console.log(JSON.stringify(Object.assign({
163
+ anchorCount: posture.trust.certs.length,
164
+ anchorSources: posture.trust.sources.map((s) => s.name),
165
+ anchorWarnings: posture.trust.warnings,
166
+ proxyUrl: posture.proxy.proxyUrl,
167
+ }, result)));
168
+ proxy.close(); inspector.close(); agent.destroy(); process.exit(0);
169
+ };
170
+ const req = https.request('https://' + TARGET + '/health/live', { agent, timeout: 10000 }, (res) => {
171
+ res.resume();
172
+ res.on('end', () => done({ outcome: 'connected', statusCode: res.statusCode }));
173
+ });
174
+ req.on('timeout', () => req.destroy(new Error('probe timed out')));
175
+ req.on('error', (err) => done({
176
+ outcome: 'refused',
177
+ code: err.code || null,
178
+ failureClass: sdk.classifyTransportError(err, {}).failureClass,
179
+ }));
180
+ req.end();
181
+ });
182
+ '@
183
+ Set-Content -LiteralPath $probe -Value $probeBody -Encoding Ascii
184
+
185
+ # Build from the MONOREPO root, not the package: edge-sdk requires its sibling
186
+ # workspaces at runtime, so a package-scoped build silently depends on some
187
+ # earlier step in this job having built them. Building all of them here means
188
+ # this script is correct on its own and cannot be broken by a step reorder.
189
+ Write-Host "== Building the SDK so the probe measures the shipped build =="
190
+ Push-Location (Join-Path $RepoRoot "trillboards-edge-sdk")
191
+ & npm run build 2>&1 | Out-Null
192
+ $buildExit = $LASTEXITCODE
193
+ Pop-Location
194
+ if ($buildExit -ne 0) { Fail "edge-sdk build failed (exit $buildExit)" }
195
+ $sdkEntry = Join-Path $sdkDir "dist\index.js"
196
+ if (-not (Test-Path -LiteralPath $sdkEntry)) { Fail "edge-sdk dist/index.js is missing after the build" }
197
+
198
+ function Invoke-Probe([string]$Pfx) {
199
+ $raw = & node $probe $sdkEntry $Pfx $pfxPlain
200
+ if ($LASTEXITCODE -ne 0) { throw "probe exited $LASTEXITCODE" }
201
+ return (($raw | Select-Object -Last 1) | ConvertFrom-Json)
202
+ }
203
+
204
+ # ---------------------------------------------------------------- CONTROL A
205
+ Write-Host ""
206
+ Write-Host "== CONTROL A: before the import, the inspection cert must be REFUSED =="
207
+ $before = Invoke-Probe -Pfx $leafPfx
208
+ Write-Host (" anchors read from the machine store: {0}" -f $before.anchorCount)
209
+ Write-Host (" sources: {0}" -f ($before.anchorSources -join ", "))
210
+ Write-Host (" warnings: {0}" -f ($before.anchorWarnings -join "; "))
211
+ Write-Host (" proxy in use: {0}" -f $before.proxyUrl)
212
+ Write-Host (" outcome: {0} {1} {2}" -f $before.outcome, $before.code, $before.failureClass)
213
+ if ($before.outcome -ne "refused") {
214
+ Fail "the untrusted inspection certificate was ACCEPTED before the import -- this probe cannot prove anything"
215
+ }
216
+ if ($before.failureClass -ne "tls_untrusted_root") {
217
+ Fail "expected failureClass tls_untrusted_root before the import, got '$($before.failureClass)'"
218
+ }
219
+ # The reader must have actually run. A store read that silently returned
220
+ # nothing would make case B below pass for the wrong reason.
221
+ if ([int]$before.anchorCount -lt 1) {
222
+ # The warnings are printed above. They are the whole point: the first version
223
+ # of the reader came back empty here with nothing to say about why, which
224
+ # cost a full windows-latest cycle to diagnose. Anything that reaches this
225
+ # line without an actionable warning above it is a reader bug of its own.
226
+ Fail "the Windows trust store read returned NO certificates -- see the warnings above; the reader did not run"
227
+ }
228
+ if (-not ("$($before.proxyUrl)" -like "http://127.0.0.1:*")) {
229
+ Fail "the probe did not go through the proxy -- HTTPS_PROXY was not honoured"
230
+ }
231
+
232
+ # ---------------------------------------------------------------- CASE B
233
+ Write-Host ""
234
+ Write-Host "== CASE B: import the root into LocalMachine\Root and retry =="
235
+ # LocalMachine, NOT CurrentUser. A write to a CurrentUser trust-anchor store
236
+ # raises an interactive consent dialog, which on a headless runner never gets
237
+ # answered and hangs this script forever (see the header). LocalMachine\Root is
238
+ # admin-gated instead, and is where a GPO-pushed enterprise root actually lands.
239
+ $imported = Import-Certificate -FilePath $caCer -CertStoreLocation "Cert:\LocalMachine\Root"
240
+ $trustedThumbprint = $imported.Thumbprint
241
+ Write-Host " imported into Cert:\LocalMachine\Root (thumbprint recorded for cleanup)"
242
+
243
+ $after = Invoke-Probe -Pfx $leafPfx
244
+ Write-Host (" anchors read from the machine store: {0}" -f $after.anchorCount)
245
+ Write-Host (" outcome: {0} {1} {2}" -f $after.outcome, $after.statusCode, $after.failureClass)
246
+ if ($after.outcome -ne "connected" -or [int]$after.statusCode -ne 200) {
247
+ Fail "the connector still could not reach the API after the root was trusted (outcome=$($after.outcome) class=$($after.failureClass))"
248
+ }
249
+ if ([int]$after.anchorCount -le [int]$before.anchorCount) {
250
+ Fail "the anchor count did not grow after the import -- the reader is not seeing LocalMachine\Root"
251
+ }
252
+
253
+ # ---------------------------------------------------------------- CASE C
254
+ Write-Host ""
255
+ Write-Host "== CASE C: a cert from a DIFFERENT root is still REFUSED =="
256
+ $foreign = Invoke-Probe -Pfx $otherPfx
257
+ Write-Host (" outcome: {0} {1} {2}" -f $foreign.outcome, $foreign.code, $foreign.failureClass)
258
+ if ($foreign.outcome -ne "refused") {
259
+ Fail "a certificate that chains to NO trusted root was accepted -- verification has been weakened, which is worse than the bug this fixes"
260
+ }
261
+ if ($foreign.failureClass -ne "tls_untrusted_root") {
262
+ Fail "expected failureClass tls_untrusted_root for the foreign root, got '$($foreign.failureClass)'"
263
+ }
264
+
265
+ # ---------------------------------------------------------------- CASE D
266
+ Write-Host ""
267
+ Write-Host "== CASE D: the store read survives a machine-scope AllSigned GPO =="
268
+ # Set-ExecutionPolicy -Scope MachinePolicy is REFUSED by Windows on purpose
269
+ # ("Execution policies at the MachinePolicy or UserPolicy scopes must be set
270
+ # through Group Policy"), so the cmdlet cannot stage this arm at all. A real
271
+ # GPO does not call the cmdlet either -- it writes these registry values, and
272
+ # the policy scope is just their projection. Writing them directly IS the GPO,
273
+ # not a workaround for one.
274
+ #
275
+ # Only the Windows PowerShell 5.1 key is written, never the PowerShellCore
276
+ # one. 5.1 is the interpreter the reader shells out to (`powershell -Command`),
277
+ # so it is the one this arm is about, and leaving pwsh 7's policy alone means a
278
+ # cleanup that fails to run cannot take the rest of the job's pwsh steps down
279
+ # with it -- the failure mode the step's own post-check exists to catch.
280
+ $polKey = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell"
281
+ $polKeyExisted = Test-Path $polKey
282
+ if ($polKeyExisted) {
283
+ $priorEnable = (Get-ItemProperty -Path $polKey -Name EnableScripts -ErrorAction SilentlyContinue).EnableScripts
284
+ $priorPolicy = (Get-ItemProperty -Path $polKey -Name ExecutionPolicy -ErrorAction SilentlyContinue).ExecutionPolicy
285
+ }
286
+ New-Item -Path $polKey -Force | Out-Null
287
+ New-ItemProperty -Path $polKey -Name "EnableScripts" -Value 1 -PropertyType DWord -Force | Out-Null
288
+ New-ItemProperty -Path $polKey -Name "ExecutionPolicy" -Value "AllSigned" -PropertyType String -Force | Out-Null
289
+ $policyChanged = $true
290
+ # Read it back through the interpreter it governs, not through pwsh 7, which
291
+ # reads a different key and would report Undefined no matter what we staged.
292
+ $effective = (& powershell.exe -NoProfile -Command "Get-ExecutionPolicy -Scope MachinePolicy" | Select-Object -Last 1).ToString().Trim()
293
+ Write-Host (" MachinePolicy (Windows PowerShell 5.1) is now: {0}" -f $effective)
294
+ if ("$effective" -ne "AllSigned") {
295
+ Fail "could not stage MachinePolicy=AllSigned, so this arm would be vacuous"
296
+ }
297
+ $underPolicy = Invoke-Probe -Pfx $leafPfx
298
+ Write-Host (" anchors read under AllSigned: {0}" -f $underPolicy.anchorCount)
299
+ Write-Host (" outcome: {0}" -f $underPolicy.outcome)
300
+ if ([int]$underPolicy.anchorCount -lt [int]$after.anchorCount) {
301
+ Fail "the trust-store read returned fewer certificates under AllSigned ($($underPolicy.anchorCount) vs $($after.anchorCount)) -- execution policy is blocking it"
302
+ }
303
+ if ($underPolicy.outcome -ne "connected") {
304
+ Fail "the connector could not reach the API under AllSigned (outcome=$($underPolicy.outcome))"
305
+ }
306
+
307
+ Write-Host ""
308
+ Write-Host "PASS: refused before the import, connected after it, still refuses a foreign root, and reads the store under AllSigned."
309
+ exit 0
310
+ } catch {
311
+ Write-Host "FAIL: $_"
312
+ exit 1
313
+ } finally {
314
+ if ($policyChanged) {
315
+ # Restore exactly what was there, rather than deleting: on a developer's
316
+ # domain-joined machine this key may be REAL policy, and clearing it would
317
+ # silently relax that machine's execution policy.
318
+ $polKey = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell"
319
+ if ($polKeyExisted) {
320
+ if ($null -ne $priorEnable) {
321
+ New-ItemProperty -Path $polKey -Name "EnableScripts" -Value $priorEnable -PropertyType DWord -Force -ErrorAction SilentlyContinue | Out-Null
322
+ } else {
323
+ Remove-ItemProperty -Path $polKey -Name "EnableScripts" -Force -ErrorAction SilentlyContinue
324
+ }
325
+ if ($null -ne $priorPolicy) {
326
+ New-ItemProperty -Path $polKey -Name "ExecutionPolicy" -Value $priorPolicy -PropertyType String -Force -ErrorAction SilentlyContinue | Out-Null
327
+ } else {
328
+ Remove-ItemProperty -Path $polKey -Name "ExecutionPolicy" -Force -ErrorAction SilentlyContinue
329
+ }
330
+ } else {
331
+ Remove-Item -Path $polKey -Recurse -Force -ErrorAction SilentlyContinue
332
+ }
333
+ }
334
+ if ($trustedThumbprint) {
335
+ Remove-Item -LiteralPath "Cert:\LocalMachine\Root\$trustedThumbprint" -Force -ErrorAction SilentlyContinue
336
+ }
337
+ foreach ($thumb in $minted) {
338
+ Remove-Item -LiteralPath "Cert:\CurrentUser\My\$thumb" -Force -ErrorAction SilentlyContinue
339
+ }
340
+ if (Test-Path -LiteralPath $work) { Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue }
341
+ }
@@ -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
+ }