@feralfile/cli 2.10.1 → 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 +31 -23
- package/config.json.example +6 -1
- package/dist/index.js +10 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/build.js +40 -0
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/device.js +85 -40
- package/dist/src/commands/enrich.js +18 -23
- package/dist/src/commands/find.js +144 -106
- 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/helpers/prompt.js +85 -10
- package/dist/src/commands/play.js +162 -84
- package/dist/src/commands/report.js +251 -0
- package/dist/src/commands/setup.js +82 -7
- package/dist/src/commands/status.js +4 -1
- package/dist/src/config.js +256 -6
- package/dist/src/main.js +14 -5
- 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-lookup.js +36 -11
- package/dist/src/utilities/device-network.js +129 -0
- package/dist/src/utilities/device-upsert.js +1 -1
- package/dist/src/utilities/feral-file-artwork.js +2 -2
- 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/index.js +270 -259
- package/dist/src/utilities/media-url.js +117 -0
- package/dist/src/utilities/neort-marketplace.js +3 -3
- package/dist/src/utilities/nft-indexer.js +12 -376
- package/dist/src/utilities/playlist-builder.js +11 -2
- package/dist/src/utilities/playlist-cast.js +6 -6
- package/dist/src/utilities/playlist-source.js +223 -34
- package/dist/src/utilities/raster-client.js +101 -14
- 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/dist/src/utilities/token-item.js +428 -0
- package/dist/src/utilities/token-source-contract.js +55 -0
- package/dist/src/utilities/token-source-indexer.js +99 -0
- package/dist/src/utilities/token-source-raster.js +378 -0
- package/dist/src/utilities/token-source.js +48 -0
- package/docs/CONFIGURATION.md +331 -28
- package/docs/EXAMPLES.md +33 -33
- package/docs/PROJECT_SPEC.md +55 -36
- package/docs/README.md +145 -43
- package/package.json +5 -2
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Problem reports to Feral File support (`ff-cli report`).
|
|
4
|
+
*
|
|
5
|
+
* The report goes to support-logs' public `POST /v2/cli/reports`, which mints
|
|
6
|
+
* the support bundle id the app uses (`Support [<id>]`) and answers with a
|
|
7
|
+
* short-lived token valid only for that bundle's FF1 log submission. ff-cli
|
|
8
|
+
* holds no support credential: it is a public package, so any key it shipped
|
|
9
|
+
* would be public too. support-intake later moves the stored report into the
|
|
10
|
+
* support inbox, where it becomes a case like a report from the app.
|
|
11
|
+
*
|
|
12
|
+
* With an Art Computer, the report carries `device_id`, `version` and `branch`
|
|
13
|
+
* from its LAN `GET /api/status`, and once the report has posted the FF1 is
|
|
14
|
+
* sent its existing `uploadLogs` command with the token as `apiKey`, so its
|
|
15
|
+
* logs land under the same bundle id. FF OS sends that key as `x-api-key` to
|
|
16
|
+
* `/v2/ff1/log-submissions`; no firmware change is involved.
|
|
17
|
+
*
|
|
18
|
+
* What must not change:
|
|
19
|
+
* - The body is the report text, the reply address when given, this ff-cli's
|
|
20
|
+
* version, the OS and Node versions, and the Art Computer block. Nothing
|
|
21
|
+
* else leaves this machine: no config, keys, topic, or controller files.
|
|
22
|
+
* - The report is posted once and never retried: a request that timed out may
|
|
23
|
+
* have been stored, and a resend would open a second case.
|
|
24
|
+
* - The device upload is best effort and never changes the outcome: the
|
|
25
|
+
* report has already reached support by then.
|
|
26
|
+
*/
|
|
27
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
28
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
29
|
+
};
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.MAX_REQUEST_BYTES = exports.MAX_REPORT_BYTES = void 0;
|
|
32
|
+
exports.buildReportPayload = buildReportPayload;
|
|
33
|
+
exports.isBareAddress = isBareAddress;
|
|
34
|
+
exports.isReservedAddress = isReservedAddress;
|
|
35
|
+
exports.containsSigningKey = containsSigningKey;
|
|
36
|
+
exports.readDeviceStatus = readDeviceStatus;
|
|
37
|
+
exports.postReport = postReport;
|
|
38
|
+
exports.requestLogUpload = requestLogUpload;
|
|
39
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
40
|
+
const http_1 = require("./http");
|
|
41
|
+
const pairing_1 = require("./control/pairing");
|
|
42
|
+
const user_agent_1 = require("./user-agent");
|
|
43
|
+
const ed25519_key_derive_1 = require("./ed25519-key-derive");
|
|
44
|
+
const ff1_device_1 = require("./ff1-device");
|
|
45
|
+
const control_errors_1 = require("./control/control-errors");
|
|
46
|
+
const secure_transport_1 = require("./control/secure-transport");
|
|
47
|
+
/** MAX_REPORT_BYTES is support-logs' cap on `report`, in UTF-8 bytes. */
|
|
48
|
+
exports.MAX_REPORT_BYTES = 32_768;
|
|
49
|
+
/**
|
|
50
|
+
* MAX_REQUEST_BYTES is support-logs' cap on the whole request body. A
|
|
51
|
+
* 32768-byte report of control characters escapes to 6 bytes each (`\u0001`),
|
|
52
|
+
* 196608 bytes, and every other field is capped at MAX_FIELD_BYTES, so a body
|
|
53
|
+
* stays near 202 KB; postReport still checks, so no future field can cross it.
|
|
54
|
+
*/
|
|
55
|
+
exports.MAX_REQUEST_BYTES = 256 * 1024;
|
|
56
|
+
/** MAX_FIELD_BYTES is support-logs' cap on every metadata field (the device's included). */
|
|
57
|
+
const MAX_FIELD_BYTES = 256;
|
|
58
|
+
/*
|
|
59
|
+
* The reply-address grammar shared with support-logs and support-intake.
|
|
60
|
+
* All three must accept exactly the same set; the shared cases are in
|
|
61
|
+
* tests/fixtures/reporter-email-fixtures.json, copied unchanged from the
|
|
62
|
+
* cross-repo contract, whose `rule` field is the grammar:
|
|
63
|
+
* - at most 254 bytes, all printable ASCII, exactly one `@`;
|
|
64
|
+
* - local part: 1-64 characters of atext in dot-separated runs (no leading,
|
|
65
|
+
* trailing or doubled dot, no quotes);
|
|
66
|
+
* - domain: two or more dot-separated labels of 1-63 letters, digits and
|
|
67
|
+
* hyphens, not starting or ending with a hyphen (no IP literal, no
|
|
68
|
+
* underscore);
|
|
69
|
+
* - refused even when well formed (case-insensitive): support's own address,
|
|
70
|
+
* and a local part of mailer-daemon or postmaster.
|
|
71
|
+
*/
|
|
72
|
+
const MAX_EMAIL_BYTES = 254;
|
|
73
|
+
const MAX_LOCAL_PART = 64;
|
|
74
|
+
const ATEXT = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+";
|
|
75
|
+
const LOCAL_PART = new RegExp(`^${ATEXT}(?:\\.${ATEXT})*$`);
|
|
76
|
+
const DOMAIN_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
|
|
77
|
+
const RESERVED_LOCAL_PARTS = new Set(['mailer-daemon', 'postmaster']);
|
|
78
|
+
/** STATUS_TIMEOUT_MS bounds the LAN status read at an address. A `.local` host gets
|
|
79
|
+
* lanReadTimeoutMs's 12 s instead, since macOS takes about 5 s to look one up. */
|
|
80
|
+
const STATUS_TIMEOUT_MS = 5_000;
|
|
81
|
+
/** SUPPORT_BUNDLE_ID is the app's reference format: 13 base62 characters (ff-app support_bundle_id.dart). */
|
|
82
|
+
const SUPPORT_BUNDLE_ID = /^[0-9A-Za-z]{13}$/;
|
|
83
|
+
/** SUPPORT_EMAIL is the human route when the report cannot be posted. */
|
|
84
|
+
const SUPPORT_EMAIL = 'support@feralfile.com';
|
|
85
|
+
/**
|
|
86
|
+
* buildReportPayload assembles the report body from the text, the optional
|
|
87
|
+
* reply address and Art Computer block, and this ff-cli and runtime.
|
|
88
|
+
*
|
|
89
|
+
* @param input - Report text, ff-cli version (what `ff-cli --version` prints),
|
|
90
|
+
* reply address, and Art Computer block
|
|
91
|
+
* @returns The body to post
|
|
92
|
+
*/
|
|
93
|
+
function buildReportPayload(input) {
|
|
94
|
+
return {
|
|
95
|
+
report: input.report,
|
|
96
|
+
ff_cli_version: input.ffCliVersion,
|
|
97
|
+
...(input.email ? { reporter_email: input.email } : {}),
|
|
98
|
+
os: `${process.platform} ${node_os_1.default.release()}`,
|
|
99
|
+
node_version: process.version,
|
|
100
|
+
...(input.device && Object.keys(input.device).length > 0 ? { device: input.device } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* isBareAddress reports whether `value` matches the shared reply-address
|
|
105
|
+
* grammar's form (everything but the reserved addresses). Anything looser is
|
|
106
|
+
* refused here because support-logs answers it with a 400, or intake drops it
|
|
107
|
+
* and the reporter gets no reply.
|
|
108
|
+
*
|
|
109
|
+
* @param value - Candidate address, as it would be sent
|
|
110
|
+
* @returns Whether it has the accepted form
|
|
111
|
+
*/
|
|
112
|
+
function isBareAddress(value) {
|
|
113
|
+
// Printable ASCII is checked first, so length is a byte count.
|
|
114
|
+
if (!/^[\x21-\x7e]+$/.test(value) || value.length > MAX_EMAIL_BYTES) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const parts = value.split('@');
|
|
118
|
+
if (parts.length !== 2) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const [local, domain] = parts;
|
|
122
|
+
const labels = domain.split('.');
|
|
123
|
+
return (local.length <= MAX_LOCAL_PART &&
|
|
124
|
+
LOCAL_PART.test(local) &&
|
|
125
|
+
labels.length >= 2 &&
|
|
126
|
+
labels.every((label) => DOMAIN_LABEL.test(label)));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* isReservedAddress reports whether `value` is an address support never
|
|
130
|
+
* replies to: Feral File support's own (intake would read the report as its
|
|
131
|
+
* own mail), or a postmaster or mailer-daemon mailbox. Case-insensitive.
|
|
132
|
+
*
|
|
133
|
+
* @param value - Candidate address
|
|
134
|
+
* @returns Whether it is reserved
|
|
135
|
+
*/
|
|
136
|
+
function isReservedAddress(value) {
|
|
137
|
+
const lower = value.toLowerCase();
|
|
138
|
+
return lower === SUPPORT_EMAIL || RESERVED_LOCAL_PARTS.has(lower.split('@')[0]);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* containsSigningKey reports whether `report` carries the configured playlist
|
|
142
|
+
* signing key. When the key parses, the report is decoded rather than compared
|
|
143
|
+
* as text: every run of base64 (either alphabet, any padding, any unused bits)
|
|
144
|
+
* and every run of hex is decoded at each alignment, and the report matches
|
|
145
|
+
* when the decoded bytes hold the key's 32-byte seed. That covers PEM, PKCS#8
|
|
146
|
+
* DER and the seed in every spelling parsePlaylistPrivateKeyToKeyObject
|
|
147
|
+
* accepts, since all of them decode to bytes that hold the seed. A key that
|
|
148
|
+
* does not parse is matched as written.
|
|
149
|
+
*
|
|
150
|
+
* The report is read as written, with JSON string escapes (`\/`, `/`,
|
|
151
|
+
* `\n`) decoded up to four levels deep, and percent-decoded, since a pasted
|
|
152
|
+
* config.json may escape the key. Characters outside the encoding's alphabet
|
|
153
|
+
* are dropped first, so a wrapped PEM body or a key split by separators still
|
|
154
|
+
* matches.
|
|
155
|
+
*
|
|
156
|
+
* A cheap guard against pasting a config or key file into a report, not a
|
|
157
|
+
* secret scanner. A match needs 32 bytes of key material, so it is never a
|
|
158
|
+
* coincidence.
|
|
159
|
+
*
|
|
160
|
+
* @param report - Report text
|
|
161
|
+
* @param material - Configured key (`playlist.privateKey` or PLAYLIST_PRIVATE_KEY), if any
|
|
162
|
+
* @returns Whether the report contains it
|
|
163
|
+
*/
|
|
164
|
+
function containsSigningKey(report, material) {
|
|
165
|
+
const configured = material?.trim();
|
|
166
|
+
if (!configured) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const readings = reportReadings(report);
|
|
170
|
+
const seed = signingSeed(configured);
|
|
171
|
+
if (seed) {
|
|
172
|
+
return readings.some((text) => decodesToInclude(text, seed));
|
|
173
|
+
}
|
|
174
|
+
const asWritten = keyText(configured.replace(/-----[^-]+-----/g, ''));
|
|
175
|
+
return asWritten.length >= 32 && readings.some((text) => keyText(text).includes(asWritten));
|
|
176
|
+
}
|
|
177
|
+
/** keyText keeps only the characters of hex, base64 and base64url, without `=` padding, lowercased. */
|
|
178
|
+
function keyText(text) {
|
|
179
|
+
return text.replace(/[^A-Za-z0-9+/_-]/g, '').toLowerCase();
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* decodesToInclude decodes `text`'s base64 characters (both alphabets) at all
|
|
183
|
+
* four alignments and its hex digits at both, and reports whether any decoding
|
|
184
|
+
* holds `bytes`. Decoding at the alignment where the key starts yields the
|
|
185
|
+
* key's bytes exactly, whatever text runs into it on either side, and base64
|
|
186
|
+
* decoding ignores the unused bits of a final character.
|
|
187
|
+
*/
|
|
188
|
+
function decodesToInclude(text, bytes) {
|
|
189
|
+
const base64 = text.replace(/[^A-Za-z0-9+/_-]/g, '');
|
|
190
|
+
for (let offset = 0; offset < 4; offset += 1) {
|
|
191
|
+
if (Buffer.from(base64.slice(offset), 'base64').includes(bytes)) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const hex = text.replace(/[^0-9A-Fa-f]/g, '');
|
|
196
|
+
for (let offset = 0; offset < 2; offset += 1) {
|
|
197
|
+
if (Buffer.from(hex.slice(offset), 'hex').includes(bytes)) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* reportReadings lists the report as written, percent-decoded, and with JSON
|
|
205
|
+
* string escapes decoded up to four levels deep (a config quoted inside a JSON
|
|
206
|
+
* log line is escaped twice).
|
|
207
|
+
*/
|
|
208
|
+
function reportReadings(report) {
|
|
209
|
+
const readings = new Set();
|
|
210
|
+
let text = report;
|
|
211
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
212
|
+
readings.add(text);
|
|
213
|
+
readings.add(text.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))));
|
|
214
|
+
const next = text.replace(/\\(?:u([0-9A-Fa-f]{4})|(.))/g, (_, hex, ch) => hex ? String.fromCharCode(parseInt(hex, 16)) : (JSON_ESCAPES[ch] ?? ch));
|
|
215
|
+
if (next === text) {
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
text = next;
|
|
219
|
+
}
|
|
220
|
+
return [...readings];
|
|
221
|
+
}
|
|
222
|
+
const JSON_ESCAPES = { b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
|
|
223
|
+
/** signingSeed returns the configured key's 32-byte Ed25519 seed, or null when the key does not parse. */
|
|
224
|
+
function signingSeed(material) {
|
|
225
|
+
try {
|
|
226
|
+
const key = (0, ed25519_key_derive_1.parsePlaylistPrivateKeyToKeyObject)(material);
|
|
227
|
+
return Buffer.from(String(key.export({ format: 'jwk' }).d), 'base64url');
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* readDeviceStatus reads the Art Computer's identity and build from its LAN
|
|
235
|
+
* `GET /api/status` (FF OS hub/status.go: `device_id`, `version` and `branch`
|
|
236
|
+
* among other fields). The route is open on every FF OS, secured or not, so no
|
|
237
|
+
* controller is involved. A failed read is returned in `error`, never thrown:
|
|
238
|
+
* the report then goes with the device ID configured for it, if any. A value
|
|
239
|
+
* over support-logs' 256-byte field cap is left out, since one would make it
|
|
240
|
+
* refuse the whole report.
|
|
241
|
+
*
|
|
242
|
+
* @param device - Configured Art Computer
|
|
243
|
+
* @param fetchFn - Fetch (tests); defaults to 5 s, or the longer `.local` deadline
|
|
244
|
+
* @returns The Art Computer block, and why the read failed when it did
|
|
245
|
+
*/
|
|
246
|
+
async function readDeviceStatus(device, fetchFn = (input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}, (0, pairing_1.lanReadTimeoutMs)(device.host, STATUS_TIMEOUT_MS))) {
|
|
247
|
+
const configuredId = fieldValue(device.id);
|
|
248
|
+
const fallback = configuredId ? { device_id: configuredId } : {};
|
|
249
|
+
let body;
|
|
250
|
+
try {
|
|
251
|
+
const response = await fetchFn(`${device.host}/api/status`, { headers: deviceHeaders(device) });
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
return { device: fallback, error: `HTTP ${response.status}` };
|
|
254
|
+
}
|
|
255
|
+
body = await response.json();
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
return { device: fallback, error: error.message };
|
|
259
|
+
}
|
|
260
|
+
const status = (body && typeof body === 'object' ? body : {});
|
|
261
|
+
const read = {};
|
|
262
|
+
for (const key of ['device_id', 'version', 'branch']) {
|
|
263
|
+
const value = fieldValue(status[key]);
|
|
264
|
+
if (value) {
|
|
265
|
+
read[key] = value;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (Object.keys(read).length === 0) {
|
|
269
|
+
return { device: fallback, error: 'the answer had no device_id, version or branch' };
|
|
270
|
+
}
|
|
271
|
+
return { device: { ...fallback, ...read } };
|
|
272
|
+
}
|
|
273
|
+
/** fieldValue returns a trimmed, non-empty string within MAX_FIELD_BYTES, else undefined. */
|
|
274
|
+
function fieldValue(value) {
|
|
275
|
+
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
276
|
+
return trimmed && Buffer.byteLength(trimmed, 'utf8') <= MAX_FIELD_BYTES ? trimmed : undefined;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* postReport sends the report body to `<baseUrl>/v2/cli/reports`, once.
|
|
280
|
+
*
|
|
281
|
+
* The endpoint is public by design; its bounds are the size caps and the
|
|
282
|
+
* service's per-IP and global rate limits. A body over MAX_REQUEST_BYTES is
|
|
283
|
+
* refused here, before anything is sent. Errors are the service's
|
|
284
|
+
* `{code, message}` JSON.
|
|
285
|
+
*
|
|
286
|
+
* @param baseUrl - support-logs origin
|
|
287
|
+
* @param body - Serialized ReportPayload
|
|
288
|
+
* @param fetchFn - Fetch (tests); defaults to the shared deadline
|
|
289
|
+
* @returns The receipt, or a failure that says what to do next
|
|
290
|
+
*/
|
|
291
|
+
async function postReport(baseUrl, body, fetchFn = http_1.defaultDeadlineFetch) {
|
|
292
|
+
const bytes = Buffer.byteLength(body, 'utf8');
|
|
293
|
+
if (bytes > exports.MAX_REQUEST_BYTES) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
error: `The report is ${bytes} bytes once encoded; Feral File support takes at most ${exports.MAX_REQUEST_BYTES}.`,
|
|
297
|
+
details: 'Shorten it. Control characters take six bytes each once encoded, so remove any the text carries.',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
let response;
|
|
301
|
+
let text;
|
|
302
|
+
try {
|
|
303
|
+
response = await fetchFn(`${baseUrl}/v2/cli/reports`, {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: {
|
|
306
|
+
'Content-Type': 'application/json',
|
|
307
|
+
Accept: 'application/json',
|
|
308
|
+
'User-Agent': user_agent_1.USER_AGENT,
|
|
309
|
+
},
|
|
310
|
+
body,
|
|
311
|
+
});
|
|
312
|
+
text = await response.text();
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
error: `Could not reach Feral File support at ${baseUrl} (${error.message})`,
|
|
318
|
+
details: `Check this computer's internet connection and run the same command again. If it keeps failing, email the report to ${SUPPORT_EMAIL}.`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (!response.ok) {
|
|
322
|
+
return { ok: false, ...refusal(response.status, text) };
|
|
323
|
+
}
|
|
324
|
+
return parseReceipt(response.status, text);
|
|
325
|
+
}
|
|
326
|
+
/** parseReceipt reads `support_bundle_id` and `ff1_upload_token` from a 2xx answer. */
|
|
327
|
+
function parseReceipt(status, text) {
|
|
328
|
+
let parsed = {};
|
|
329
|
+
try {
|
|
330
|
+
const value = JSON.parse(text);
|
|
331
|
+
if (value && typeof value === 'object') {
|
|
332
|
+
parsed = value;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// Reported below as an answer without a reference.
|
|
337
|
+
}
|
|
338
|
+
const id = parsed.support_bundle_id;
|
|
339
|
+
if (typeof id !== 'string' || !SUPPORT_BUNDLE_ID.test(id)) {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
error: `Feral File support answered HTTP ${status} without a report reference`,
|
|
343
|
+
details: `The report may have arrived, but there is no reference to quote. Email it to ${SUPPORT_EMAIL} so it is not lost.`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const token = parsed.ff1_upload_token;
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
receipt: {
|
|
350
|
+
supportBundleId: id,
|
|
351
|
+
...(typeof token === 'string' && token ? { uploadToken: token } : {}),
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/** refusal maps a non-2xx answer to copy that says what to do next. */
|
|
356
|
+
function refusal(status, text) {
|
|
357
|
+
const reason = serviceMessage(text);
|
|
358
|
+
const said = reason ? `: ${reason}` : '';
|
|
359
|
+
if (status === 429) {
|
|
360
|
+
return {
|
|
361
|
+
error: `Feral File support is limiting reports right now (HTTP 429)${said}`,
|
|
362
|
+
details: 'Wait a few minutes, then run the same command again.',
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (status === 400) {
|
|
366
|
+
return {
|
|
367
|
+
error: `Feral File support refused the report (HTTP 400)${said}`,
|
|
368
|
+
details: 'Fix what the answer names, then run the command again.',
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (status < 500) {
|
|
372
|
+
return {
|
|
373
|
+
error: `Feral File support refused the report (HTTP ${status})${said}`,
|
|
374
|
+
details: `If you set supportLogs.baseUrl or FF_SUPPORT_LOGS_URL, check it. Otherwise email the report to ${SUPPORT_EMAIL}.`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
error: `Feral File support could not take the report (HTTP ${status})${said}`,
|
|
379
|
+
details: `Try again later. If it keeps failing, email the report to ${SUPPORT_EMAIL}.`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/** serviceMessage reads support-logs' `{code, message}` error, or a short excerpt of anything else. */
|
|
383
|
+
function serviceMessage(text) {
|
|
384
|
+
try {
|
|
385
|
+
const parsed = JSON.parse(text);
|
|
386
|
+
if (typeof parsed?.message === 'string' && parsed.message) {
|
|
387
|
+
return parsed.message;
|
|
388
|
+
}
|
|
389
|
+
if (typeof parsed?.code === 'string' && parsed.code) {
|
|
390
|
+
return parsed.code;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// Not JSON: quote it.
|
|
395
|
+
}
|
|
396
|
+
return text.trim().slice(0, 200);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* requestLogUpload asks the Art Computer to upload its logs under the report's
|
|
400
|
+
* bundle, with FF OS's existing `uploadLogs` command and the bundle-scoped
|
|
401
|
+
* token as `apiKey`. FF OS requires `userId` and `title` and does not use them.
|
|
402
|
+
*
|
|
403
|
+
* The transport is the one ff-cli uses for the FF1's other owner-tier
|
|
404
|
+
* commands. With a controller file, a secure envelope (FF OS control/tiers.go
|
|
405
|
+
* puts `uploadLogs` at owner tier, so a controller-role grant gets
|
|
406
|
+
* `permission_denied`), over the LAN and through the relayer only when the LAN
|
|
407
|
+
* is unreachable, like `device controllers` and `device revoke`; the envelope
|
|
408
|
+
* is sealed to the FF1, so the relayer never sees the token. Without one, a
|
|
409
|
+
* plaintext LAN `/api/cast`, which FF OS takes from any LAN peer while the FF1
|
|
410
|
+
* is not secured (in phase 0 only `sshAccess` needs topic proof). No retry:
|
|
411
|
+
* FF OS runs one log upload at a time and ignores a duplicate.
|
|
412
|
+
*
|
|
413
|
+
* FF OS acks at once and uploads in the background (it needs internet), so
|
|
414
|
+
* success means the FF1 took the request, not that the logs arrived.
|
|
415
|
+
*
|
|
416
|
+
* @param device - Configured Art Computer
|
|
417
|
+
* @param receipt - The posted report's bundle id and upload token
|
|
418
|
+
* @param deps - Transport seams (tests)
|
|
419
|
+
* @returns Whether the FF1 took the request; never throws
|
|
420
|
+
*/
|
|
421
|
+
async function requestLogUpload(device, receipt, deps = {}) {
|
|
422
|
+
const req = {
|
|
423
|
+
userId: 'ff-cli',
|
|
424
|
+
apiKey: receipt.uploadToken,
|
|
425
|
+
title: 'ff-cli report',
|
|
426
|
+
supportBundleID: receipt.supportBundleId,
|
|
427
|
+
};
|
|
428
|
+
const secure = (0, secure_transport_1.findSecureController)(device, deps.controllerStore);
|
|
429
|
+
if (secure.failure) {
|
|
430
|
+
return { success: false, error: secure.failure.error, details: secure.failure.details };
|
|
431
|
+
}
|
|
432
|
+
if (!secure.record) {
|
|
433
|
+
return castUploadLogs(device, req, deps.fetchFn ?? http_1.defaultDeadlineFetch);
|
|
434
|
+
}
|
|
435
|
+
// The secure transport can still throw on paths it does not map (a relayer
|
|
436
|
+
// answer whose body read fails). This step runs after the report is in, so a
|
|
437
|
+
// throw here must not reach the command and turn a delivered report into
|
|
438
|
+
// exit 1, which would invite a second report.
|
|
439
|
+
try {
|
|
440
|
+
const result = await (0, secure_transport_1.runSecureCommand)({
|
|
441
|
+
device: device,
|
|
442
|
+
record: secure.record,
|
|
443
|
+
cmd: 'uploadLogs',
|
|
444
|
+
req,
|
|
445
|
+
relayerFallback: true,
|
|
446
|
+
retry: false,
|
|
447
|
+
}, deps);
|
|
448
|
+
return { success: result.success, error: result.error, details: result.details };
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
return { success: false, error: error.message };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** castUploadLogs sends `uploadLogs` as a plaintext LAN cast and reads FF OS's `{ok}` answer. */
|
|
455
|
+
async function castUploadLogs(device, req, fetchFn) {
|
|
456
|
+
const label = device.name || device.host;
|
|
457
|
+
let response;
|
|
458
|
+
let text;
|
|
459
|
+
try {
|
|
460
|
+
response = await fetchFn(`${device.host}/api/cast`, {
|
|
461
|
+
method: 'POST',
|
|
462
|
+
headers: { 'Content-Type': 'application/json', ...deviceHeaders(device) },
|
|
463
|
+
body: JSON.stringify({ command: 'uploadLogs', request: req }),
|
|
464
|
+
});
|
|
465
|
+
text = await response.text();
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
return {
|
|
469
|
+
success: false,
|
|
470
|
+
error: `Could not reach ${label} at ${device.host} (${error.message})`,
|
|
471
|
+
details: 'Check that it is on and reachable from this computer.',
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
if (!response.ok) {
|
|
475
|
+
const code = (0, ff1_device_1.publicControlCode)(response.status, text);
|
|
476
|
+
if (code) {
|
|
477
|
+
const failure = (0, control_errors_1.describeControlCode)(code, device.name || device.id || device.host);
|
|
478
|
+
return { success: false, error: failure.error, details: failure.details };
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
success: false,
|
|
482
|
+
error: `${label} answered HTTP ${response.status}`,
|
|
483
|
+
details: text.trim().slice(0, 200) || undefined,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
let answer = null;
|
|
487
|
+
try {
|
|
488
|
+
answer = JSON.parse(text);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// Reported below.
|
|
492
|
+
}
|
|
493
|
+
if (!answer || typeof answer !== 'object') {
|
|
494
|
+
// FF OS answers {"ok":true}. An empty or non-JSON 2xx (seen on the first
|
|
495
|
+
// cast after boot) does not say whether the command ran.
|
|
496
|
+
return {
|
|
497
|
+
success: false,
|
|
498
|
+
error: `${label} answered HTTP ${response.status} without a usable reply`,
|
|
499
|
+
details: 'It may or may not upload its logs.',
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
if (answer.ok === false) {
|
|
503
|
+
const why = typeof answer.message === 'string' ? answer.message : String(answer.error ?? '');
|
|
504
|
+
return { success: false, error: `${label} refused the request`, details: why || undefined };
|
|
505
|
+
}
|
|
506
|
+
return { success: true };
|
|
507
|
+
}
|
|
508
|
+
/** deviceHeaders carries the device's legacy API key when one is configured. */
|
|
509
|
+
function deviceHeaders(device) {
|
|
510
|
+
return device.apiKey ? { 'API-KEY': device.apiKey } : {};
|
|
511
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TemporaryChannel = void 0;
|
|
4
|
+
const relay_client_1 = require("./relay-client");
|
|
5
|
+
/** TemporaryChannel supplies only the generic carrier seam for the reviewed
|
|
6
|
+
* pairing workflow. QR generation, crypto, result validation and staging stay
|
|
7
|
+
* with that workflow. Its caller chooses the recipient from the endpoint exchange.
|
|
8
|
+
*/
|
|
9
|
+
class TemporaryChannel {
|
|
10
|
+
channelId;
|
|
11
|
+
connectionId;
|
|
12
|
+
peers = new Set();
|
|
13
|
+
ready;
|
|
14
|
+
socket;
|
|
15
|
+
timer;
|
|
16
|
+
pinger;
|
|
17
|
+
closed = false;
|
|
18
|
+
resolveReady;
|
|
19
|
+
rejectReady;
|
|
20
|
+
receipts = new Map();
|
|
21
|
+
onPayload;
|
|
22
|
+
onClose;
|
|
23
|
+
/** Create an endpoint-timed attempt. Await ready before displaying its QR. */
|
|
24
|
+
constructor(endpoint, channelId = crypto.randomUUID(), timeoutMs = 120000, socketFactory = (url) => new WebSocket(url, 'ff-relay.v2')) {
|
|
25
|
+
this.channelId = channelId;
|
|
26
|
+
this.ready = new Promise((resolve, reject) => {
|
|
27
|
+
this.resolveReady = resolve;
|
|
28
|
+
this.rejectReady = reject;
|
|
29
|
+
});
|
|
30
|
+
void this.ready.catch(() => undefined);
|
|
31
|
+
this.socket = socketFactory((0, relay_client_1.relayURL)(endpoint, channelId));
|
|
32
|
+
this.socket.binaryType = 'arraybuffer';
|
|
33
|
+
this.timer = setTimeout(() => this.close(new Error('Pairing not completed: local timeout')), timeoutMs);
|
|
34
|
+
this.socket.addEventListener('message', (event) => {
|
|
35
|
+
try {
|
|
36
|
+
this.receive(event.data);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
this.close(new Error('Malformed temporary-channel frame'));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
this.socket.addEventListener('close', () => this.close(new Error('Pairing channel disconnected')));
|
|
43
|
+
this.socket.addEventListener('error', () => this.close(new Error('Pairing channel failed')));
|
|
44
|
+
}
|
|
45
|
+
/** send targets encrypted endpoint bytes; resolving means receipt, not approval. */
|
|
46
|
+
async send(to, ciphertext) {
|
|
47
|
+
await this.ready;
|
|
48
|
+
if (this.closed || !this.peers.has(to)) {
|
|
49
|
+
throw new Error('Pairing peer unavailable');
|
|
50
|
+
}
|
|
51
|
+
if (this.receipts.size >= 2) {
|
|
52
|
+
throw new Error('Pairing carrier backpressure');
|
|
53
|
+
}
|
|
54
|
+
const id = crypto.randomUUID();
|
|
55
|
+
const frame = (0, relay_client_1.encodeRelayFrame)({ v: 2, id, to }, ciphertext);
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const key = `${to}:${id}`;
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
this.receipts.delete(key);
|
|
60
|
+
reject(new Error('Pairing receipt timeout; result delivery unknown'));
|
|
61
|
+
}, 5000);
|
|
62
|
+
this.receipts.set(key, { resolve, reject, timer });
|
|
63
|
+
try {
|
|
64
|
+
this.socket.send(frame);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
this.close(error);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** close cancels this local attempt and never revokes an endpoint grant. */
|
|
72
|
+
close(reason = new Error('Pairing cancelled')) {
|
|
73
|
+
if (this.closed) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.closed = true;
|
|
77
|
+
clearTimeout(this.timer);
|
|
78
|
+
clearInterval(this.pinger);
|
|
79
|
+
this.rejectReady(reason);
|
|
80
|
+
for (const pending of this.receipts.values()) {
|
|
81
|
+
clearTimeout(pending.timer);
|
|
82
|
+
pending.reject(reason);
|
|
83
|
+
}
|
|
84
|
+
this.receipts.clear();
|
|
85
|
+
this.peers.clear();
|
|
86
|
+
this.socket.close();
|
|
87
|
+
this.onClose?.(reason);
|
|
88
|
+
}
|
|
89
|
+
/** receive releases transport credit before handing ciphertext to validators. */
|
|
90
|
+
receive(data) {
|
|
91
|
+
if (typeof data !== 'string') {
|
|
92
|
+
if (!(data instanceof ArrayBuffer)) {
|
|
93
|
+
throw new Error('Invalid binary frame');
|
|
94
|
+
}
|
|
95
|
+
const { header, payload } = (0, relay_client_1.decodeRelayFrame)(data);
|
|
96
|
+
this.socket.send(JSON.stringify({ type: 'ack', from: header.from, id: header.id }));
|
|
97
|
+
this.onPayload?.(header.from, payload);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (data === 'pong') {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const event = JSON.parse(data);
|
|
104
|
+
if (event.type === 'hello') {
|
|
105
|
+
if (typeof event.connectionId !== 'string' || !Array.isArray(event.peers)) {
|
|
106
|
+
throw new Error('Invalid hello');
|
|
107
|
+
}
|
|
108
|
+
this.connectionId = event.connectionId;
|
|
109
|
+
// §10 Liveness: a pairing wait can outlast the relay's 90 s idle close.
|
|
110
|
+
this.pinger ??= (0, relay_client_1.startPinging)(() => this.socket);
|
|
111
|
+
for (const peer of event.peers.slice(0, 35)) {
|
|
112
|
+
this.peers.add(peer);
|
|
113
|
+
}
|
|
114
|
+
this.resolveReady();
|
|
115
|
+
}
|
|
116
|
+
else if (event.type === 'peer_connected' && this.peers.size < 35) {
|
|
117
|
+
this.peers.add(event.connectionId);
|
|
118
|
+
}
|
|
119
|
+
else if (event.type === 'peer_disconnected') {
|
|
120
|
+
this.peers.delete(event.connectionId);
|
|
121
|
+
}
|
|
122
|
+
else if (event.type === 'ack' || event.type === 'error') {
|
|
123
|
+
const key = `${event.from ?? event.to}:${event.id}`;
|
|
124
|
+
const receipt = this.receipts.get(key);
|
|
125
|
+
if (!receipt) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
clearTimeout(receipt.timer);
|
|
129
|
+
this.receipts.delete(key);
|
|
130
|
+
if (event.type === 'ack') {
|
|
131
|
+
receipt.resolve();
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
receipt.reject(new Error(`Relay: ${event.code}`));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
exports.TemporaryChannel = TemporaryChannel;
|