@trillboards/edge-sdk 0.2.24 → 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/windows/build-windows.ps1 +1 -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/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 +332 -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 +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type HttpPoster } from './AudienceAnalyzeClient';
|
|
2
|
+
/**
|
|
3
|
+
* EnrollClient — UNATTENDED first-boot provisioning.
|
|
4
|
+
*
|
|
5
|
+
* Exchanges a FLEET enrollment credential (issued once in the portal, shipped
|
|
6
|
+
* inside the MDM/Intune/SCCM package) for THIS MACHINE's own connector
|
|
7
|
+
* credential, via `POST {apiBaseUrl}/v2/earner/connectors/enroll`.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS EXISTS. Every other path to a working connector needs a human at the
|
|
10
|
+
* machine: `pair --code` needs an 8-character claim code typed in, and `setup`
|
|
11
|
+
* needs a code read off this screen and typed into a browser on another. Neither
|
|
12
|
+
* can be automated, so before this there was no way to deploy to N machines.
|
|
13
|
+
*
|
|
14
|
+
* WHAT COMES BACK, AND WHAT DOES NOT. The response carries a `connectorKey`
|
|
15
|
+
* scoped to camera registration for one account — NOT the fleet credential, and
|
|
16
|
+
* NOT any device token. Device tokens are still minted per camera feed, later,
|
|
17
|
+
* when the cameras this machine finds are registered. That split is deliberate:
|
|
18
|
+
* the credential that ends up sitting on 200 store PCs must not be able to
|
|
19
|
+
* enroll a 201st machine.
|
|
20
|
+
*
|
|
21
|
+
* THE FLEET CREDENTIAL IS CONSUMED, NOT KEPT. The caller writes `connectorKey`
|
|
22
|
+
* into the config and DELETES the enrollment file — see `runEnroll` in cli.ts.
|
|
23
|
+
* A bootstrap credential that outlives its bootstrap is just a credential.
|
|
24
|
+
*
|
|
25
|
+
* Auth is `Authorization: Bearer <enrollment token>` — the same platform-key
|
|
26
|
+
* header the Partner API and the CTV SDK use. Unlike `PairClient`, this request
|
|
27
|
+
* IS authenticated; unlike `AudienceAnalyzeClient`, it carries no device token,
|
|
28
|
+
* because at first boot this machine has no device identity yet. That is the
|
|
29
|
+
* whole point of the exchange.
|
|
30
|
+
*
|
|
31
|
+
* Uses the same built-in-http poster as its siblings (no extra dep → survives
|
|
32
|
+
* bundling; trivially mockable in tests).
|
|
33
|
+
*/
|
|
34
|
+
export interface EnrollClientOptions {
|
|
35
|
+
/** Base URL, e.g. https://api.trillboards.com (no trailing slash needed). */
|
|
36
|
+
apiBaseUrl: string;
|
|
37
|
+
/** Request timeout in ms. Default: 30000. */
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
export interface EnrollRequest {
|
|
41
|
+
/** The fleet enrollment credential from the MDM package. */
|
|
42
|
+
enrollmentToken: string;
|
|
43
|
+
/** This machine's stable fingerprint — `hostFingerprint()` in cli.ts. */
|
|
44
|
+
machineId: string;
|
|
45
|
+
/** Free-text install label, e.g. "Store 0417". Optional. */
|
|
46
|
+
siteLabel?: string;
|
|
47
|
+
hostname?: string;
|
|
48
|
+
platform?: string;
|
|
49
|
+
connectorVersion?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface EnrollResult {
|
|
52
|
+
ok: boolean;
|
|
53
|
+
/** Present only when ok. */
|
|
54
|
+
connectorId?: string;
|
|
55
|
+
connectorKey?: string;
|
|
56
|
+
siteLabel?: string | null;
|
|
57
|
+
reEnrollment?: boolean;
|
|
58
|
+
remainingEnrollments?: number | null;
|
|
59
|
+
error?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Classification, so the installer wrapper can tell an operator-fixable
|
|
62
|
+
* problem from a wait-and-retry one WITHOUT parsing prose:
|
|
63
|
+
*
|
|
64
|
+
* 'auth' the credential is wrong, expired, revoked or exhausted. A human
|
|
65
|
+
* must issue a new one — retrying forever cannot help, and a
|
|
66
|
+
* connector that retries a dead credential every 60s across 200
|
|
67
|
+
* stores is a self-inflicted DDoS.
|
|
68
|
+
* 'network' unreachable / timed out. Retry.
|
|
69
|
+
* 'server' 5xx or an unparseable body. Retry.
|
|
70
|
+
* 'invalid' this machine sent something malformed. A bug, not a retry.
|
|
71
|
+
*/
|
|
72
|
+
kind?: 'auth' | 'network' | 'server' | 'invalid';
|
|
73
|
+
}
|
|
74
|
+
export declare class EnrollClient {
|
|
75
|
+
private readonly endpoint;
|
|
76
|
+
private readonly timeoutMs;
|
|
77
|
+
private readonly poster;
|
|
78
|
+
constructor(options: EnrollClientOptions, poster?: HttpPoster);
|
|
79
|
+
enroll(req: EnrollRequest): Promise<EnrollResult>;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=EnrollClient.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EnrollClient.d.ts","sourceRoot":"","sources":["../src/EnrollClient.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAE7E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,MAAM,WAAW,mBAAmB;IAClC,6EAA6E;IAC7E,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,4DAA4D;IAC5D,eAAe,EAAE,MAAM,CAAC;IACxB,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,OAAO,CAAC;IACZ,4BAA4B;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;OAWG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;CAClD;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;gBAExB,OAAO,EAAE,mBAAmB,EAAE,MAAM,GAAE,UAA8B;IAU1E,MAAM,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC;CAqFxD"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EnrollClient = void 0;
|
|
4
|
+
const AudienceAnalyzeClient_1 = require("./AudienceAnalyzeClient");
|
|
5
|
+
class EnrollClient {
|
|
6
|
+
constructor(options, poster = AudienceAnalyzeClient_1.defaultHttpPoster) {
|
|
7
|
+
if (!options?.apiBaseUrl) {
|
|
8
|
+
throw new Error('EnrollClient requires an apiBaseUrl');
|
|
9
|
+
}
|
|
10
|
+
const base = options.apiBaseUrl.replace(/\/+$/, '');
|
|
11
|
+
this.endpoint = `${base}/v2/earner/connectors/enroll`;
|
|
12
|
+
this.timeoutMs = options.timeoutMs ?? 30000;
|
|
13
|
+
this.poster = poster;
|
|
14
|
+
}
|
|
15
|
+
async enroll(req) {
|
|
16
|
+
const token = (req?.enrollmentToken ?? '').trim();
|
|
17
|
+
if (!token) {
|
|
18
|
+
return { ok: false, error: 'enroll() requires a non-empty enrollmentToken', kind: 'invalid' };
|
|
19
|
+
}
|
|
20
|
+
if (!req?.machineId) {
|
|
21
|
+
return { ok: false, error: 'enroll() requires a machineId', kind: 'invalid' };
|
|
22
|
+
}
|
|
23
|
+
const body = { machineId: req.machineId };
|
|
24
|
+
if (req.siteLabel)
|
|
25
|
+
body.siteLabel = req.siteLabel;
|
|
26
|
+
if (req.hostname)
|
|
27
|
+
body.hostname = req.hostname;
|
|
28
|
+
if (req.platform)
|
|
29
|
+
body.platform = req.platform;
|
|
30
|
+
if (req.connectorVersion)
|
|
31
|
+
body.connectorVersion = req.connectorVersion;
|
|
32
|
+
let response;
|
|
33
|
+
try {
|
|
34
|
+
response = await this.poster(this.endpoint, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
Accept: 'application/json',
|
|
39
|
+
Authorization: `Bearer ${token}`,
|
|
40
|
+
},
|
|
41
|
+
timeoutMs: this.timeoutMs,
|
|
42
|
+
}, JSON.stringify(body));
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: `enrollment request failed: ${err?.message ?? 'unknown error'}`,
|
|
48
|
+
kind: 'network',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
let parsed = null;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(response.body);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
parsed = null;
|
|
57
|
+
}
|
|
58
|
+
const code = response.statusCode;
|
|
59
|
+
if (code < 200 || code >= 300) {
|
|
60
|
+
// 401/403 is terminal: the credential is wrong, spent, expired or revoked.
|
|
61
|
+
// The server deliberately does not say WHICH — see consumeKeyUse — so the
|
|
62
|
+
// connector cannot tell either, and must not retry as if it could.
|
|
63
|
+
const kind = code === 401 || code === 403
|
|
64
|
+
? 'auth'
|
|
65
|
+
: code === 400
|
|
66
|
+
? 'invalid'
|
|
67
|
+
: 'server';
|
|
68
|
+
const message = parsed?.error
|
|
69
|
+
|| parsed?.message
|
|
70
|
+
|| `enrollment failed (HTTP ${code})`;
|
|
71
|
+
return { ok: false, error: message, kind };
|
|
72
|
+
}
|
|
73
|
+
const connectorKey = typeof parsed?.connectorKey === 'string' ? parsed.connectorKey : '';
|
|
74
|
+
const connectorId = typeof parsed?.connectorId === 'string' ? parsed.connectorId : '';
|
|
75
|
+
if (!connectorKey || !connectorId) {
|
|
76
|
+
// A 2xx that carries no credential is the ingestion-endpoint trap in
|
|
77
|
+
// reverse: treating it as success would leave a "provisioned" machine with
|
|
78
|
+
// nothing to authenticate with, and the failure would only surface much
|
|
79
|
+
// later as a camera that never registers.
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: 'enrollment response did not contain a connector credential',
|
|
83
|
+
kind: 'server',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
connectorId,
|
|
89
|
+
connectorKey,
|
|
90
|
+
siteLabel: parsed?.siteLabel ?? null,
|
|
91
|
+
reEnrollment: parsed?.reEnrollment === true,
|
|
92
|
+
remainingEnrollments: typeof parsed?.remainingEnrollments === 'number' ? parsed.remainingEnrollments : null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.EnrollClient = EnrollClient;
|
|
97
|
+
//# sourceMappingURL=EnrollClient.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EnrollClient.js","sourceRoot":"","sources":["../src/EnrollClient.ts"],"names":[],"mappings":"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"}
|
package/dist/cli.d.ts
CHANGED
|
@@ -26,4 +26,53 @@ export declare function configWritePermissionMessage(resolvedConfigPath: string)
|
|
|
26
26
|
export declare function writePairedConfig(resolvedConfigPath: string, serialized: string, writeFile?: (p: string, data: string, opts: {
|
|
27
27
|
mode: number;
|
|
28
28
|
}) => void): void;
|
|
29
|
+
/** Show a credential's SHAPE without showing the credential. */
|
|
30
|
+
export declare function maskConnectorKey(key: string | undefined | null): string;
|
|
31
|
+
/**
|
|
32
|
+
* Read the enrollment token for an unattended install, in precedence order.
|
|
33
|
+
*
|
|
34
|
+
* 1. `--enrollment-token <value>` — for a scripted/manual run.
|
|
35
|
+
* 2. `TRILLBOARDS_ENROLLMENT_TOKEN` — the Docker/systemd path.
|
|
36
|
+
* 3. `--enrollment-file <path>` / the default bootstrap file next to the config
|
|
37
|
+
* — what the Windows installer writes from `/ENROLLMENT_TOKEN=`.
|
|
38
|
+
*
|
|
39
|
+
* The FILE is the preferred form for MDM and the reason this function exists:
|
|
40
|
+
* an argument shows up in the process table and an env var is inherited by every
|
|
41
|
+
* child process, while a file can be read once and DELETED. That deletion is not
|
|
42
|
+
* hygiene, it is the design — the fleet credential must not outlive the
|
|
43
|
+
* bootstrap, or every store PC keeps something that can enroll more machines.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readEnrollmentToken(args: string[], configPath: string, env?: NodeJS.ProcessEnv, readFile?: (p: string) => string, exists?: (p: string) => boolean): {
|
|
46
|
+
token: string | null;
|
|
47
|
+
source: 'flag' | 'env' | 'file' | null;
|
|
48
|
+
filePath: string | null;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* UNATTENDED first-boot provisioning — the MDM/Intune/SCCM path.
|
|
52
|
+
*
|
|
53
|
+
* trillboards-edge enroll [--enrollment-token T | --enrollment-file P]
|
|
54
|
+
* [--site "Store 0417"] [--config PATH] [--api-base URL]
|
|
55
|
+
*
|
|
56
|
+
* Exchanges the FLEET enrollment credential shipped in the deployment package
|
|
57
|
+
* for THIS MACHINE's own connector credential and writes it to the config. No
|
|
58
|
+
* human touches the machine, which is the entire point: `pair` needs a code
|
|
59
|
+
* typed in and `setup` needs a code read off the screen, so neither can be
|
|
60
|
+
* automated across N stores.
|
|
61
|
+
*
|
|
62
|
+
* IDEMPOTENT BY DESIGN. Re-running on an already-enrolled machine is safe and
|
|
63
|
+
* FREE — the server keys on `machineId` and does not spend another use of the
|
|
64
|
+
* bounded fleet credential. MDM re-pushes packages; if a re-push cost a use, a
|
|
65
|
+
* 200-use credential would die partway through a 200-store rollout.
|
|
66
|
+
*
|
|
67
|
+
* ON SUCCESS THE ENROLLMENT FILE IS DELETED. See readEnrollmentToken.
|
|
68
|
+
*
|
|
69
|
+
* Exit codes (the install wrappers branch on these — keep stable, and note they
|
|
70
|
+
* mirror `pair`'s so a wrapper can treat the two paths alike):
|
|
71
|
+
* 0 enrolled (or already enrolled), config written
|
|
72
|
+
* 1 the credential was rejected — wrong, expired, revoked or exhausted. A
|
|
73
|
+
* human must issue a new one; retrying cannot help.
|
|
74
|
+
* 4 no enrollment token was supplied at all (not an unattended install)
|
|
75
|
+
* 6 network/server failure — the credential is UNSPENT. Retry later.
|
|
76
|
+
*/
|
|
77
|
+
export declare function runEnroll(args: string[]): Promise<void>;
|
|
29
78
|
//# sourceMappingURL=cli.d.ts.map
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAscA;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAKvD;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,kBAAkB,EAAE,MAAM,GAAG,MAAM,CAW/E;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,kBAAkB,EAAE,MAAM,EAC1B,UAAU,EAAE,MAAM,EAClB,SAAS,GAAE,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,KAAK,IAC/B,GAChC,IAAI,CAWN;AAUD,gEAAgE;AAChE,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CASvE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EAAE,EACd,UAAU,EAAE,MAAM,EAClB,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,QAAQ,GAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAA2C,EACpE,MAAM,GAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAiC,GACvD;IAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAyB3F;AAgGD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA+K7D"}
|
package/dist/cli.js
CHANGED
|
@@ -37,6 +37,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
37
37
|
exports.isPermissionError = isPermissionError;
|
|
38
38
|
exports.configWritePermissionMessage = configWritePermissionMessage;
|
|
39
39
|
exports.writePairedConfig = writePairedConfig;
|
|
40
|
+
exports.maskConnectorKey = maskConnectorKey;
|
|
41
|
+
exports.readEnrollmentToken = readEnrollmentToken;
|
|
42
|
+
exports.runEnroll = runEnroll;
|
|
40
43
|
const config_1 = require("./config");
|
|
41
44
|
const autoSense_1 = require("./autoSense");
|
|
42
45
|
const sensingRunMode_1 = require("./sensingRunMode");
|
|
@@ -180,6 +183,9 @@ async function main() {
|
|
|
180
183
|
case 'pair':
|
|
181
184
|
await runPair(args.slice(1));
|
|
182
185
|
break;
|
|
186
|
+
case 'enroll':
|
|
187
|
+
await runEnroll(args.slice(1));
|
|
188
|
+
break;
|
|
183
189
|
case 'setup': {
|
|
184
190
|
// Validate-first onboarding (direction-inverted pairing): WE display a
|
|
185
191
|
// short code, the owner claims it in the portal, the portal drives
|
|
@@ -490,6 +496,318 @@ function writePairedConfig(resolvedConfigPath, serialized, writeFile = (p, data,
|
|
|
490
496
|
throw err;
|
|
491
497
|
}
|
|
492
498
|
}
|
|
499
|
+
/** Show a credential's SHAPE without showing the credential. */
|
|
500
|
+
function maskConnectorKey(key) {
|
|
501
|
+
if (!key)
|
|
502
|
+
return '(none)';
|
|
503
|
+
const s = String(key);
|
|
504
|
+
// Prefix identifies the key family; last 4 lets an admin match it to the
|
|
505
|
+
// portal row. Everything between is what must never reach a log, a support
|
|
506
|
+
// bundle, or a screenshot of this terminal.
|
|
507
|
+
const head = s.slice(0, 7);
|
|
508
|
+
const tail = s.length > 4 ? s.slice(-4) : '';
|
|
509
|
+
return `${head}…${tail}`;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Read the enrollment token for an unattended install, in precedence order.
|
|
513
|
+
*
|
|
514
|
+
* 1. `--enrollment-token <value>` — for a scripted/manual run.
|
|
515
|
+
* 2. `TRILLBOARDS_ENROLLMENT_TOKEN` — the Docker/systemd path.
|
|
516
|
+
* 3. `--enrollment-file <path>` / the default bootstrap file next to the config
|
|
517
|
+
* — what the Windows installer writes from `/ENROLLMENT_TOKEN=`.
|
|
518
|
+
*
|
|
519
|
+
* The FILE is the preferred form for MDM and the reason this function exists:
|
|
520
|
+
* an argument shows up in the process table and an env var is inherited by every
|
|
521
|
+
* child process, while a file can be read once and DELETED. That deletion is not
|
|
522
|
+
* hygiene, it is the design — the fleet credential must not outlive the
|
|
523
|
+
* bootstrap, or every store PC keeps something that can enroll more machines.
|
|
524
|
+
*/
|
|
525
|
+
function readEnrollmentToken(args, configPath, env = process.env, readFile = (p) => fs.readFileSync(p, 'utf-8'), exists = (p) => fs.existsSync(p)) {
|
|
526
|
+
const fromFlag = getArg(args, '--enrollment-token');
|
|
527
|
+
if (fromFlag && fromFlag.trim())
|
|
528
|
+
return { token: fromFlag.trim(), source: 'flag', filePath: null };
|
|
529
|
+
const fromEnv = env.TRILLBOARDS_ENROLLMENT_TOKEN;
|
|
530
|
+
if (fromEnv && fromEnv.trim())
|
|
531
|
+
return { token: fromEnv.trim(), source: 'env', filePath: null };
|
|
532
|
+
const filePath = path.resolve(getArg(args, '--enrollment-file')
|
|
533
|
+
|| path.join(path.dirname(path.resolve(configPath)), 'trillboards.enrollment'));
|
|
534
|
+
if (exists(filePath)) {
|
|
535
|
+
try {
|
|
536
|
+
// The installer writes the bare token, but tolerate a trailing newline and
|
|
537
|
+
// a `TOKEN=` prefix — an admin editing this file by hand should not have
|
|
538
|
+
// their rollout fail on a shell habit.
|
|
539
|
+
const raw = readFile(filePath).trim();
|
|
540
|
+
const token = raw.replace(/^TRILLBOARDS_ENROLLMENT_TOKEN\s*=\s*/i, '').replace(/^["']|["']$/g, '').trim();
|
|
541
|
+
if (token)
|
|
542
|
+
return { token, source: 'file', filePath };
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// Unreadable is the same as absent for the caller; the missing-token
|
|
546
|
+
// message below is the right thing to print either way.
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return { token: null, source: null, filePath };
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Find the feeds on this machine's LAN and hand them to the server, returning
|
|
553
|
+
* the `cameras[]` this connector will run on.
|
|
554
|
+
*
|
|
555
|
+
* WHY CREDENTIALS ARE REQUIRED HERE, and why "no credentials" is not an error:
|
|
556
|
+
* this product targets cheap analog DVRs whose channels are behind a recorder
|
|
557
|
+
* password. Registering a channel we cannot open would mint a screen that never
|
|
558
|
+
* sees a frame — a camera board full of feeds that are permanently dark, which
|
|
559
|
+
* is worse than an empty one. So with no credentials we register NOTHING and say
|
|
560
|
+
* so plainly; the operator finishes in the portal exactly as before.
|
|
561
|
+
*
|
|
562
|
+
* The recorder credentials never leave this machine: only a stable `cameraKey`
|
|
563
|
+
* and a display name go to the server, and the credentialed URLs are matched
|
|
564
|
+
* back to the returned device tokens locally.
|
|
565
|
+
*/
|
|
566
|
+
async function discoverAndRegisterFeeds(opts) {
|
|
567
|
+
if (!opts.nvrUser || !opts.nvrPass) {
|
|
568
|
+
return {
|
|
569
|
+
feeds: [],
|
|
570
|
+
discovered: 0,
|
|
571
|
+
reason: 'no recorder credentials were supplied (--nvr-user / --nvr-pass), so no feeds were registered',
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
575
|
+
const discovery = require('./discovery');
|
|
576
|
+
const recorders = await discovery.sweepLanForRecorders({});
|
|
577
|
+
if (!recorders.length) {
|
|
578
|
+
return { feeds: [], discovered: 0, reason: 'no recorders answered on this network' };
|
|
579
|
+
}
|
|
580
|
+
// The XM/DVRIP handshake makes a recorder state its OWN channel count, which
|
|
581
|
+
// is the difference between "a socket accepted a connection" and "a 16-channel
|
|
582
|
+
// recorder, expand it". Hosts that do not answer keep their port-open shape and
|
|
583
|
+
// fall back to a single channel below.
|
|
584
|
+
const annotated = await discovery.annotateXmRecorders(recorders);
|
|
585
|
+
const brand = (opts.brand || 'generic');
|
|
586
|
+
const discovered = [];
|
|
587
|
+
for (const rec of annotated) {
|
|
588
|
+
const xm = rec.xm;
|
|
589
|
+
const channels = Number(xm?.channelNum) > 0 ? Number(xm.channelNum) : 1;
|
|
590
|
+
const expanded = discovery.expandChannelRange({
|
|
591
|
+
brand,
|
|
592
|
+
host: rec.host,
|
|
593
|
+
count: channels,
|
|
594
|
+
username: opts.nvrUser,
|
|
595
|
+
password: opts.nvrPass,
|
|
596
|
+
namePrefix: opts.siteLabel ? `${opts.siteLabel} channel` : 'Channel',
|
|
597
|
+
});
|
|
598
|
+
for (const cam of expanded)
|
|
599
|
+
discovered.push(cam);
|
|
600
|
+
}
|
|
601
|
+
if (!discovered.length) {
|
|
602
|
+
return { feeds: [], discovered: 0, reason: 'recorders answered but no channels could be expanded' };
|
|
603
|
+
}
|
|
604
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
605
|
+
const { ConnectorCameraClient, buildCameraFeeds, MAX_CAMERAS_PER_CALL } = require('./ConnectorCameraClient');
|
|
606
|
+
const client = new ConnectorCameraClient({ apiBaseUrl: opts.apiBaseUrl, connectorKey: opts.connectorKey });
|
|
607
|
+
// The server caps one call; a 64-channel recorder is a real configuration, so
|
|
608
|
+
// page rather than silently truncating (the claim-code path's `.slice(0, 64)`
|
|
609
|
+
// is exactly the bug this avoids).
|
|
610
|
+
const feeds = [];
|
|
611
|
+
for (let i = 0; i < discovered.length; i += MAX_CAMERAS_PER_CALL) {
|
|
612
|
+
const page = discovered.slice(i, i + MAX_CAMERAS_PER_CALL);
|
|
613
|
+
// eslint-disable-next-line no-await-in-loop
|
|
614
|
+
const result = await client.registerCameras(page.map((c) => ({ cameraKey: c.cameraKey, name: c.name })));
|
|
615
|
+
if (!result.ok) {
|
|
616
|
+
// Partial success is kept: the feeds already registered are real and their
|
|
617
|
+
// tokens work. Reporting zero would throw away provisioning that happened.
|
|
618
|
+
return {
|
|
619
|
+
feeds,
|
|
620
|
+
discovered: discovered.length,
|
|
621
|
+
reason: `camera registration failed after ${feeds.length} feed(s): ${result.error}`,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
for (const feed of buildCameraFeeds(result.cameras, discovered))
|
|
625
|
+
feeds.push(feed);
|
|
626
|
+
}
|
|
627
|
+
return { feeds, discovered: discovered.length };
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* UNATTENDED first-boot provisioning — the MDM/Intune/SCCM path.
|
|
631
|
+
*
|
|
632
|
+
* trillboards-edge enroll [--enrollment-token T | --enrollment-file P]
|
|
633
|
+
* [--site "Store 0417"] [--config PATH] [--api-base URL]
|
|
634
|
+
*
|
|
635
|
+
* Exchanges the FLEET enrollment credential shipped in the deployment package
|
|
636
|
+
* for THIS MACHINE's own connector credential and writes it to the config. No
|
|
637
|
+
* human touches the machine, which is the entire point: `pair` needs a code
|
|
638
|
+
* typed in and `setup` needs a code read off the screen, so neither can be
|
|
639
|
+
* automated across N stores.
|
|
640
|
+
*
|
|
641
|
+
* IDEMPOTENT BY DESIGN. Re-running on an already-enrolled machine is safe and
|
|
642
|
+
* FREE — the server keys on `machineId` and does not spend another use of the
|
|
643
|
+
* bounded fleet credential. MDM re-pushes packages; if a re-push cost a use, a
|
|
644
|
+
* 200-use credential would die partway through a 200-store rollout.
|
|
645
|
+
*
|
|
646
|
+
* ON SUCCESS THE ENROLLMENT FILE IS DELETED. See readEnrollmentToken.
|
|
647
|
+
*
|
|
648
|
+
* Exit codes (the install wrappers branch on these — keep stable, and note they
|
|
649
|
+
* mirror `pair`'s so a wrapper can treat the two paths alike):
|
|
650
|
+
* 0 enrolled (or already enrolled), config written
|
|
651
|
+
* 1 the credential was rejected — wrong, expired, revoked or exhausted. A
|
|
652
|
+
* human must issue a new one; retrying cannot help.
|
|
653
|
+
* 4 no enrollment token was supplied at all (not an unattended install)
|
|
654
|
+
* 6 network/server failure — the credential is UNSPENT. Retry later.
|
|
655
|
+
*/
|
|
656
|
+
async function runEnroll(args) {
|
|
657
|
+
const configPath = getArg(args, '--config') || 'trillboards.config.json';
|
|
658
|
+
const resolvedConfigPath = path.resolve(configPath);
|
|
659
|
+
const apiBaseUrl = getArg(args, '--api-base')
|
|
660
|
+
|| process.env.TRILLBOARDS_API_BASE
|
|
661
|
+
|| 'https://api.trillboards.com';
|
|
662
|
+
const siteLabel = getArg(args, '--site') || process.env.TRILLBOARDS_SITE || undefined;
|
|
663
|
+
const { token, source, filePath } = readEnrollmentToken(args, resolvedConfigPath);
|
|
664
|
+
if (!token) {
|
|
665
|
+
console.error('No enrollment credential found.\n' +
|
|
666
|
+
' Pass --enrollment-token <token>, set TRILLBOARDS_ENROLLMENT_TOKEN,\n' +
|
|
667
|
+
` or place the token in ${filePath ?? 'trillboards.enrollment'}.\n` +
|
|
668
|
+
' Create one in the portal under Cameras → Deploy connectors.');
|
|
669
|
+
process.exit(4);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const machineId = hostFingerprint();
|
|
673
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
674
|
+
const { EnrollClient } = require('./EnrollClient');
|
|
675
|
+
const client = new EnrollClient({ apiBaseUrl });
|
|
676
|
+
const result = await client.enroll({
|
|
677
|
+
enrollmentToken: token,
|
|
678
|
+
machineId,
|
|
679
|
+
siteLabel,
|
|
680
|
+
hostname: os.hostname(),
|
|
681
|
+
platform: process.platform,
|
|
682
|
+
connectorVersion: VERSION,
|
|
683
|
+
});
|
|
684
|
+
if (!result.ok) {
|
|
685
|
+
console.error(`Enrollment failed: ${result.error}`);
|
|
686
|
+
if (result.kind === 'auth') {
|
|
687
|
+
console.error('The enrollment credential was rejected. It may be expired, revoked, or it may\n' +
|
|
688
|
+
'have already enrolled every machine it was issued for. Issue a new one in the\n' +
|
|
689
|
+
'portal — retrying with this one cannot succeed.');
|
|
690
|
+
process.exit(1);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (result.kind === 'invalid') {
|
|
694
|
+
process.exit(1);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
console.error('This is a network or server failure — the credential was NOT spent. Retry later.');
|
|
698
|
+
process.exit(6);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
// Merge into any existing config rather than replacing it. A machine may be
|
|
702
|
+
// re-enrolled after cameras are already registered, and clobbering
|
|
703
|
+
// `cameras[]` would drop every device token on the box.
|
|
704
|
+
let existing = {};
|
|
705
|
+
if (fs.existsSync(resolvedConfigPath)) {
|
|
706
|
+
try {
|
|
707
|
+
existing = JSON.parse(fs.readFileSync(resolvedConfigPath, 'utf-8'));
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
console.warn(`Existing config at ${resolvedConfigPath} is not valid JSON — writing a fresh one.`);
|
|
711
|
+
existing = {};
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// Now that this machine has a credential, find its feeds and register them.
|
|
715
|
+
// Enrollment without this leaves the box with an identity and no cameras, and
|
|
716
|
+
// `auto-sense` exits through its no-feeds path — an install that reports
|
|
717
|
+
// success while a human still has to run the interactive camera setup.
|
|
718
|
+
let registration = {
|
|
719
|
+
feeds: [],
|
|
720
|
+
discovered: 0,
|
|
721
|
+
reason: 'camera registration was skipped',
|
|
722
|
+
};
|
|
723
|
+
try {
|
|
724
|
+
registration = await discoverAndRegisterFeeds({
|
|
725
|
+
apiBaseUrl,
|
|
726
|
+
connectorKey: result.connectorKey,
|
|
727
|
+
nvrUser: getArg(args, '--nvr-user') || process.env.TRILLBOARDS_NVR_USER,
|
|
728
|
+
nvrPass: getArg(args, '--nvr-pass') || process.env.TRILLBOARDS_NVR_PASS,
|
|
729
|
+
brand: getArg(args, '--brand'),
|
|
730
|
+
siteLabel: result.siteLabel || siteLabel,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
// A discovery failure must never lose the credential we just obtained: the
|
|
735
|
+
// enrollment itself SUCCEEDED and its use is spent, so write the config and
|
|
736
|
+
// let the operator (or the next-logon retry) finish the camera half.
|
|
737
|
+
registration = {
|
|
738
|
+
feeds: [],
|
|
739
|
+
discovered: 0,
|
|
740
|
+
reason: `camera discovery failed: ${err?.message ?? 'unknown error'}`,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
const existingCameras = Array.isArray(existing.cameras)
|
|
744
|
+
? (existing.cameras)
|
|
745
|
+
: [];
|
|
746
|
+
// Merge by screenId so a re-run refreshes a feed's token rather than
|
|
747
|
+
// duplicating it, and never drops a feed the operator added by hand.
|
|
748
|
+
const mergedCameras = registration.feeds.length > 0
|
|
749
|
+
? [
|
|
750
|
+
...registration.feeds,
|
|
751
|
+
...existingCameras.filter((c) => !registration.feeds.some((f) => f.screenId && f.screenId === c.screenId)),
|
|
752
|
+
]
|
|
753
|
+
: existingCameras;
|
|
754
|
+
const merged = {
|
|
755
|
+
...existing,
|
|
756
|
+
apiBaseUrl,
|
|
757
|
+
...(mergedCameras.length > 0 ? { cameras: mergedCameras } : {}),
|
|
758
|
+
connector: {
|
|
759
|
+
id: result.connectorId,
|
|
760
|
+
key: result.connectorKey,
|
|
761
|
+
machineId,
|
|
762
|
+
...(result.siteLabel ? { siteLabel: result.siteLabel } : {}),
|
|
763
|
+
enrolledAt: new Date().toISOString(),
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
fs.mkdirSync(path.dirname(resolvedConfigPath), { recursive: true });
|
|
767
|
+
writePairedConfig(resolvedConfigPath, JSON.stringify(merged, null, 2) + '\n');
|
|
768
|
+
// `mode` on writeFileSync only applies on CREATE, so an existing 0644 config —
|
|
769
|
+
// exactly what `trillboards-edge init` leaves behind — would keep its
|
|
770
|
+
// permissions and this credential would land world-readable. Tighten
|
|
771
|
+
// explicitly, as setupMode.ts does for the same reason.
|
|
772
|
+
try {
|
|
773
|
+
fs.chmodSync(resolvedConfigPath, 0o600);
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
// Windows has no POSIX modes; %LOCALAPPDATA% is the per-user boundary there.
|
|
777
|
+
}
|
|
778
|
+
// The fleet credential has done its one job. Anything left on disk that can
|
|
779
|
+
// enroll another machine is a skeleton key sitting in a store.
|
|
780
|
+
if (source === 'file' && filePath) {
|
|
781
|
+
try {
|
|
782
|
+
fs.unlinkSync(filePath);
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
console.warn(`WARNING: enrolled successfully but could not delete the enrollment file at ${filePath}: ` +
|
|
786
|
+
`${err?.message}. Delete it manually — it can enroll further machines.`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
console.log(`${result.reEnrollment ? 'Re-enrolled' : 'Enrolled'} connector ${result.connectorId}` +
|
|
790
|
+
(result.siteLabel ? ` (${result.siteLabel})` : '') +
|
|
791
|
+
`\n machine: ${machineId}` +
|
|
792
|
+
`\n credential: ${maskConnectorKey(result.connectorKey)}` +
|
|
793
|
+
(result.remainingEnrollments != null
|
|
794
|
+
? `\n remaining enrollments on this credential: ${result.remainingEnrollments}`
|
|
795
|
+
: '') +
|
|
796
|
+
`\n cameras: ${mergedCameras.length} registered` +
|
|
797
|
+
(registration.reason ? ` (${registration.reason})` : ''));
|
|
798
|
+
// Exit 5 = provisioned but NOT sensing. A distinct code, because "enrolled"
|
|
799
|
+
// and "enrolled and actually watching something" are different outcomes and
|
|
800
|
+
// collapsing them into 0 is how an installer reports success on a machine that
|
|
801
|
+
// still needs a human.
|
|
802
|
+
if (mergedCameras.length === 0) {
|
|
803
|
+
console.log('This connector is provisioned but has no camera feeds yet. Supply recorder\n' +
|
|
804
|
+
'credentials (--nvr-user / --nvr-pass, or /NVR_USER= /NVR_PASS= at install)\n' +
|
|
805
|
+
'and re-run, or finish in the portal.');
|
|
806
|
+
process.exit(5);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
process.exit(0);
|
|
810
|
+
}
|
|
493
811
|
/**
|
|
494
812
|
* First-run pairing: exchange a portal claim code for the connector config and
|
|
495
813
|
* write trillboards.config.json. The device token is delivered ONLY here (never
|
|
@@ -749,6 +1067,20 @@ Usage:
|
|
|
749
1067
|
trillboards-edge setup [--config <path>] Validate-first onboarding: opens the portal and keeps a code fallback
|
|
750
1068
|
trillboards-edge pair --code <XXXX-XXXX> Pair with a portal claim code (writes config)
|
|
751
1069
|
trillboards-edge pair --qr <image.png> Pair by scanning a screenshot/photo of the portal QR
|
|
1070
|
+
trillboards-edge enroll [--site "Store 0417"] [--nvr-user U --nvr-pass P]
|
|
1071
|
+
Unattended provisioning for MDM/Intune/SCCM — no
|
|
1072
|
+
human at the machine. Reads the fleet enrollment
|
|
1073
|
+
credential from --enrollment-token, the
|
|
1074
|
+
TRILLBOARDS_ENROLLMENT_TOKEN env var, or the
|
|
1075
|
+
trillboards.enrollment file, exchanges it for THIS
|
|
1076
|
+
machine's own credential, deletes the file, then
|
|
1077
|
+
discovers recorders on the LAN and registers their
|
|
1078
|
+
channels (recorder credentials required for that
|
|
1079
|
+
half — without them the machine provisions its
|
|
1080
|
+
identity and registers no feeds).
|
|
1081
|
+
Safe to re-run: a re-push does not consume another
|
|
1082
|
+
enrollment. (exit 0=ok 1=credential rejected
|
|
1083
|
+
4=no token 5=enrolled but no feeds 6=network, retry)
|
|
752
1084
|
trillboards-edge verify-connection [--config p] Prove the saved pairing works (exit 0=ok 2=network 3=auth 4=no config)
|
|
753
1085
|
trillboards-edge discover Find cameras on your network (ONVIF) or expand an NVR's channels
|
|
754
1086
|
trillboards-edge status [--host h] [--port p] Show agent status dashboard
|