@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.
- package/deploy/macos/scripts/setup-litertlm.sh +30 -0
- package/deploy/windows/build-windows.ps1 +1 -0
- package/deploy/windows/e2e/test-enterprise-trust-store.ps1 +341 -0
- package/deploy/windows/enroll-connector.ps1 +169 -0
- package/deploy/windows/installer.nsi +102 -6
- package/deploy/windows/run-connector.ps1 +30 -0
- package/deploy/windows/setup-litertlm.ps1 +49 -1
- package/dist/AudienceAnalyzeClient.d.ts +32 -1
- package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
- package/dist/AudienceAnalyzeClient.js +34 -5
- package/dist/AudienceAnalyzeClient.js.map +1 -1
- package/dist/CloudSensingManager.d.ts +30 -0
- package/dist/CloudSensingManager.d.ts.map +1 -1
- package/dist/CloudSensingManager.js +61 -1
- package/dist/CloudSensingManager.js.map +1 -1
- package/dist/ConnectorCameraClient.d.ts +79 -0
- package/dist/ConnectorCameraClient.d.ts.map +1 -0
- package/dist/ConnectorCameraClient.js +114 -0
- package/dist/ConnectorCameraClient.js.map +1 -0
- package/dist/EdgeAgent.js +1 -1
- package/dist/EnrollClient.d.ts +81 -0
- package/dist/EnrollClient.d.ts.map +1 -0
- package/dist/EnrollClient.js +97 -0
- package/dist/EnrollClient.js.map +1 -0
- package/dist/cli.d.ts +49 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +392 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/diagnose.d.ts +37 -4
- package/dist/diagnose.d.ts.map +1 -1
- package/dist/diagnose.js +155 -6
- package/dist/diagnose.js.map +1 -1
- package/dist/enterpriseNetwork.d.ts +130 -0
- package/dist/enterpriseNetwork.d.ts.map +1 -0
- package/dist/enterpriseNetwork.js +568 -0
- package/dist/enterpriseNetwork.js.map +1 -0
- package/dist/enterpriseProxy.d.ts +73 -0
- package/dist/enterpriseProxy.d.ts.map +1 -0
- package/dist/enterpriseProxy.js +406 -0
- package/dist/enterpriseProxy.js.map +1 -0
- package/dist/enterpriseTrust.d.ts +188 -0
- package/dist/enterpriseTrust.d.ts.map +1 -0
- package/dist/enterpriseTrust.js +615 -0
- package/dist/enterpriseTrust.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -1
- package/dist/index.js.map +1 -1
- package/dist/localNetworkAccess.d.ts +166 -0
- package/dist/localNetworkAccess.d.ts.map +1 -0
- package/dist/localNetworkAccess.js +490 -0
- package/dist/localNetworkAccess.js.map +1 -0
- package/dist/sensingRunMode.d.ts +36 -0
- package/dist/sensingRunMode.d.ts.map +1 -1
- package/dist/sensingRunMode.js +63 -1
- package/dist/sensingRunMode.js.map +1 -1
- package/dist/transportFailure.d.ts +118 -0
- package/dist/transportFailure.d.ts.map +1 -0
- package/dist/transportFailure.js +267 -0
- package/dist/transportFailure.js.map +1 -0
- 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
|
+
}
|