@feralfile/cli 3.0.0 → 4.0.0
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/README.md +20 -17
- package/config.json.example +1 -1
- package/dist/index.js +10 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/build.js +1 -1
- package/dist/src/commands/device.js +84 -39
- package/dist/src/commands/enrich.js +2 -2
- package/dist/src/commands/find.js +71 -37
- package/dist/src/commands/helpers/device-control.js +937 -0
- package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
- package/dist/src/commands/helpers/playlist-display.js +2 -15
- package/dist/src/commands/play.js +39 -26
- package/dist/src/commands/report.js +251 -0
- package/dist/src/commands/setup.js +1 -1
- package/dist/src/config.js +162 -6
- package/dist/src/utilities/control/codec.js +578 -0
- package/dist/src/utilities/control/control-errors.js +157 -0
- package/dist/src/utilities/control/controller-store.js +964 -0
- package/dist/src/utilities/control/jwk.js +141 -0
- package/dist/src/utilities/control/pairing-channel.js +52 -0
- package/dist/src/utilities/control/pairing.js +416 -0
- package/dist/src/utilities/control/registration.js +391 -0
- package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
- package/dist/src/utilities/control/secure-transport.js +850 -0
- package/dist/src/utilities/control/strict-json.js +376 -0
- package/dist/src/utilities/device-network.js +129 -0
- package/dist/src/utilities/device-upsert.js +1 -1
- package/dist/src/utilities/ff1-compatibility.js +87 -11
- package/dist/src/utilities/ff1-device.js +161 -78
- package/dist/src/utilities/ff1-relayer.js +34 -14
- package/dist/src/utilities/handoff-client.js +23 -9
- package/dist/src/utilities/neort-marketplace.js +3 -3
- package/dist/src/utilities/playlist-builder.js +3 -3
- package/dist/src/utilities/playlist-cast.js +6 -6
- package/dist/src/utilities/playlist-source.js +7 -7
- package/dist/src/utilities/raster-client.js +1 -1
- package/dist/src/utilities/relay-client.js +774 -0
- package/dist/src/utilities/ssh-access.js +192 -58
- package/dist/src/utilities/support-report.js +511 -0
- package/dist/src/utilities/temporary-channel.js +139 -0
- package/docs/CONFIGURATION.md +270 -28
- package/docs/EXAMPLES.md +31 -31
- package/docs/PROJECT_SPEC.md +32 -19
- package/docs/README.md +113 -36
- package/package.json +5 -2
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Secure command transport (ffos-user docs/ff1-control-v1.md §10): sends one
|
|
4
|
+
* request as `{"command":"ff1Envelope","request":{"v":1,"jwe":…}}` to the
|
|
5
|
+
* FF1's LAN `POST /api/cast`, and, where today's plaintext path falls back,
|
|
6
|
+
* through the relayer topic cast.
|
|
7
|
+
*
|
|
8
|
+
* Rules this module owns (keep them when changing it):
|
|
9
|
+
* - Every attempt is sealed afresh with a newly allocated counter, rid, rk,
|
|
10
|
+
* epk and IV. The same ctr is never sent twice, so a retry after an
|
|
11
|
+
* ambiguous failure (timeout, empty body) is a NEW request. The device's
|
|
12
|
+
* replay window would reject a resend anyway; resending would also let a
|
|
13
|
+
* lost-but-executed command run twice.
|
|
14
|
+
* - Retries happen only where the legacy path already retries
|
|
15
|
+
* (displayPlaylist: transient network errors, HTTP 429, empty or non-JSON
|
|
16
|
+
* 2xx bodies), plus the two encrypted codes the spec says to retry with a
|
|
17
|
+
* new ctr (internal_error) or after a pause (rate_limited).
|
|
18
|
+
* - The relayer is used only after LAN reachability is exhausted, never after
|
|
19
|
+
* an HTTP or protocol answer, exactly like the legacy fallback. With a
|
|
20
|
+
* configured v2 endpoint, or for an FF1 whose verified status advertised
|
|
21
|
+
* relay v2, the v2 device channel is tried first; the legacy topic follows
|
|
22
|
+
* only when v2 proves the command undelivered (§10). One exception: a
|
|
23
|
+
* registration enroll tries a topic held from before the pairing first,
|
|
24
|
+
* and moves on only on the relayer's 1003, 410 or 404 (sendOnce). And an
|
|
25
|
+
* ordinary command with no working topic (none held, the store
|
|
26
|
+
* unreadable, or the topic is gone) may still try v2
|
|
27
|
+
* (fallbackOrUnreachable).
|
|
28
|
+
* - A reply that fails §4.2 verification is discarded whole and not retried:
|
|
29
|
+
* on the relayer a forged "first reply wins" answer costs availability
|
|
30
|
+
* only, and retrying would hand an attacker a free oracle.
|
|
31
|
+
* - A `replay` in a reply that VERIFIED under the pinned devSig is the one
|
|
32
|
+
* answer that moves the counter: it proves the request did not run, so the
|
|
33
|
+
* counter jumps past the FF1's window and the command is sent once more
|
|
34
|
+
* (§14.4). A public error is never evidence of this, and neither is a
|
|
35
|
+
* `replay` over relay v2, where the FF1 may have received two copies of
|
|
36
|
+
* the same bytes: that is an unknown outcome (v2Answer).
|
|
37
|
+
*/
|
|
38
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
39
|
+
if (k2 === undefined) k2 = k;
|
|
40
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
41
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
42
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
43
|
+
}
|
|
44
|
+
Object.defineProperty(o, k2, desc);
|
|
45
|
+
}) : (function(o, m, k, k2) {
|
|
46
|
+
if (k2 === undefined) k2 = k;
|
|
47
|
+
o[k2] = m[k];
|
|
48
|
+
}));
|
|
49
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
50
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
51
|
+
}) : function(o, v) {
|
|
52
|
+
o["default"] = v;
|
|
53
|
+
});
|
|
54
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
55
|
+
var ownKeys = function(o) {
|
|
56
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
57
|
+
var ar = [];
|
|
58
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
59
|
+
return ar;
|
|
60
|
+
};
|
|
61
|
+
return ownKeys(o);
|
|
62
|
+
};
|
|
63
|
+
return function (mod) {
|
|
64
|
+
if (mod && mod.__esModule) return mod;
|
|
65
|
+
var result = {};
|
|
66
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
67
|
+
__setModuleDefault(result, mod);
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
})();
|
|
71
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
72
|
+
exports.REPLAY_COUNTER_JUMP = void 0;
|
|
73
|
+
exports.findSecureController = findSecureController;
|
|
74
|
+
exports.findEnvelopeAnswer = findEnvelopeAnswer;
|
|
75
|
+
exports.sendSecureRequest = sendSecureRequest;
|
|
76
|
+
exports.advertisedRelayV2 = advertisedRelayV2;
|
|
77
|
+
exports.outcomeToResult = outcomeToResult;
|
|
78
|
+
exports.runSecureCommand = runSecureCommand;
|
|
79
|
+
const relay_client_1 = require("../relay-client");
|
|
80
|
+
const http_1 = require("../http");
|
|
81
|
+
const logger = __importStar(require("../../logger"));
|
|
82
|
+
const config_1 = require("../../config");
|
|
83
|
+
const topic_store_1 = require("../topic-store");
|
|
84
|
+
const ff1_relayer_1 = require("../ff1-relayer");
|
|
85
|
+
const device_network_1 = require("../device-network");
|
|
86
|
+
const codec_1 = require("./codec");
|
|
87
|
+
const controller_store_1 = require("./controller-store");
|
|
88
|
+
const control_errors_1 = require("./control-errors");
|
|
89
|
+
const RETRY_ATTEMPTS = 3;
|
|
90
|
+
const RETRY_BASE_DELAY_MS = 750;
|
|
91
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
92
|
+
// §4.4 public errors come back as LAN 401/403, and `busy` (the device was
|
|
93
|
+
// authenticating too many envelopes at once; nothing was executed) as 503.
|
|
94
|
+
const PUBLIC_ERROR_STATUSES = new Set([401, 403, 503]);
|
|
95
|
+
/**
|
|
96
|
+
* REPLAY_COUNTER_JUMP is how far a verified `replay` moves the counter. The
|
|
97
|
+
* spec asks for at least the 64-entry window; a restored backup can be many
|
|
98
|
+
* requests behind, and skipped counters cost nothing, so one jump covers it.
|
|
99
|
+
*/
|
|
100
|
+
exports.REPLAY_COUNTER_JUMP = 1024;
|
|
101
|
+
/** V2_TIMEOUT_MS is the default deadline of one relay v2 command. */
|
|
102
|
+
const V2_TIMEOUT_MS = 30_000;
|
|
103
|
+
function resolveDeps(deps) {
|
|
104
|
+
return {
|
|
105
|
+
fetchFn: deps.fetchFn ?? http_1.defaultDeadlineFetch,
|
|
106
|
+
getTopicIdFn: deps.getTopicIdFn ?? topic_store_1.getTopicId,
|
|
107
|
+
getRelayerConfigFn: deps.getRelayerConfigFn ?? config_1.getFF1RelayerConfig,
|
|
108
|
+
waitFn: deps.waitFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
109
|
+
noticeFn: deps.noticeFn ?? logger.always,
|
|
110
|
+
nowFn: deps.nowFn ?? (() => Math.floor(Date.now() / 1000)),
|
|
111
|
+
controllerStore: deps.controllerStore ?? {},
|
|
112
|
+
v2TimeoutMs: deps.v2TimeoutMs,
|
|
113
|
+
relayClientFn: deps.relayClientFn ?? ((endpoint, deviceId) => new relay_client_1.RelayClient(endpoint, deviceId)),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* findSecureController returns the controller record to use for a device, or
|
|
118
|
+
* null when the device has none (legacy path). A pending (not yet approved)
|
|
119
|
+
* record is reported as a failure instead of being used: the device would
|
|
120
|
+
* answer `pending`/`not_approved` and the legacy path is not an option for a
|
|
121
|
+
* device the app already handed a control profile for.
|
|
122
|
+
*
|
|
123
|
+
* @param device - Configured device
|
|
124
|
+
* @param options - Controller store options
|
|
125
|
+
* @returns The record, null for legacy, or a failure
|
|
126
|
+
*/
|
|
127
|
+
function findSecureController(device, options = {}) {
|
|
128
|
+
if (!device.id) {
|
|
129
|
+
return { record: null };
|
|
130
|
+
}
|
|
131
|
+
let record;
|
|
132
|
+
try {
|
|
133
|
+
record = (0, controller_store_1.loadController)(device.id, options);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return { record: null, failure: { code: 'store', error: error.message } };
|
|
137
|
+
}
|
|
138
|
+
if (record && !(0, controller_store_1.isEnrolled)(record)) {
|
|
139
|
+
return {
|
|
140
|
+
record: null,
|
|
141
|
+
failure: {
|
|
142
|
+
code: 'pending',
|
|
143
|
+
error: 'Pairing ff-cli with this FF1 is not finished.',
|
|
144
|
+
details: `Run \`${(0, control_errors_1.pairCommand)(device.name ?? device.id)} --resume\` to finish it, or run \`${(0, control_errors_1.unpairCommand)(device.name ?? device.id)}\` to go back to unpaired access.`,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return { record };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* sealNext allocates a counter (persisted before returning) and seals the
|
|
152
|
+
* request with it.
|
|
153
|
+
*/
|
|
154
|
+
async function sealNext(request, deps) {
|
|
155
|
+
const { record, ctr } = (0, controller_store_1.nextCounter)(request.device.id, deps.controllerStore);
|
|
156
|
+
return (0, codec_1.sealRequest)({
|
|
157
|
+
profile: record.profile,
|
|
158
|
+
keys: record.keys,
|
|
159
|
+
ctr,
|
|
160
|
+
op: request.op,
|
|
161
|
+
cmd: request.cmd,
|
|
162
|
+
req: request.req,
|
|
163
|
+
enroll: request.enroll,
|
|
164
|
+
now: deps.nowFn(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* findEnvelopeAnswer locates the device's answer in a response body: the LAN
|
|
169
|
+
* returns it at top level, the relayer wraps the RPC `message` one or two
|
|
170
|
+
* levels down. The answer is `{v:1,jwe}` or a public `{error:{code}}`.
|
|
171
|
+
*
|
|
172
|
+
* @param value - Parsed response body
|
|
173
|
+
* @returns The compact JWE, a public code, or null when absent
|
|
174
|
+
*/
|
|
175
|
+
function findEnvelopeAnswer(value) {
|
|
176
|
+
let current = value;
|
|
177
|
+
for (let depth = 0; depth < 3 && current && typeof current === 'object'; depth += 1) {
|
|
178
|
+
const record = current;
|
|
179
|
+
if (record.v === 1 && typeof record.jwe === 'string') {
|
|
180
|
+
return { jwe: record.jwe };
|
|
181
|
+
}
|
|
182
|
+
const error = record.error;
|
|
183
|
+
if (error && typeof error === 'object' && typeof error.code === 'string') {
|
|
184
|
+
return { code: error.code };
|
|
185
|
+
}
|
|
186
|
+
current = record.message;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* openAnswer turns a located answer into an outcome, verifying a reply
|
|
192
|
+
* against the PINNED profile and the request's binding (§4.2).
|
|
193
|
+
*/
|
|
194
|
+
async function openAnswer(answer, sealed, record, transport) {
|
|
195
|
+
if (answer.code) {
|
|
196
|
+
return { kind: 'public', code: answer.code, transport };
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const reply = await (0, codec_1.openReply)(answer.jwe, sealed.rk, record.profile.sig, {
|
|
200
|
+
dev: sealed.claims.dev,
|
|
201
|
+
gen: sealed.claims.gen,
|
|
202
|
+
ctl: sealed.claims.ctl,
|
|
203
|
+
rid: sealed.claims.rid,
|
|
204
|
+
});
|
|
205
|
+
return { kind: 'reply', reply, transport };
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
const failure = (0, control_errors_1.unverifiedReplyFailure)(error.message);
|
|
209
|
+
return { kind: 'failed', ...failure, transport };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** attemptLan sends one sealed request over the LAN and classifies the result. */
|
|
213
|
+
async function attemptLan(request, sealed, record, deps) {
|
|
214
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
215
|
+
if (request.device.apiKey) {
|
|
216
|
+
headers['API-KEY'] = request.device.apiKey;
|
|
217
|
+
}
|
|
218
|
+
const response = await deps.fetchFn(`${request.device.host}/api/cast`, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers,
|
|
221
|
+
body: JSON.stringify((0, codec_1.envelopeCastBody)(sealed.compact)),
|
|
222
|
+
});
|
|
223
|
+
const bodyText = (await response.text()).trim();
|
|
224
|
+
if (response.status === 429) {
|
|
225
|
+
const delayMs = (0, device_network_1.parseRetryAfterMs)(response.headers.get('retry-after'));
|
|
226
|
+
return {
|
|
227
|
+
kind: 'retryable',
|
|
228
|
+
reason: 'Device rate-limited the command',
|
|
229
|
+
delayMs,
|
|
230
|
+
final: {
|
|
231
|
+
kind: 'failed',
|
|
232
|
+
error: `Device "${request.device.name || request.device.host}" is busy and rate-limited the command`,
|
|
233
|
+
details: 'Wait a few seconds and try again.',
|
|
234
|
+
transport: 'lan',
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
let parsed;
|
|
239
|
+
try {
|
|
240
|
+
parsed = bodyText ? JSON.parse(bodyText) : undefined;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
parsed = undefined;
|
|
244
|
+
}
|
|
245
|
+
const answer = findEnvelopeAnswer(parsed);
|
|
246
|
+
if (answer && (response.ok || (answer.code && PUBLIC_ERROR_STATUSES.has(response.status)))) {
|
|
247
|
+
return openAnswer(answer, sealed, record, 'lan');
|
|
248
|
+
}
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
return {
|
|
251
|
+
kind: 'failed',
|
|
252
|
+
error: `Device returned error ${response.status}: ${response.statusText}`,
|
|
253
|
+
details: bodyText.slice(0, 500),
|
|
254
|
+
transport: 'lan',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const issue = bodyText
|
|
258
|
+
? `device returned a response without a secure reply: ${bodyText.slice(0, 200)}`
|
|
259
|
+
: 'device returned an empty response body';
|
|
260
|
+
return {
|
|
261
|
+
kind: 'retryable',
|
|
262
|
+
reason: issue,
|
|
263
|
+
final: {
|
|
264
|
+
kind: 'failed',
|
|
265
|
+
error: `Device "${request.device.name || request.device.host}" accepted the request (HTTP ${response.status}) but returned no secure reply`,
|
|
266
|
+
details: `${issue}. Check that the device address still reaches this FF1; ff-cli keeps its keys. Only if you know this FF1 now runs firmware without secure control, run \`${(0, control_errors_1.unpairCommand)(request.device.name ?? request.device.id)}\`.`,
|
|
267
|
+
transport: 'lan',
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* retryableReply reports whether an answer may be retried with a new
|
|
273
|
+
* request: the public `busy` (§4.4: not executed), `internal_error` (§4.3: not executed, retry with a new ctr),
|
|
274
|
+
* `rate_limited`, and a `command_failed` whose handler body is the router's
|
|
275
|
+
* storm protection (`res.error == "rate_limited"`), which the plaintext path
|
|
276
|
+
* sees as HTTP 429 and already retries.
|
|
277
|
+
*/
|
|
278
|
+
function retryableReply(outcome) {
|
|
279
|
+
if (outcome.kind === 'public') {
|
|
280
|
+
return outcome.code === 'busy';
|
|
281
|
+
}
|
|
282
|
+
if (outcome.kind !== 'reply' || outcome.reply.ok) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
const code = outcome.reply.code;
|
|
286
|
+
if (code === 'internal_error' || code === 'rate_limited') {
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
const res = outcome.reply.res;
|
|
290
|
+
return code === 'command_failed' && res?.error === 'rate_limited';
|
|
291
|
+
}
|
|
292
|
+
/** isDeadlineError reports a request aborted by its fetch deadline. */
|
|
293
|
+
function isDeadlineError(error) {
|
|
294
|
+
return error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* sendSecureRequest delivers one logical command and returns its outcome.
|
|
298
|
+
* A verified `replay` (the counter fell behind the FF1's window) moves the
|
|
299
|
+
* counter ahead and sends the command once more as a new request (§14.4).
|
|
300
|
+
*
|
|
301
|
+
* @param request - The command and its routing rules
|
|
302
|
+
* @param record - The device's enrolled (or, for pairing, pending) controller record
|
|
303
|
+
* @param overrides - Test seams
|
|
304
|
+
* @returns The outcome; never throws for network or protocol failures
|
|
305
|
+
*/
|
|
306
|
+
async function sendSecureRequest(request, record, overrides = {}) {
|
|
307
|
+
const deps = resolveDeps(overrides);
|
|
308
|
+
const outcome = await sendOnce(request, record, deps);
|
|
309
|
+
noteTransports(request, record, outcome, deps);
|
|
310
|
+
if (outcome.kind !== 'reply' || outcome.reply.ok || outcome.reply.code !== 'replay') {
|
|
311
|
+
return outcome;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
(0, controller_store_1.skipCounterAhead)(request.device.id, exports.REPLAY_COUNTER_JUMP, deps.controllerStore);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
logger.debug(`Could not move the controller counter ahead: ${error.message}`);
|
|
318
|
+
return outcome;
|
|
319
|
+
}
|
|
320
|
+
logger.debug(`The FF1 answered replay; counter moved ahead by ${exports.REPLAY_COUNTER_JUMP}, retrying once`);
|
|
321
|
+
const retried = await sendOnce(request, record, deps);
|
|
322
|
+
if (retried.kind === 'reply' && !retried.reply.ok && retried.reply.code === 'replay') {
|
|
323
|
+
return { ...retried, replayRetried: true };
|
|
324
|
+
}
|
|
325
|
+
return retried;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* advertisedRelayV2 reads `control.transports` from a getDeviceStatus body
|
|
329
|
+
* (bare, or in the `{message:{…}}` wrapper the LAN reply uses). An absent
|
|
330
|
+
* transports member means lan and relayer only; an absent control block
|
|
331
|
+
* says nothing, so it returns undefined.
|
|
332
|
+
*/
|
|
333
|
+
function advertisedRelayV2(res) {
|
|
334
|
+
const body = res;
|
|
335
|
+
const control = (body?.control ?? body?.message?.control);
|
|
336
|
+
if (!control || typeof control !== 'object') {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
return Array.isArray(control.transports) && control.transports.includes('relayV2');
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* noteTransports caches, from a VERIFIED getDeviceStatus reply only, whether
|
|
343
|
+
* the FF1 advertises relay v2; fallbackOrUnreachable reads it when the LAN is
|
|
344
|
+
* down. A plaintext status is never used: on the LAN anyone can forge it.
|
|
345
|
+
*/
|
|
346
|
+
function noteTransports(request, record, outcome, deps) {
|
|
347
|
+
if (request.cmd !== 'getDeviceStatus' || outcome.kind !== 'reply' || !outcome.reply.ok) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const relayV2 = advertisedRelayV2(outcome.reply.res);
|
|
351
|
+
if (relayV2 === undefined || relayV2 === record.relayV2) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
record.relayV2 = relayV2;
|
|
355
|
+
try {
|
|
356
|
+
(0, controller_store_1.recordRelayV2)(request.device.id, relayV2, deps.controllerStore);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
logger.debug(`Could not record the FF1's relay transports: ${error.message}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* sendOnce is one logical delivery: the LAN with its retry budget, then the
|
|
364
|
+
* relayer when the LAN was unreachable.
|
|
365
|
+
*/
|
|
366
|
+
async function sendOnce(request, record, deps) {
|
|
367
|
+
// §10 newcomer order: a topic held from BEFORE the pairing goes first. It
|
|
368
|
+
// reaches the real FF1's authenticated legacy socket, which cannot open an
|
|
369
|
+
// enroll sealed to a profile forged by someone holding the QR's ps, so a
|
|
370
|
+
// forgery fails there; over the LAN or the public v2 channel the forger
|
|
371
|
+
// could answer it. Only the relayer's own 1003 (no device on the topic),
|
|
372
|
+
// 410 (legacy retired) or 404 (legacy host shut down) lets the enroll go
|
|
373
|
+
// on to the LAN and v2 (heldTopicGone).
|
|
374
|
+
const held = registrationHeldTopic(request, record, deps);
|
|
375
|
+
const heldTopic = held === UNKNOWN_HELD_TOPIC ? null : held;
|
|
376
|
+
if (heldTopic) {
|
|
377
|
+
const viaTopic = await sendViaRelayer(request, record, deps, heldTopic);
|
|
378
|
+
if (!heldTopicGone(viaTopic)) {
|
|
379
|
+
return viaTopic;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const attempts = request.retry ? RETRY_ATTEMPTS : 1;
|
|
383
|
+
let last;
|
|
384
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
385
|
+
let result;
|
|
386
|
+
try {
|
|
387
|
+
const sealed = await sealNext(request, deps);
|
|
388
|
+
result = await attemptLan(request, sealed, record, deps);
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
if (!(0, device_network_1.isTransientDeviceNetworkError)(error)) {
|
|
392
|
+
return {
|
|
393
|
+
kind: 'failed',
|
|
394
|
+
error: error.message,
|
|
395
|
+
...(isDeadlineError(error) ? { timedOut: true } : {}),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
// §14.4: a command may be sent again (new ctr, LAN or relayer) only when
|
|
399
|
+
// the failure proves it never left this machine. enroll is idempotent
|
|
400
|
+
// on the FF1 (§5.3), so it keeps retrying.
|
|
401
|
+
if (request.op === 'cmd' && !(0, device_network_1.isConnectPhaseNetworkError)(error)) {
|
|
402
|
+
return outcomeUnknown(request, error);
|
|
403
|
+
}
|
|
404
|
+
if (attempt < attempts) {
|
|
405
|
+
logger.warn(`Transient network error (attempt ${attempt}/${attempts}): ${error.message}`);
|
|
406
|
+
await deps.waitFn(RETRY_BASE_DELAY_MS * attempt);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
return fallbackOrUnreachable(request, record, deps, error, {
|
|
410
|
+
topicTried: heldTopic !== null,
|
|
411
|
+
heldTopicUnknown: held === UNKNOWN_HELD_TOPIC,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const retryable = result.kind === 'retryable' || retryableReply(result);
|
|
415
|
+
if (!retryable) {
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
last = result.kind === 'retryable' ? result.final : result;
|
|
419
|
+
if (attempt < attempts) {
|
|
420
|
+
const delay = Math.min((result.kind === 'retryable' && result.delayMs) || RETRY_BASE_DELAY_MS * attempt, MAX_RETRY_DELAY_MS);
|
|
421
|
+
logger.warn(`Retrying with a new request in ${delay}ms (attempt ${attempt}/${attempts})`);
|
|
422
|
+
await deps.waitFn(delay);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return last;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* outcomeUnknown reports a command whose LAN request may have reached the FF1
|
|
429
|
+
* (the connection broke after it was opened). It is not sent again, so a
|
|
430
|
+
* non-idempotent command cannot run twice.
|
|
431
|
+
*/
|
|
432
|
+
function outcomeUnknown(request, lanError) {
|
|
433
|
+
const label = request.device.name || request.device.host;
|
|
434
|
+
logger.debug(`Ambiguous LAN failure: ${lanError.message}`);
|
|
435
|
+
return {
|
|
436
|
+
kind: 'failed',
|
|
437
|
+
error: `Outcome unknown: the connection to "${label}" broke after the request was sent (${lanError.message})`,
|
|
438
|
+
details: 'The FF1 may already have run it, so ff-cli did not send it again. Check the FF1, then run the command again if it did not take effect.',
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* fallbackOrUnreachable sends a freshly sealed request through the relayer
|
|
443
|
+
* when allowed and possible, else reports the LAN as unreachable.
|
|
444
|
+
*/
|
|
445
|
+
async function fallbackOrUnreachable(request, record, deps, lanError, held = {}) {
|
|
446
|
+
const label = request.device.name || request.device.host;
|
|
447
|
+
const unreachable = {
|
|
448
|
+
kind: 'failed',
|
|
449
|
+
unreachable: true,
|
|
450
|
+
error: `Could not reach device "${label}" at ${request.device.host}`,
|
|
451
|
+
details: 'Check that the device is powered on and reachable on your network. ' +
|
|
452
|
+
'If the device IP changed (e.g. after a factory reset), run: ff-cli setup',
|
|
453
|
+
};
|
|
454
|
+
logger.debug(`LAN failure: ${lanError.message}`);
|
|
455
|
+
if (!request.relayerFallback) {
|
|
456
|
+
return unreachable;
|
|
457
|
+
}
|
|
458
|
+
let v2Endpoint;
|
|
459
|
+
let v2Tried = false;
|
|
460
|
+
try {
|
|
461
|
+
const relay = deps.getRelayerConfigFn();
|
|
462
|
+
v2Endpoint = relay.v2Endpoint;
|
|
463
|
+
// A derived endpoint is used only for an FF1 whose verified status
|
|
464
|
+
// advertised relay v2; otherwise a stranger's ACK on the device channel
|
|
465
|
+
// could strand a command the topic would have delivered (config.ts).
|
|
466
|
+
// The flag changes only with a verified status or a verified v2 reply
|
|
467
|
+
// (noteV2Reply), never on a failed send: one ambiguous v2 failure must
|
|
468
|
+
// not cut a v2-only FF1 off until the next LAN visit. The cost is the other way round: an FF1 that
|
|
469
|
+
// really left v2 is reached as "outcome unknown" (never resent) until
|
|
470
|
+
// its next verified status clears the flag.
|
|
471
|
+
// A registration enroll whose held topic could not be read stays off
|
|
472
|
+
// v2: the topic it may hold would have to go first (§10), and the public
|
|
473
|
+
// channel is where a forged profile is answered from anywhere.
|
|
474
|
+
if (relay.v2Endpoint &&
|
|
475
|
+
!held.heldTopicUnknown &&
|
|
476
|
+
v2Allowed(request, record, relay.v2Explicit === true)) {
|
|
477
|
+
v2Tried = true;
|
|
478
|
+
const outcome = await sendViaV2(request, record, deps, relay.v2Endpoint);
|
|
479
|
+
noteV2Reply(request, record, outcome, deps);
|
|
480
|
+
if (outcome.kind !== 'failed' || !outcome.unreachable) {
|
|
481
|
+
return outcome;
|
|
482
|
+
}
|
|
483
|
+
// Proven undelivered: no chunk reached a peer, so an optional legacy
|
|
484
|
+
// topic may carry a freshly sealed request (new ctr) instead.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
return {
|
|
489
|
+
kind: 'failed',
|
|
490
|
+
error: 'Invalid FF1 relayer configuration',
|
|
491
|
+
details: error.message,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (held.topicTried) {
|
|
495
|
+
// The held topic already answered 1003/410/404 in sendOnce.
|
|
496
|
+
return unreachable;
|
|
497
|
+
}
|
|
498
|
+
// A command that has no working topic may still try v2 even though no
|
|
499
|
+
// verified status has listed relayV2 yet. The "v2 only when listed" rule
|
|
500
|
+
// exists to avoid an ambiguous v2 send INSTEAD of a working topic; with no
|
|
501
|
+
// topic (none held, the store unreadable, or the topic leads nowhere) there
|
|
502
|
+
// is nothing left to lose, and without this a CLI paired before its FF1
|
|
503
|
+
// listed relayV2, or paired on the LAN with no topic at all, and then used
|
|
504
|
+
// off the LAN would never reach it. Enroll keeps its own rule (v2Allowed).
|
|
505
|
+
const tryV2WithoutTopic = async (noTopic) => {
|
|
506
|
+
if (request.op !== 'cmd' || v2Tried || !v2Endpoint) {
|
|
507
|
+
return noTopic;
|
|
508
|
+
}
|
|
509
|
+
const outcome = await sendViaV2(request, record, deps, v2Endpoint);
|
|
510
|
+
noteV2Reply(request, record, outcome, deps);
|
|
511
|
+
return outcome.kind !== 'failed' || !outcome.unreachable ? outcome : noTopic;
|
|
512
|
+
};
|
|
513
|
+
let topicId;
|
|
514
|
+
try {
|
|
515
|
+
topicId = deps.getTopicIdFn(request.device.id);
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
return tryV2WithoutTopic({
|
|
519
|
+
...unreachable,
|
|
520
|
+
details: `${unreachable.details} Secure topic storage is unavailable: ${error.message}`,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
if (!topicId) {
|
|
524
|
+
return tryV2WithoutTopic({
|
|
525
|
+
...unreachable,
|
|
526
|
+
details: `${unreachable.details} The relayer route needs the topic the Feral File app sends during \`ff-cli device pair\`.`,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
const viaTopic = await sendViaRelayer(request, record, deps, topicId);
|
|
530
|
+
// The topic leads nowhere (the legacy host is gone, or no device is on
|
|
531
|
+
// it): the answer proved this request undelivered, so it may go out
|
|
532
|
+
// again over v2, freshly sealed.
|
|
533
|
+
return heldTopicGone(viaTopic) ? tryV2WithoutTopic(viaTopic) : viaTopic;
|
|
534
|
+
}
|
|
535
|
+
/** The keyring could not be read: whether a topic is held is unknown. */
|
|
536
|
+
const UNKNOWN_HELD_TOPIC = Symbol('unknown held topic');
|
|
537
|
+
/**
|
|
538
|
+
* registrationHeldTopic returns the keyring topic for a §5.6 registration
|
|
539
|
+
* enroll (no `inv`, profile from a verified result), null when none is held
|
|
540
|
+
* or the request is not one, or UNKNOWN_HELD_TOPIC when the keyring could
|
|
541
|
+
* not be read. The keyring topic predates the pairing: the result's own
|
|
542
|
+
* topic is stored only after the enroll verifies (device-control.ts).
|
|
543
|
+
*
|
|
544
|
+
* An unreadable keyring is not proof that no topic is held, so such an
|
|
545
|
+
* enroll never goes over relay v2 (fallbackOrUnreachable). The LAN stays
|
|
546
|
+
* open: Linux without a secret service can never store a topic, pairing
|
|
547
|
+
* there is supported (storeRelayerTopic), and the LAN leg is what a pairing
|
|
548
|
+
* had before relay v2.
|
|
549
|
+
*/
|
|
550
|
+
function registrationHeldTopic(request, record, deps) {
|
|
551
|
+
if (request.op !== 'enroll' ||
|
|
552
|
+
request.enroll?.inv ||
|
|
553
|
+
record.registration === undefined ||
|
|
554
|
+
!request.relayerFallback) {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
return deps.getTopicIdFn(request.device.id) || null;
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
logger.debug(`Secure topic storage is unavailable: ${error.message}`);
|
|
562
|
+
return UNKNOWN_HELD_TOPIC;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* noteV2Reply records relay v2 for the FF1 after a reply that verified under
|
|
567
|
+
* the pinned profile arrived over v2 (an enroll's only when it succeeded):
|
|
568
|
+
* the FF1 itself answered there, which is what a status listing relayV2
|
|
569
|
+
* stands for. A newcomer paired off the LAN after legacy retirement, or a
|
|
570
|
+
* CLI whose topic died before it saw such a status, has no other way to
|
|
571
|
+
* learn it, and without it its commands would never try v2 (v2Allowed).
|
|
572
|
+
*/
|
|
573
|
+
function noteV2Reply(request, record, outcome, deps) {
|
|
574
|
+
if (outcome.kind !== 'reply' || (request.op === 'enroll' && !outcome.reply.ok)) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (record.relayV2 === true) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
record.relayV2 = true;
|
|
581
|
+
try {
|
|
582
|
+
(0, controller_store_1.recordRelayV2)(request.device.id, true, deps.controllerStore);
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
logger.debug(`Could not record the FF1's relay transports: ${error.message}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/** RELAYER_NO_DEVICE_SOCKET is ff-relayer's `/api/cast` error id for "no device on this topic". */
|
|
589
|
+
const RELAYER_NO_DEVICE_SOCKET = 1003;
|
|
590
|
+
/** ff-relayer's 410 error strings for a retired legacy route. */
|
|
591
|
+
const RELAYER_LEGACY_RETIRED = new Set(['legacy_controller_api_retired', 'legacy_api_retired']);
|
|
592
|
+
/**
|
|
593
|
+
* heldTopicGone reports whether a topic cast failed with the relayer's own
|
|
594
|
+
* answer that the topic leads nowhere: 1003 (no device socket on it), 410
|
|
595
|
+
* (the legacy route is retired) or 404 (the legacy host is shut down: a
|
|
596
|
+
* deleted workers.dev worker answers `404 error code: 1042`, while a live
|
|
597
|
+
* relayer never 404s `/api/cast`). All are given over TLS before anything
|
|
598
|
+
* reaches a device. Anything else (no answer, a timeout, a 5xx, a DNS
|
|
599
|
+
* failure) is not evidence: someone able to cut this machine's internet or
|
|
600
|
+
* spoof its DNS could force it. A legacy host whose zone is removed answers
|
|
601
|
+
* NXDOMAIN and so is never read as gone; a CLI release with the legacy
|
|
602
|
+
* relayer off covers that case.
|
|
603
|
+
*/
|
|
604
|
+
function heldTopicGone(outcome) {
|
|
605
|
+
if (outcome.kind !== 'failed' || outcome.relayerStatus === undefined) {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
if (outcome.relayerStatus === 404) {
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
let body;
|
|
612
|
+
try {
|
|
613
|
+
body = JSON.parse(outcome.details ?? '');
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
const error = body?.error;
|
|
619
|
+
if (outcome.relayerStatus === 410) {
|
|
620
|
+
return typeof error === 'string' && RELAYER_LEGACY_RETIRED.has(error);
|
|
621
|
+
}
|
|
622
|
+
return (typeof error === 'object' &&
|
|
623
|
+
error !== null &&
|
|
624
|
+
error.id === RELAYER_NO_DEVICE_SOCKET);
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* v2Allowed is the §10 route rule for relay v2. A command goes there on an
|
|
628
|
+
* explicit endpoint or to an FF1 whose verified status advertised relayV2.
|
|
629
|
+
* An enroll goes there only without `inv` and only when the profile was
|
|
630
|
+
* pinned from an owner's verified §5.6 registration result (record.registration):
|
|
631
|
+
* that result is sealed under `ps`, so the profile is the owner's verified view
|
|
632
|
+
* of the FF1, and a stranger on the public device channel cannot answer under
|
|
633
|
+
* it. Such an enroll is tried on v2 even before any status has advertised
|
|
634
|
+
* relayV2, since a newcomer has never read one; an ambiguous outcome is safe
|
|
635
|
+
* because enroll is idempotent (§5.3). A first-owner enroll with `inv` never
|
|
636
|
+
* goes over v2 (the FF1 refuses it there, §5.1).
|
|
637
|
+
*/
|
|
638
|
+
function v2Allowed(request, record, explicit) {
|
|
639
|
+
if (request.op === 'cmd') {
|
|
640
|
+
return explicit || record.relayV2 === true;
|
|
641
|
+
}
|
|
642
|
+
return request.op === 'enroll' && !request.enroll?.inv && record.registration !== undefined;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* sendViaV2 carries one freshly sealed command on the relay v2 device channel
|
|
646
|
+
* (§10). The relay client fans the first envelope out and accepts only a reply
|
|
647
|
+
* that verifies under the pinned profile, so neither a stranger's reply nor a
|
|
648
|
+
* receipt can answer it. It is not followed by a status probe: notifications
|
|
649
|
+
* are not consumed here, and a probe would cost a ctr and a round trip.
|
|
650
|
+
*
|
|
651
|
+
* Failures keep the §14.4 split: RelayUndeliveredError (no chunk reached a
|
|
652
|
+
* peer) is reported `unreachable` so the caller may use the legacy topic;
|
|
653
|
+
* anything else may have run on the FF1 and is never resent.
|
|
654
|
+
*/
|
|
655
|
+
async function sendViaV2(request, record, deps, endpoint) {
|
|
656
|
+
let client;
|
|
657
|
+
try {
|
|
658
|
+
client = deps.relayClientFn(endpoint, record.profile.dev);
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
return { kind: 'failed', unreachable: true, error: error.message };
|
|
662
|
+
}
|
|
663
|
+
// Outcomes by the reply object that produced them: two replies may verify
|
|
664
|
+
// (an FF1 answering two copies), and only the one that settled the request
|
|
665
|
+
// is the answer.
|
|
666
|
+
const verified = new WeakMap();
|
|
667
|
+
try {
|
|
668
|
+
const settled = await client.requestPrepared(async () => {
|
|
669
|
+
const sealed = await sealNext(request, deps);
|
|
670
|
+
return {
|
|
671
|
+
command: 'ff1Envelope',
|
|
672
|
+
request: { v: 1, jwe: sealed.compact },
|
|
673
|
+
verify: async (body) => {
|
|
674
|
+
const answer = findEnvelopeAnswer(body);
|
|
675
|
+
if (!answer?.jwe) {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
const outcome = await openAnswer(answer, sealed, record, 'relayer');
|
|
679
|
+
if (outcome.kind !== 'reply') {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
verified.set(body, outcome);
|
|
683
|
+
return true;
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
}, deps.v2TimeoutMs ?? v2Deadline(request));
|
|
687
|
+
return v2Answer(request, verified.get(settled));
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
return v2Failure(request, error);
|
|
691
|
+
}
|
|
692
|
+
finally {
|
|
693
|
+
client.close();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* v2Answer passes a verified v2 reply through, except `replay` (§14.4): on v2
|
|
698
|
+
* the same sealed bytes may have reached the FF1 twice (a fan-out plus a copy
|
|
699
|
+
* to a late joiner), so a `replay` may answer the duplicate of a request that
|
|
700
|
+
* ran. It is an unknown outcome and never moves the counter or resends.
|
|
701
|
+
*/
|
|
702
|
+
function v2Answer(request, outcome) {
|
|
703
|
+
if (outcome.kind === 'reply' && !outcome.reply.ok && outcome.reply.code === 'replay') {
|
|
704
|
+
return v2Failure(request, new relay_client_1.RelayAmbiguousError('the FF1 answered replay; it may have received this request twice'));
|
|
705
|
+
}
|
|
706
|
+
return outcome;
|
|
707
|
+
}
|
|
708
|
+
/** v2Deadline gives a large command time to cross the relay's byte ceiling. */
|
|
709
|
+
function v2Deadline(request) {
|
|
710
|
+
const size = JSON.stringify(request.req ?? {}).length;
|
|
711
|
+
return V2_TIMEOUT_MS + (size > 32768 ? Math.ceil(size / 24000) * 1000 : 0);
|
|
712
|
+
}
|
|
713
|
+
/** v2Failure maps a typed relay error to an outcome (§10, §14.4). */
|
|
714
|
+
function v2Failure(request, error) {
|
|
715
|
+
if (error instanceof relay_client_1.RelayUndeliveredError) {
|
|
716
|
+
logger.debug(`Relay v2 did not deliver the command: ${error.message}`);
|
|
717
|
+
return {
|
|
718
|
+
kind: 'failed',
|
|
719
|
+
unreachable: true,
|
|
720
|
+
error: 'The FF1 relay did not deliver the command',
|
|
721
|
+
details: error.message,
|
|
722
|
+
transport: 'relayer',
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
const label = request.device.name || request.device.id;
|
|
726
|
+
return {
|
|
727
|
+
kind: 'failed',
|
|
728
|
+
error: `Outcome unknown: the relay result from "${label}" was lost (${error.message})`,
|
|
729
|
+
details: 'The FF1 may already have run it, so ff-cli did not send it again. Check the FF1, then run the command again if it did not take effect.',
|
|
730
|
+
transport: 'relayer',
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
/** sendViaRelayer seals a NEW request (new ctr) and casts it on the topic. */
|
|
734
|
+
async function sendViaRelayer(request, record, deps, topicId) {
|
|
735
|
+
deps.noticeFn(`Notice: LAN device is unreachable; falling back to the FF1 relayer for ${request.device.name ?? request.device.id}.`);
|
|
736
|
+
let relayer;
|
|
737
|
+
try {
|
|
738
|
+
relayer = deps.getRelayerConfigFn();
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
return {
|
|
742
|
+
kind: 'failed',
|
|
743
|
+
error: 'Invalid FF1 relayer configuration',
|
|
744
|
+
details: error.message,
|
|
745
|
+
transport: 'relayer',
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
let sealed;
|
|
749
|
+
try {
|
|
750
|
+
sealed = await sealNext(request, deps);
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
return { kind: 'failed', error: error.message, transport: 'relayer' };
|
|
754
|
+
}
|
|
755
|
+
const result = await (0, ff1_relayer_1.sendCastViaRelayer)({
|
|
756
|
+
...relayer,
|
|
757
|
+
topicId,
|
|
758
|
+
body: (0, codec_1.envelopeCastBody)(sealed.compact),
|
|
759
|
+
fetchFn: deps.fetchFn,
|
|
760
|
+
});
|
|
761
|
+
if (!result.success) {
|
|
762
|
+
return {
|
|
763
|
+
kind: 'failed',
|
|
764
|
+
error: result.error,
|
|
765
|
+
details: result.details,
|
|
766
|
+
transport: 'relayer',
|
|
767
|
+
...(result.status !== undefined ? { relayerStatus: result.status } : {}),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
const answer = findEnvelopeAnswer(result.response);
|
|
771
|
+
if (!answer) {
|
|
772
|
+
return {
|
|
773
|
+
kind: 'failed',
|
|
774
|
+
error: 'The FF1 relayer returned no secure reply',
|
|
775
|
+
details: JSON.stringify(result.response).slice(0, 500),
|
|
776
|
+
transport: 'relayer',
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
return openAnswer(answer, sealed, record, 'relayer');
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* outcomeToResult maps an outcome onto a command result with actionable copy.
|
|
783
|
+
*
|
|
784
|
+
* @param outcome - Secure outcome
|
|
785
|
+
* @param device - Device, for the pair command in messages
|
|
786
|
+
* @returns Command result
|
|
787
|
+
*/
|
|
788
|
+
function outcomeToResult(outcome, device) {
|
|
789
|
+
const label = device.name || device.id || device.host;
|
|
790
|
+
if (outcome.kind === 'failed') {
|
|
791
|
+
return {
|
|
792
|
+
success: false,
|
|
793
|
+
error: outcome.error,
|
|
794
|
+
details: outcome.details,
|
|
795
|
+
transport: outcome.transport,
|
|
796
|
+
code: outcome.code,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
const code = outcome.kind === 'public' ? outcome.code : outcome.reply.code;
|
|
800
|
+
if (outcome.kind === 'reply' && outcome.reply.ok) {
|
|
801
|
+
const res = outcome.reply.res;
|
|
802
|
+
return {
|
|
803
|
+
success: true,
|
|
804
|
+
response: res && typeof res === 'object' ? res : {},
|
|
805
|
+
transport: outcome.transport,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
if (code === 'command_failed' && outcome.kind === 'reply') {
|
|
809
|
+
// §4.3: res is the handler's error body, {ok:false,error,command,message}
|
|
810
|
+
// for routed commands and {error:<reason>} for the owner commands.
|
|
811
|
+
const res = outcome.reply.res;
|
|
812
|
+
return {
|
|
813
|
+
success: false,
|
|
814
|
+
error: `Device "${label}" rejected the command`,
|
|
815
|
+
details: typeof res?.message === 'string' ? res.message : JSON.stringify(res ?? {}),
|
|
816
|
+
transport: outcome.transport,
|
|
817
|
+
code,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
const failure = outcome.kind === 'reply' && outcome.replayRetried
|
|
821
|
+
? (0, control_errors_1.replayAfterJumpFailure)(label)
|
|
822
|
+
: (0, control_errors_1.describeControlCode)(code ?? 'unknown', label);
|
|
823
|
+
return {
|
|
824
|
+
success: false,
|
|
825
|
+
error: failure.error,
|
|
826
|
+
details: failure.details,
|
|
827
|
+
transport: outcome.transport,
|
|
828
|
+
code,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* runSecureCommand sends `op:cmd` for an enrolled controller and maps the
|
|
833
|
+
* outcome. It is the one entry point the three device commands use.
|
|
834
|
+
*
|
|
835
|
+
* @param input - Device, record, command and routing
|
|
836
|
+
* @param overrides - Test seams
|
|
837
|
+
* @returns Command result
|
|
838
|
+
*/
|
|
839
|
+
async function runSecureCommand(input, overrides = {}) {
|
|
840
|
+
logger.debug(`Sending ${input.cmd} as controller ${(0, codec_1.controllerIdOf)(input.record.keys)} (secure envelope)`);
|
|
841
|
+
const outcome = await sendSecureRequest({
|
|
842
|
+
device: input.device,
|
|
843
|
+
op: 'cmd',
|
|
844
|
+
cmd: input.cmd,
|
|
845
|
+
req: input.req,
|
|
846
|
+
relayerFallback: input.relayerFallback,
|
|
847
|
+
retry: input.retry,
|
|
848
|
+
}, input.record, overrides);
|
|
849
|
+
return outcomeToResult(outcome, input.device);
|
|
850
|
+
}
|