@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,376 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Strict JSON and base64url decoding for the FF1 control protocol v1
|
|
4
|
+
* (ffos-user docs/ff1-control-v1.md §1).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists instead of `JSON.parse`: two implementations that disagree
|
|
7
|
+
* about what a byte string means (which of two duplicate `kid` members wins,
|
|
8
|
+
* whether `1.0` is the integer 1) are a signature-confusion bug. `JSON.parse`
|
|
9
|
+
* silently keeps the LAST duplicate and erases the difference between `1`,
|
|
10
|
+
* `1.0` and `1e0`, so every header, claims object and JWK that crosses the
|
|
11
|
+
* trust boundary is parsed here instead. The Go reference (control/json.go)
|
|
12
|
+
* applies the same rules; keep the two in step.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.ControlFormatError = void 0;
|
|
16
|
+
exports.b64uDecode = b64uDecode;
|
|
17
|
+
exports.b64uEncode = b64uEncode;
|
|
18
|
+
exports.isB64uOfLength = isB64uOfLength;
|
|
19
|
+
exports.utf8Decode = utf8Decode;
|
|
20
|
+
exports.parseStrictJson = parseStrictJson;
|
|
21
|
+
exports.isPlainObject = isPlainObject;
|
|
22
|
+
exports.strictObject = strictObject;
|
|
23
|
+
exports.stringMember = stringMember;
|
|
24
|
+
exports.uintMember = uintMember;
|
|
25
|
+
exports.boolMember = boolMember;
|
|
26
|
+
exports.toPlainJson = toPlainJson;
|
|
27
|
+
/** ControlFormatError is thrown for every malformed protocol object. */
|
|
28
|
+
class ControlFormatError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(`ff1-control: ${message}`);
|
|
31
|
+
this.name = 'ControlFormatError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
exports.ControlFormatError = ControlFormatError;
|
|
35
|
+
/** MAX_SAFE_CLAIM_INT is 2^53-1, the integer ceiling of §1. */
|
|
36
|
+
const MAX_SAFE_CLAIM_INT = Number.MAX_SAFE_INTEGER;
|
|
37
|
+
/** MAX_JSON_DEPTH bounds nesting, matching the Go reference's maxJSONDepth. */
|
|
38
|
+
const MAX_JSON_DEPTH = 128;
|
|
39
|
+
/**
|
|
40
|
+
* rawNumbers remembers the exact source text of every number member of every
|
|
41
|
+
* parsed object, so integer claims can be checked for canonical form after
|
|
42
|
+
* parsing (JSON.parse-style values cannot tell `1` from `1.0`).
|
|
43
|
+
*/
|
|
44
|
+
const rawNumbers = new WeakMap();
|
|
45
|
+
const B64U_ALPHABET = /^[A-Za-z0-9_-]*$/;
|
|
46
|
+
/**
|
|
47
|
+
* b64uDecode decodes unpadded base64url strictly: `=` padding, whitespace,
|
|
48
|
+
* any non-alphabet byte and non-zero trailing bits are all rejected (§1). The
|
|
49
|
+
* round-trip comparison is what catches non-canonical trailing bits, which
|
|
50
|
+
* Node's lenient decoder would otherwise accept.
|
|
51
|
+
*
|
|
52
|
+
* @param value - Candidate base64url text
|
|
53
|
+
* @returns The decoded bytes
|
|
54
|
+
* @throws {ControlFormatError} When the text is not canonical base64url
|
|
55
|
+
*/
|
|
56
|
+
function b64uDecode(value) {
|
|
57
|
+
if (typeof value !== 'string' || !B64U_ALPHABET.test(value) || value.length % 4 === 1) {
|
|
58
|
+
throw new ControlFormatError('invalid base64url');
|
|
59
|
+
}
|
|
60
|
+
const bytes = Buffer.from(value, 'base64url');
|
|
61
|
+
if (bytes.toString('base64url') !== value) {
|
|
62
|
+
throw new ControlFormatError('non-canonical base64url');
|
|
63
|
+
}
|
|
64
|
+
return new Uint8Array(bytes);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* b64uEncode encodes bytes as unpadded base64url.
|
|
68
|
+
*
|
|
69
|
+
* @param bytes - Bytes or UTF-8 text to encode
|
|
70
|
+
* @returns Unpadded base64url text
|
|
71
|
+
*/
|
|
72
|
+
function b64uEncode(bytes) {
|
|
73
|
+
return Buffer.from(bytes).toString('base64url');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* isB64uOfLength reports whether value is canonical base64url of exactly n bytes.
|
|
77
|
+
*
|
|
78
|
+
* @param value - Candidate value
|
|
79
|
+
* @param n - Required decoded length
|
|
80
|
+
* @returns True when the value decodes to exactly n bytes
|
|
81
|
+
*/
|
|
82
|
+
function isB64uOfLength(value, n) {
|
|
83
|
+
if (typeof value !== 'string') {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return b64uDecode(value).length === n;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* utf8Decode decodes bytes as UTF-8, rejecting invalid sequences (§1). A
|
|
95
|
+
* lenient decoder would map them to U+FFFD and let two different byte strings
|
|
96
|
+
* read as the same JSON.
|
|
97
|
+
*
|
|
98
|
+
* @param bytes - Raw bytes
|
|
99
|
+
* @returns The decoded string
|
|
100
|
+
* @throws {ControlFormatError} On invalid UTF-8
|
|
101
|
+
*/
|
|
102
|
+
function utf8Decode(bytes) {
|
|
103
|
+
try {
|
|
104
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new ControlFormatError('invalid UTF-8');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** StrictParser is a small recursive-descent JSON parser with duplicate-key detection. */
|
|
111
|
+
class StrictParser {
|
|
112
|
+
text;
|
|
113
|
+
pos = 0;
|
|
114
|
+
constructor(text) {
|
|
115
|
+
this.text = text;
|
|
116
|
+
}
|
|
117
|
+
parseDocument() {
|
|
118
|
+
const value = this.parseValue(0);
|
|
119
|
+
this.skipWhitespace();
|
|
120
|
+
if (this.pos !== this.text.length) {
|
|
121
|
+
throw new ControlFormatError('trailing data after JSON value');
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
fail(message) {
|
|
126
|
+
throw new ControlFormatError(`invalid JSON at ${this.pos}: ${message}`);
|
|
127
|
+
}
|
|
128
|
+
skipWhitespace() {
|
|
129
|
+
while (this.pos < this.text.length && ' \t\r\n'.includes(this.text[this.pos])) {
|
|
130
|
+
this.pos += 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
parseValue(depth) {
|
|
134
|
+
if (depth > MAX_JSON_DEPTH) {
|
|
135
|
+
this.fail('nested too deeply');
|
|
136
|
+
}
|
|
137
|
+
this.skipWhitespace();
|
|
138
|
+
const ch = this.text[this.pos];
|
|
139
|
+
if (ch === '{') {
|
|
140
|
+
return this.parseObject(depth);
|
|
141
|
+
}
|
|
142
|
+
if (ch === '[') {
|
|
143
|
+
return this.parseArray(depth);
|
|
144
|
+
}
|
|
145
|
+
if (ch === '"') {
|
|
146
|
+
return this.parseString();
|
|
147
|
+
}
|
|
148
|
+
if (ch === '-' || (ch >= '0' && ch <= '9')) {
|
|
149
|
+
return Number(this.parseNumberText());
|
|
150
|
+
}
|
|
151
|
+
for (const [literal, value] of [
|
|
152
|
+
['true', true],
|
|
153
|
+
['false', false],
|
|
154
|
+
['null', null],
|
|
155
|
+
]) {
|
|
156
|
+
if (this.text.startsWith(literal, this.pos)) {
|
|
157
|
+
this.pos += literal.length;
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return this.fail('unexpected token');
|
|
162
|
+
}
|
|
163
|
+
parseObject(depth) {
|
|
164
|
+
this.pos += 1;
|
|
165
|
+
// A null-prototype object keeps a member named "__proto__" an ordinary
|
|
166
|
+
// member instead of a prototype assignment.
|
|
167
|
+
const out = Object.create(null);
|
|
168
|
+
const numbers = new Map();
|
|
169
|
+
this.skipWhitespace();
|
|
170
|
+
if (this.text[this.pos] === '}') {
|
|
171
|
+
this.pos += 1;
|
|
172
|
+
rawNumbers.set(out, numbers);
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
for (;;) {
|
|
176
|
+
this.skipWhitespace();
|
|
177
|
+
if (this.text[this.pos] !== '"') {
|
|
178
|
+
this.fail('object key is not a string');
|
|
179
|
+
}
|
|
180
|
+
// Keys are compared after unescaping, so "typ" and "typ" collide.
|
|
181
|
+
const key = this.parseString();
|
|
182
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) {
|
|
183
|
+
this.fail(`duplicate member ${JSON.stringify(key)}`);
|
|
184
|
+
}
|
|
185
|
+
this.skipWhitespace();
|
|
186
|
+
if (this.text[this.pos] !== ':') {
|
|
187
|
+
this.fail('expected ":"');
|
|
188
|
+
}
|
|
189
|
+
this.pos += 1;
|
|
190
|
+
this.skipWhitespace();
|
|
191
|
+
const start = this.pos;
|
|
192
|
+
out[key] = this.parseValue(depth + 1);
|
|
193
|
+
if (typeof out[key] === 'number') {
|
|
194
|
+
numbers.set(key, this.text.slice(start, this.pos));
|
|
195
|
+
}
|
|
196
|
+
this.skipWhitespace();
|
|
197
|
+
const sep = this.text[this.pos];
|
|
198
|
+
this.pos += 1;
|
|
199
|
+
if (sep === '}') {
|
|
200
|
+
rawNumbers.set(out, numbers);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
if (sep !== ',') {
|
|
204
|
+
this.fail('expected "," or "}"');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
parseArray(depth) {
|
|
209
|
+
this.pos += 1;
|
|
210
|
+
const out = [];
|
|
211
|
+
this.skipWhitespace();
|
|
212
|
+
if (this.text[this.pos] === ']') {
|
|
213
|
+
this.pos += 1;
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
for (;;) {
|
|
217
|
+
out.push(this.parseValue(depth + 1));
|
|
218
|
+
this.skipWhitespace();
|
|
219
|
+
const sep = this.text[this.pos];
|
|
220
|
+
this.pos += 1;
|
|
221
|
+
if (sep === ']') {
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
if (sep !== ',') {
|
|
225
|
+
this.fail('expected "," or "]"');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
parseString() {
|
|
230
|
+
const start = this.pos;
|
|
231
|
+
this.pos += 1;
|
|
232
|
+
for (;;) {
|
|
233
|
+
const ch = this.text[this.pos];
|
|
234
|
+
if (ch === undefined) {
|
|
235
|
+
this.fail('unterminated string');
|
|
236
|
+
}
|
|
237
|
+
if (ch === '"') {
|
|
238
|
+
this.pos += 1;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
if (ch === '\\') {
|
|
242
|
+
this.pos += 2;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (ch < ' ') {
|
|
246
|
+
this.fail('control character in string');
|
|
247
|
+
}
|
|
248
|
+
this.pos += 1;
|
|
249
|
+
}
|
|
250
|
+
// The span is grammar-checked by JSON.parse, which also unescapes it.
|
|
251
|
+
try {
|
|
252
|
+
return JSON.parse(this.text.slice(start, this.pos));
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return this.fail('invalid string escape');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
parseNumberText() {
|
|
259
|
+
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(this.text.slice(this.pos));
|
|
260
|
+
if (!match) {
|
|
261
|
+
this.fail('invalid number');
|
|
262
|
+
}
|
|
263
|
+
this.pos += match[0].length;
|
|
264
|
+
return match[0];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* parseStrictJson parses UTF-8 JSON text with the §1 rules: one value, no
|
|
269
|
+
* trailing data, no duplicate member names at any depth.
|
|
270
|
+
*
|
|
271
|
+
* @param input - JSON text or raw UTF-8 bytes
|
|
272
|
+
* @returns The parsed value; objects have a null prototype
|
|
273
|
+
* @throws {ControlFormatError} On any violation
|
|
274
|
+
*/
|
|
275
|
+
function parseStrictJson(input) {
|
|
276
|
+
const text = typeof input === 'string' ? input : utf8Decode(input);
|
|
277
|
+
return new StrictParser(text).parseDocument();
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* isPlainObject reports whether value is a parsed JSON object (not an array or null).
|
|
281
|
+
*
|
|
282
|
+
* @param value - Candidate value
|
|
283
|
+
* @returns True for JSON objects
|
|
284
|
+
*/
|
|
285
|
+
function isPlainObject(value) {
|
|
286
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* strictObject requires value to be an object whose member names are all in
|
|
290
|
+
* required ∪ optional, with every required member present (§1 closed sets).
|
|
291
|
+
*
|
|
292
|
+
* @param value - Parsed JSON value
|
|
293
|
+
* @param required - Members that must be present
|
|
294
|
+
* @param optional - Members that may be present
|
|
295
|
+
* @param what - Name used in error messages
|
|
296
|
+
* @returns The object, typed as a record
|
|
297
|
+
* @throws {ControlFormatError} On any extra or missing member
|
|
298
|
+
*/
|
|
299
|
+
function strictObject(value, required, optional, what) {
|
|
300
|
+
if (!isPlainObject(value)) {
|
|
301
|
+
throw new ControlFormatError(`${what} is not a JSON object`);
|
|
302
|
+
}
|
|
303
|
+
for (const key of Object.keys(value)) {
|
|
304
|
+
if (!required.includes(key) && !optional.includes(key)) {
|
|
305
|
+
throw new ControlFormatError(`${what} has unexpected member ${JSON.stringify(key)}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const key of required) {
|
|
309
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
|
310
|
+
throw new ControlFormatError(`${what} is missing member ${JSON.stringify(key)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* stringMember returns a required string member.
|
|
317
|
+
*
|
|
318
|
+
* @param obj - Parsed object
|
|
319
|
+
* @param key - Member name
|
|
320
|
+
* @returns The string value
|
|
321
|
+
* @throws {ControlFormatError} When the member is absent or not a string
|
|
322
|
+
*/
|
|
323
|
+
function stringMember(obj, key) {
|
|
324
|
+
const value = obj[key];
|
|
325
|
+
if (typeof value !== 'string') {
|
|
326
|
+
throw new ControlFormatError(`member ${JSON.stringify(key)} is not a string`);
|
|
327
|
+
}
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* uintMember returns a canonical non-negative integer member in [0, 2^53-1].
|
|
332
|
+
* `1.0`, `1e3`, `-0`, `01` and quoted numbers are rejected (§1): the Dart,
|
|
333
|
+
* JS and Go encoders never produce them, so seeing one means a hand-built or
|
|
334
|
+
* hostile object. Only objects produced by parseStrictJson can be checked.
|
|
335
|
+
*
|
|
336
|
+
* @param obj - Object returned by parseStrictJson
|
|
337
|
+
* @param key - Member name
|
|
338
|
+
* @returns The integer value
|
|
339
|
+
* @throws {ControlFormatError} When absent, non-canonical or out of range
|
|
340
|
+
*/
|
|
341
|
+
function uintMember(obj, key) {
|
|
342
|
+
const raw = rawNumbers.get(obj)?.get(key);
|
|
343
|
+
if (raw === undefined || !/^(?:0|[1-9]\d*)$/.test(raw)) {
|
|
344
|
+
throw new ControlFormatError(`member ${JSON.stringify(key)} is not a canonical integer`);
|
|
345
|
+
}
|
|
346
|
+
const value = Number(raw);
|
|
347
|
+
if (!Number.isSafeInteger(value) || value > MAX_SAFE_CLAIM_INT) {
|
|
348
|
+
throw new ControlFormatError(`member ${JSON.stringify(key)} is out of range`);
|
|
349
|
+
}
|
|
350
|
+
return value;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* boolMember returns a required boolean member.
|
|
354
|
+
*
|
|
355
|
+
* @param obj - Parsed object
|
|
356
|
+
* @param key - Member name
|
|
357
|
+
* @returns The boolean value
|
|
358
|
+
* @throws {ControlFormatError} When absent or not a boolean
|
|
359
|
+
*/
|
|
360
|
+
function boolMember(obj, key) {
|
|
361
|
+
const value = obj[key];
|
|
362
|
+
if (typeof value !== 'boolean') {
|
|
363
|
+
throw new ControlFormatError(`member ${JSON.stringify(key)} is not a boolean`);
|
|
364
|
+
}
|
|
365
|
+
return value;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* toPlainJson deep-copies a parsed value onto ordinary prototypes so callers
|
|
369
|
+
* outside the codec (printing, deepEqual in tests) see normal objects.
|
|
370
|
+
*
|
|
371
|
+
* @param value - Value from parseStrictJson
|
|
372
|
+
* @returns Equivalent value with ordinary object prototypes
|
|
373
|
+
*/
|
|
374
|
+
function toPlainJson(value) {
|
|
375
|
+
return JSON.parse(JSON.stringify(value));
|
|
376
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Network classification shared by the FF1 LAN delivery paths (legacy
|
|
4
|
+
* plaintext casts in ff1-device.ts and the secure envelope transport in
|
|
5
|
+
* control/secure-transport.ts), so both apply the same retry and relayer
|
|
6
|
+
* fallback rules.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
|
|
10
|
+
exports.isConnectPhaseNetworkError = isConnectPhaseNetworkError;
|
|
11
|
+
exports.parseRetryAfterMs = parseRetryAfterMs;
|
|
12
|
+
/**
|
|
13
|
+
* isTransientDeviceNetworkError returns true when a send failure is likely temporary.
|
|
14
|
+
*
|
|
15
|
+
* This classifier intentionally targets resolver and route-level failures that are
|
|
16
|
+
* common on local mDNS/Wi-Fi environments. Permanent command errors should surface
|
|
17
|
+
* immediately without retry loops.
|
|
18
|
+
*
|
|
19
|
+
* @param {unknown} error - Error thrown by fetch
|
|
20
|
+
* @returns {boolean} True when retrying is likely to recover
|
|
21
|
+
*/
|
|
22
|
+
function isTransientDeviceNetworkError(error) {
|
|
23
|
+
if (!(error instanceof Error)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const networkCodes = new Set([
|
|
27
|
+
'EHOSTUNREACH',
|
|
28
|
+
'ENETUNREACH',
|
|
29
|
+
'ENOTFOUND',
|
|
30
|
+
'EAI_AGAIN',
|
|
31
|
+
'ETIMEDOUT',
|
|
32
|
+
'ECONNREFUSED',
|
|
33
|
+
'ECONNRESET',
|
|
34
|
+
]);
|
|
35
|
+
const message = error.message || '';
|
|
36
|
+
const messageLooksTransient = message.includes('fetch failed') ||
|
|
37
|
+
message.includes('getaddrinfo') ||
|
|
38
|
+
message.includes('EHOSTUNREACH') ||
|
|
39
|
+
message.includes('ENOTFOUND') ||
|
|
40
|
+
message.includes('ETIMEDOUT') ||
|
|
41
|
+
message.includes('network timeout') ||
|
|
42
|
+
message.includes('No route to host');
|
|
43
|
+
if (messageLooksTransient) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
const causeCode = error.cause &&
|
|
47
|
+
typeof error.cause === 'object' &&
|
|
48
|
+
'code' in error.cause &&
|
|
49
|
+
typeof error.cause.code === 'string'
|
|
50
|
+
? error.cause.code
|
|
51
|
+
: undefined;
|
|
52
|
+
return Boolean(causeCode && networkCodes.has(causeCode));
|
|
53
|
+
}
|
|
54
|
+
// Codes that can only come from opening the connection (resolving the name,
|
|
55
|
+
// routing, or a refused/timed-out TCP connect): the request bytes were never
|
|
56
|
+
// written, so the FF1 cannot have run it.
|
|
57
|
+
const CONNECT_PHASE_CODES = new Set([
|
|
58
|
+
'ECONNREFUSED',
|
|
59
|
+
'EHOSTUNREACH',
|
|
60
|
+
'ENETUNREACH',
|
|
61
|
+
'ENOTFOUND',
|
|
62
|
+
'EAI_AGAIN',
|
|
63
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
64
|
+
]);
|
|
65
|
+
// Codes that prove the same only when the failing call was the TCP connect.
|
|
66
|
+
// macOS answers a connect to an absent LAN host with EHOSTDOWN (no ARP reply),
|
|
67
|
+
// where Linux says EHOSTUNREACH.
|
|
68
|
+
const CONNECT_SYSCALL_CODES = new Set(['ETIMEDOUT', 'EHOSTDOWN', 'ENETDOWN', 'EADDRNOTAVAIL']);
|
|
69
|
+
/**
|
|
70
|
+
* isConnectPhaseNetworkError reports a send failure that proves the request
|
|
71
|
+
* never reached the FF1, so sending it again (even on another route, with a new
|
|
72
|
+
* counter) cannot run it twice. `fetch failed` on its own, ECONNRESET, a closed
|
|
73
|
+
* socket or a response timeout may follow a delivered request, and are not
|
|
74
|
+
* connect-phase (feral-file#3551 spec §14.4).
|
|
75
|
+
*
|
|
76
|
+
* @param {unknown} error - Error thrown by fetch
|
|
77
|
+
* @returns {boolean} True when the failure happened before any request byte was sent
|
|
78
|
+
*/
|
|
79
|
+
function isConnectPhaseNetworkError(error) {
|
|
80
|
+
if (!(error instanceof Error)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const causes = [error, error.cause];
|
|
84
|
+
const cause = error.cause;
|
|
85
|
+
if (cause && Array.isArray(cause.errors)) {
|
|
86
|
+
// Happy-eyeballs connects report one error per address.
|
|
87
|
+
causes.push(...cause.errors);
|
|
88
|
+
}
|
|
89
|
+
return causes.some((candidate) => {
|
|
90
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const { code, syscall } = candidate;
|
|
94
|
+
if (typeof code !== 'string') {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return (CONNECT_PHASE_CODES.has(code) || (syscall === 'connect' && CONNECT_SYSCALL_CODES.has(code)));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* parseRetryAfterMs converts an HTTP `Retry-After` header into milliseconds.
|
|
102
|
+
*
|
|
103
|
+
* Supports both forms from RFC 7231: a delta-seconds integer (e.g. "5") and an
|
|
104
|
+
* HTTP-date (e.g. "Wed, 21 Oct 2025 07:28:00 GMT"). Returns undefined when the
|
|
105
|
+
* header is absent or unparseable, so callers can fall back to their own
|
|
106
|
+
* backoff. A past date clamps to 0 (retry immediately).
|
|
107
|
+
*
|
|
108
|
+
* @param {string | null | undefined} headerValue - Raw Retry-After header value
|
|
109
|
+
* @returns {number | undefined} Delay in milliseconds, or undefined if unknown
|
|
110
|
+
*/
|
|
111
|
+
function parseRetryAfterMs(headerValue) {
|
|
112
|
+
if (!headerValue) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const trimmed = headerValue.trim();
|
|
116
|
+
if (/^\d+$/.test(trimmed)) {
|
|
117
|
+
return Number(trimmed) * 1000;
|
|
118
|
+
}
|
|
119
|
+
// A valid HTTP-date always contains letters (month/day names, or the ISO
|
|
120
|
+
// 'T'/'Z'). Requiring one rejects bare numeric junk (e.g. "-5", "5.5") that
|
|
121
|
+
// Date.parse would otherwise misinterpret as a date.
|
|
122
|
+
if (/[a-zA-Z]/.test(trimmed)) {
|
|
123
|
+
const dateMs = Date.parse(trimmed);
|
|
124
|
+
if (!Number.isNaN(dateMs)) {
|
|
125
|
+
return Math.max(0, dateMs - Date.now());
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
@@ -58,7 +58,7 @@ function applyPatch(existing, patch) {
|
|
|
58
58
|
* 1. Same mDNS device ID → update in-place (preserves position, handles host change).
|
|
59
59
|
* 2. Same host → update in-place (preserves position and metadata).
|
|
60
60
|
* 3. Same name, different host → replace in-place (preserves position so that
|
|
61
|
-
* devices[0] — the implicit default for
|
|
61
|
+
* devices[0] — the implicit default for display/send/ssh — does not silently change).
|
|
62
62
|
* 4. Neither match → append.
|
|
63
63
|
*/
|
|
64
64
|
function upsertDevice(existingDevices, newDevice,
|
|
@@ -42,6 +42,8 @@ const http_1 = require("./http");
|
|
|
42
42
|
const config_1 = require("../config");
|
|
43
43
|
const device_lookup_1 = require("./device-lookup");
|
|
44
44
|
const logger = __importStar(require("../logger"));
|
|
45
|
+
const secure_transport_1 = require("./control/secure-transport");
|
|
46
|
+
const control_errors_1 = require("./control/control-errors");
|
|
45
47
|
const FF1_COMMAND_POLICIES = {
|
|
46
48
|
displayPlaylist: {
|
|
47
49
|
minimumVersion: '1.0.0',
|
|
@@ -118,7 +120,12 @@ async function assertFF1CommandCompatibility(device, command, options = {}) {
|
|
|
118
120
|
// the TCP connection but never answers must not hang `ff-cli ssh`.
|
|
119
121
|
const fetchFn = options.fetchFn || ((input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}));
|
|
120
122
|
const policy = getCommandPolicy(command);
|
|
121
|
-
const versionResult =
|
|
123
|
+
const versionResult = options.controller && device.id
|
|
124
|
+
? await detectFF1VersionSecurely(device, options.controller)
|
|
125
|
+
: await detectFF1VersionSafely(device, buildVersionHeaders(device), fetchFn);
|
|
126
|
+
if (versionResult?.refusal) {
|
|
127
|
+
return { compatible: false, ...versionResult.refusal };
|
|
128
|
+
}
|
|
122
129
|
return resolveCompatibility(device, command, policy, versionResult);
|
|
123
130
|
}
|
|
124
131
|
/**
|
|
@@ -135,14 +142,14 @@ function getCommandPolicy(command) {
|
|
|
135
142
|
/**
|
|
136
143
|
* Detect FF1 version and recover compatibility when detection fails.
|
|
137
144
|
*
|
|
138
|
-
* @param {
|
|
145
|
+
* @param {FF1Device} device - Target device
|
|
139
146
|
* @param {Object} headers - Request headers
|
|
140
147
|
* @param {Function} fetchFn - Fetch implementation
|
|
141
148
|
* @returns {Promise<FF1VersionProbe | null>} Detected version metadata
|
|
142
149
|
*/
|
|
143
|
-
async function detectFF1VersionSafely(
|
|
150
|
+
async function detectFF1VersionSafely(device, headers, fetchFn) {
|
|
144
151
|
try {
|
|
145
|
-
return await detectFF1Version(
|
|
152
|
+
return await detectFF1Version(device, headers, fetchFn);
|
|
146
153
|
}
|
|
147
154
|
catch (error) {
|
|
148
155
|
logger.debug('FF1 version detection failed; continuing with command', error.message);
|
|
@@ -159,7 +166,7 @@ async function detectFF1VersionSafely(host, headers, fetchFn) {
|
|
|
159
166
|
* @returns {FF1CompatibilityResult} Compatibility decision
|
|
160
167
|
*/
|
|
161
168
|
function resolveCompatibility(device, command, policy, versionResult) {
|
|
162
|
-
if (!versionResult) {
|
|
169
|
+
if (!versionResult?.version) {
|
|
163
170
|
logger.warn(`Could not verify FF1 OS version for ${device.name || device.host}`);
|
|
164
171
|
return { compatible: true };
|
|
165
172
|
}
|
|
@@ -187,22 +194,22 @@ function resolveCompatibility(device, command, policy, versionResult) {
|
|
|
187
194
|
*
|
|
188
195
|
* Reads `message.installedVersion` from the device status response.
|
|
189
196
|
*
|
|
190
|
-
* @param {
|
|
197
|
+
* @param {FF1Device} device - Target device
|
|
191
198
|
* @param {Record<string, string>} headers - Request headers (e.g. API-KEY)
|
|
192
199
|
* @param {FetchFunction} fetchFn - Fetch implementation
|
|
193
|
-
* @returns {Promise<FF1VersionProbe | null>} Version probe or null if unavailable
|
|
200
|
+
* @returns {Promise<FF1VersionProbe | null>} Version probe, a refusal, or null if unavailable
|
|
194
201
|
* @example
|
|
195
|
-
* const probe = await detectFF1Version(
|
|
202
|
+
* const probe = await detectFF1Version(device, {}, fetch);
|
|
196
203
|
*/
|
|
197
|
-
async function detectFF1Version(
|
|
204
|
+
async function detectFF1Version(device, headers, fetchFn) {
|
|
198
205
|
try {
|
|
199
|
-
const response = await fetchFn(`${host}/api/cast`, {
|
|
206
|
+
const response = await fetchFn(`${device.host}/api/cast`, {
|
|
200
207
|
method: 'POST',
|
|
201
208
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
202
209
|
body: JSON.stringify({ command: 'getDeviceStatus', request: {} }),
|
|
203
210
|
});
|
|
204
211
|
if (!response.ok) {
|
|
205
|
-
return
|
|
212
|
+
return refusalFromPublicError(response.status, await response.text(), device.name || device.id || device.host);
|
|
206
213
|
}
|
|
207
214
|
const data = (await response.json());
|
|
208
215
|
const version = data?.message?.installedVersion;
|
|
@@ -215,6 +222,75 @@ async function detectFF1Version(host, headers, fetchFn) {
|
|
|
215
222
|
return null;
|
|
216
223
|
}
|
|
217
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* refusalFromPublicError recognizes a secured FF1 refusing a plaintext LAN
|
|
227
|
+
* cast (ff1-control-v1 §4.4, HTTP 403 `enrollment_required`). That answer
|
|
228
|
+
* means every plaintext command will be refused, so the preflight stops with
|
|
229
|
+
* the pairing guidance instead of sending the real command into the same 403.
|
|
230
|
+
* Any other failure keeps the historical "could not verify" behavior.
|
|
231
|
+
*
|
|
232
|
+
* @param status - HTTP status
|
|
233
|
+
* @param body - Response body text
|
|
234
|
+
* @param label - Device name for the pair command
|
|
235
|
+
* @returns A refusal probe, or null
|
|
236
|
+
*/
|
|
237
|
+
function refusalFromPublicError(status, body, label) {
|
|
238
|
+
if (status !== 401 && status !== 403) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const code = JSON.parse(body)?.error?.code;
|
|
243
|
+
if (code === 'enrollment_required' || code === 'locked') {
|
|
244
|
+
return { refusal: (0, control_errors_1.describeControlCode)(code, label) };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Not a control-protocol answer.
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* TRANSIENT_PROBE_CODES are secure-probe answers that do not mean "this
|
|
254
|
+
* controller may not proceed": the FF1 shed load (busy, rate_limited, or the
|
|
255
|
+
* router's storm protection surfacing as command_failed) or could not record
|
|
256
|
+
* the replay window (internal_error, §4.3: not executed, retry with a new ctr).
|
|
257
|
+
* The plaintext probe sees the same conditions as HTTP 429 and carries on, so
|
|
258
|
+
* the secure probe does too; the real command then applies its own retries.
|
|
259
|
+
*/
|
|
260
|
+
const TRANSIENT_PROBE_CODES = new Set(['busy', 'rate_limited', 'internal_error', 'command_failed']);
|
|
261
|
+
/**
|
|
262
|
+
* detectFF1VersionSecurely runs the getDeviceStatus probe as a secure
|
|
263
|
+
* envelope over the LAN only (no retry, no relayer: the probe never had
|
|
264
|
+
* either). A protocol refusal (revoked, unknown controller, …) stops the
|
|
265
|
+
* command with actionable guidance; an unreachable device degrades to
|
|
266
|
+
* "could not verify", exactly like the plaintext probe.
|
|
267
|
+
*
|
|
268
|
+
* @param device - Device with a stable ID
|
|
269
|
+
* @param controller - Enrolled controller record and transport seams
|
|
270
|
+
* @returns Version probe, refusal, or null
|
|
271
|
+
*/
|
|
272
|
+
async function detectFF1VersionSecurely(device, controller) {
|
|
273
|
+
const result = await (0, secure_transport_1.runSecureCommand)({
|
|
274
|
+
device,
|
|
275
|
+
record: controller.record,
|
|
276
|
+
cmd: 'getDeviceStatus',
|
|
277
|
+
req: {},
|
|
278
|
+
relayerFallback: false,
|
|
279
|
+
retry: false,
|
|
280
|
+
}, controller.deps);
|
|
281
|
+
if (!result.success) {
|
|
282
|
+
if (result.code && !TRANSIENT_PROBE_CODES.has(result.code)) {
|
|
283
|
+
return { refusal: { error: result.error, details: result.details } };
|
|
284
|
+
}
|
|
285
|
+
logger.debug('Secure FF1 version probe failed; continuing with command', result.error);
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
// res is the handler's existing body; accept both the bare status object
|
|
289
|
+
// and the `{message:{…}}` wrapper the plaintext LAN reply uses.
|
|
290
|
+
const res = result.response;
|
|
291
|
+
const version = res?.installedVersion ?? res?.message?.installedVersion;
|
|
292
|
+
return typeof version === 'string' && version ? { version } : null;
|
|
293
|
+
}
|
|
218
294
|
/**
|
|
219
295
|
* Build headers shared by cast requests.
|
|
220
296
|
*
|