@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
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* SSH access control for FF1 devices.
|
|
4
|
+
*
|
|
5
|
+
* The sshAccess command is LAN-only: it is POSTed straight to the device's
|
|
6
|
+
* `/api/cast` endpoint and never falls back to the relayer.
|
|
7
|
+
*
|
|
8
|
+
* Topic proof (feral-file#3551 phase 0): on a claimed FF1, newer FF OS rejects
|
|
9
|
+
* a LAN `sshAccess` (and `connect`) cast unless it carries the relayer topic in
|
|
10
|
+
* the `X-FF1-Topic` header, answering HTTP 401 `{"error":{"code":"topic_required"}}`.
|
|
11
|
+
* The topic is the secret that `ff-cli device pair` stores in the OS keyring;
|
|
12
|
+
* holding it proves the CLI was paired by the owner through the mobile app.
|
|
13
|
+
* Older firmware and unclaimed devices ignore the header, so the request is
|
|
14
|
+
* still sent without it when no topic is stored. The topic value must never be
|
|
15
|
+
* logged or placed in an error message.
|
|
16
|
+
*
|
|
17
|
+
* Secure control (feral-file#3551 M6): when ff-cli holds a controller file for
|
|
18
|
+
* the device, `sshAccess` is sent as an ff1-control-v1 envelope instead, still
|
|
19
|
+
* LAN-only. `sshAccess` is owner tier, so a controller-role grant gets
|
|
20
|
+
* `permission_denied`, mapped to "needs an owner grant". No topic header is
|
|
21
|
+
* sent on that path: the signed envelope is the proof.
|
|
4
22
|
*/
|
|
5
23
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
24
|
if (k2 === undefined) k2 = k;
|
|
@@ -36,25 +54,28 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
54
|
};
|
|
37
55
|
})();
|
|
38
56
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
57
|
+
exports.FF1_TOPIC_HEADER = void 0;
|
|
39
58
|
exports.sendSshAccessCommand = sendSshAccessCommand;
|
|
40
59
|
const http_1 = require("./http");
|
|
41
60
|
const logger = __importStar(require("../logger"));
|
|
42
61
|
const ff1_compatibility_1 = require("./ff1-compatibility");
|
|
62
|
+
const topic_store_1 = require("./topic-store");
|
|
63
|
+
const secure_transport_1 = require("./control/secure-transport");
|
|
64
|
+
/** FF1_TOPIC_HEADER is the LAN cast header that carries the relayer topic proof. */
|
|
65
|
+
exports.FF1_TOPIC_HEADER = 'X-FF1-Topic';
|
|
66
|
+
/** TOPIC_REQUIRED_CODE is the device error code for a missing or wrong topic proof. */
|
|
67
|
+
const TOPIC_REQUIRED_CODE = 'topic_required';
|
|
43
68
|
/**
|
|
44
|
-
*
|
|
69
|
+
* sendSshAccessCommand sends an SSH access command to an FF1 device over the LAN.
|
|
45
70
|
*
|
|
46
71
|
* @param {Object} params - Function parameters
|
|
47
72
|
* @param {boolean} params.enabled - Whether to enable SSH access
|
|
48
73
|
* @param {string} [params.deviceName] - Device name to target (defaults to first configured)
|
|
49
74
|
* @param {string} [params.publicKey] - SSH public key to authorize (required for enable)
|
|
50
75
|
* @param {number} [params.ttlSeconds] - Time-to-live in seconds for auto-disable
|
|
51
|
-
* @
|
|
52
|
-
* @returns {
|
|
53
|
-
*
|
|
54
|
-
* @returns {string} [returns.deviceName] - Device name used
|
|
55
|
-
* @returns {Object} [returns.response] - Response from device
|
|
56
|
-
* @returns {string} [returns.error] - Error message if failed
|
|
57
|
-
* @throws {Error} When device configuration is invalid or missing
|
|
76
|
+
* @param {SshAccessDependencies} [overrides] - Optional fetch, topic-store, and config seams
|
|
77
|
+
* @returns {Promise<SshAccessResult>} Result object; never throws. A device that
|
|
78
|
+
* demands topic proof yields `success: false` with an actionable pairing error.
|
|
58
79
|
* @example
|
|
59
80
|
* // Enable SSH for 30 minutes
|
|
60
81
|
* const result = await sendSshAccessCommand({
|
|
@@ -63,15 +84,15 @@ const ff1_compatibility_1 = require("./ff1-compatibility");
|
|
|
63
84
|
* ttlSeconds: 1800,
|
|
64
85
|
* });
|
|
65
86
|
*/
|
|
66
|
-
async function sendSshAccessCommand({ enabled, deviceName, publicKey, ttlSeconds, }) {
|
|
87
|
+
async function sendSshAccessCommand({ enabled, deviceName, publicKey, ttlSeconds }, overrides = {}) {
|
|
88
|
+
const fetchFn = overrides.fetchFn ?? ((input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}));
|
|
67
89
|
try {
|
|
68
90
|
if (enabled && (!publicKey || !publicKey.trim())) {
|
|
69
|
-
return {
|
|
70
|
-
success: false,
|
|
71
|
-
error: 'Public key is required to enable SSH access',
|
|
72
|
-
};
|
|
91
|
+
return { success: false, error: 'Public key is required to enable SSH access' };
|
|
73
92
|
}
|
|
74
|
-
const resolved = (0, ff1_compatibility_1.resolveConfiguredDevice)(deviceName
|
|
93
|
+
const resolved = (0, ff1_compatibility_1.resolveConfiguredDevice)(deviceName, {
|
|
94
|
+
getFF1DeviceConfigFn: overrides.getFF1DeviceConfigFn,
|
|
95
|
+
});
|
|
75
96
|
if (!resolved.success || !resolved.device) {
|
|
76
97
|
return {
|
|
77
98
|
success: false,
|
|
@@ -79,64 +100,177 @@ async function sendSshAccessCommand({ enabled, deviceName, publicKey, ttlSeconds
|
|
|
79
100
|
};
|
|
80
101
|
}
|
|
81
102
|
const device = resolved.device;
|
|
82
|
-
const
|
|
103
|
+
const secure = (0, secure_transport_1.findSecureController)(device, overrides.controllerStore);
|
|
104
|
+
if (secure.failure) {
|
|
105
|
+
return { success: false, error: secure.failure.error, details: secure.failure.details };
|
|
106
|
+
}
|
|
107
|
+
const secureDeps = {
|
|
108
|
+
fetchFn: overrides.fetchFn,
|
|
109
|
+
getTopicIdFn: overrides.getTopicIdFn,
|
|
110
|
+
controllerStore: overrides.controllerStore,
|
|
111
|
+
nowFn: overrides.nowFn,
|
|
112
|
+
};
|
|
113
|
+
const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'sshAccess', {
|
|
114
|
+
fetchFn: overrides.fetchFn,
|
|
115
|
+
controller: secure.record ? { record: secure.record, deps: secureDeps } : undefined,
|
|
116
|
+
});
|
|
83
117
|
if (!compatibility.compatible) {
|
|
84
118
|
return {
|
|
85
119
|
success: false,
|
|
86
120
|
error: compatibility.error || 'FF1 OS does not support SSH access command',
|
|
87
|
-
details: compatibility.
|
|
121
|
+
details: compatibility.details ??
|
|
122
|
+
(compatibility.version ? `Detected version ${compatibility.version}` : undefined),
|
|
88
123
|
};
|
|
89
124
|
}
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
};
|
|
94
|
-
if (publicKey && publicKey.trim()) {
|
|
95
|
-
request.publicKey = publicKey.trim();
|
|
96
|
-
}
|
|
97
|
-
if (typeof ttlSeconds === 'number') {
|
|
98
|
-
request.ttlSeconds = ttlSeconds;
|
|
99
|
-
}
|
|
100
|
-
const requestBody = {
|
|
101
|
-
command: 'sshAccess',
|
|
102
|
-
request,
|
|
103
|
-
};
|
|
104
|
-
const headers = {
|
|
105
|
-
'Content-Type': 'application/json',
|
|
106
|
-
};
|
|
107
|
-
if (device.apiKey) {
|
|
108
|
-
headers['API-KEY'] = device.apiKey;
|
|
125
|
+
const body = buildSshAccessBody(enabled, publicKey, ttlSeconds);
|
|
126
|
+
if (secure.record) {
|
|
127
|
+
return await sendSshAccessSecurely(device, secure.record, body.request, secureDeps);
|
|
109
128
|
}
|
|
110
|
-
const
|
|
129
|
+
const topicId = lookupTopicId(device, overrides.getTopicIdFn ?? topic_store_1.getTopicId);
|
|
130
|
+
const response = await fetchFn(`${device.host}/api/cast`, {
|
|
111
131
|
method: 'POST',
|
|
112
|
-
headers,
|
|
113
|
-
body: JSON.stringify(
|
|
132
|
+
headers: buildSshAccessHeaders(device, topicId),
|
|
133
|
+
body: JSON.stringify(body),
|
|
114
134
|
});
|
|
115
|
-
|
|
116
|
-
const errorText = await response.text();
|
|
117
|
-
logger.error(`SSH access request failed: ${response.status} ${response.statusText}`);
|
|
118
|
-
logger.debug(`Error details: ${errorText}`);
|
|
119
|
-
return {
|
|
120
|
-
success: false,
|
|
121
|
-
error: `Device returned error ${response.status}: ${response.statusText}`,
|
|
122
|
-
details: errorText,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
const responseData = (await response.json());
|
|
126
|
-
logger.info('SSH access command succeeded');
|
|
127
|
-
logger.debug(`Device response: ${JSON.stringify(responseData)}`);
|
|
128
|
-
return {
|
|
129
|
-
success: true,
|
|
130
|
-
device: device.host,
|
|
131
|
-
deviceName: device.name || device.host,
|
|
132
|
-
response: responseData,
|
|
133
|
-
};
|
|
135
|
+
return await interpretSshAccessResponse(device, response);
|
|
134
136
|
}
|
|
135
137
|
catch (error) {
|
|
136
138
|
logger.error(`Error sending SSH access command: ${error.message}`);
|
|
139
|
+
return { success: false, error: error.message };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* sendSshAccessSecurely sends `sshAccess` as a secure envelope over the LAN
|
|
144
|
+
* (no retry and no relayer fallback, matching the plaintext path).
|
|
145
|
+
*
|
|
146
|
+
* @param device - Device with a controller record
|
|
147
|
+
* @param record - Enrolled controller record
|
|
148
|
+
* @param request - The unchanged sshAccess request object
|
|
149
|
+
* @param deps - Transport seams
|
|
150
|
+
* @returns SSH access result
|
|
151
|
+
*/
|
|
152
|
+
async function sendSshAccessSecurely(device, record, request, deps) {
|
|
153
|
+
const result = await (0, secure_transport_1.runSecureCommand)({
|
|
154
|
+
device: device,
|
|
155
|
+
record,
|
|
156
|
+
cmd: 'sshAccess',
|
|
157
|
+
req: request,
|
|
158
|
+
relayerFallback: false,
|
|
159
|
+
retry: false,
|
|
160
|
+
}, deps);
|
|
161
|
+
if (!result.success) {
|
|
162
|
+
logger.error(`SSH access request failed: ${result.error}`);
|
|
163
|
+
return { success: false, error: result.error, details: result.details };
|
|
164
|
+
}
|
|
165
|
+
logger.info('SSH access command succeeded');
|
|
166
|
+
return {
|
|
167
|
+
success: true,
|
|
168
|
+
device: device.host,
|
|
169
|
+
deviceName: device.name || device.host,
|
|
170
|
+
response: result.response,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* lookupTopicId reads the stored relayer topic for a device, or null.
|
|
175
|
+
*
|
|
176
|
+
* A missing device ID, a missing topic, or an unavailable keyring all degrade
|
|
177
|
+
* to "send without the header": unclaimed devices and older firmware accept
|
|
178
|
+
* that, and a device that does require proof answers 401 topic_required, which
|
|
179
|
+
* is mapped to the pairing guidance. Failing hard here would break SSH on every
|
|
180
|
+
* device the header is irrelevant to.
|
|
181
|
+
*/
|
|
182
|
+
function lookupTopicId(device, getTopicIdFn) {
|
|
183
|
+
if (!device.id) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
return getTopicIdFn(device.id);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
logger.debug(`Secure topic storage unavailable: ${error.message}`);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** buildSshAccessHeaders returns the cast headers, adding topic proof only when known. */
|
|
195
|
+
function buildSshAccessHeaders(device, topicId) {
|
|
196
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
197
|
+
if (device.apiKey) {
|
|
198
|
+
headers['API-KEY'] = device.apiKey;
|
|
199
|
+
}
|
|
200
|
+
if (topicId) {
|
|
201
|
+
headers[exports.FF1_TOPIC_HEADER] = topicId;
|
|
202
|
+
}
|
|
203
|
+
return headers;
|
|
204
|
+
}
|
|
205
|
+
/** buildSshAccessBody returns the `sshAccess` cast body. */
|
|
206
|
+
function buildSshAccessBody(enabled, publicKey, ttlSeconds) {
|
|
207
|
+
const request = { enabled };
|
|
208
|
+
if (publicKey && publicKey.trim()) {
|
|
209
|
+
request.publicKey = publicKey.trim();
|
|
210
|
+
}
|
|
211
|
+
if (typeof ttlSeconds === 'number') {
|
|
212
|
+
request.ttlSeconds = ttlSeconds;
|
|
213
|
+
}
|
|
214
|
+
return { command: 'sshAccess', request };
|
|
215
|
+
}
|
|
216
|
+
/** interpretSshAccessResponse maps the device HTTP response onto an SshAccessResult. */
|
|
217
|
+
async function interpretSshAccessResponse(device, response) {
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
const errorText = await response.text();
|
|
220
|
+
logger.error(`SSH access request failed: ${response.status} ${response.statusText}`);
|
|
221
|
+
logger.debug(`Error details: ${errorText}`);
|
|
222
|
+
if (isTopicRequired(response.status, errorText)) {
|
|
223
|
+
return { success: false, error: topicRequiredMessage(device), details: errorText };
|
|
224
|
+
}
|
|
137
225
|
return {
|
|
138
226
|
success: false,
|
|
139
|
-
error: error.
|
|
227
|
+
error: `Device returned error ${response.status}: ${response.statusText}`,
|
|
228
|
+
details: errorText,
|
|
140
229
|
};
|
|
141
230
|
}
|
|
231
|
+
const responseData = (await response.json());
|
|
232
|
+
logger.info('SSH access command succeeded');
|
|
233
|
+
logger.debug(`Device response: ${JSON.stringify(responseData)}`);
|
|
234
|
+
return {
|
|
235
|
+
success: true,
|
|
236
|
+
device: device.host,
|
|
237
|
+
deviceName: device.name || device.host,
|
|
238
|
+
response: responseData,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** isTopicRequired reports whether a device rejection is the topic-proof 401. */
|
|
242
|
+
function isTopicRequired(status, body) {
|
|
243
|
+
if (status !== 401) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const parsed = JSON.parse(body);
|
|
248
|
+
return parsed?.error?.code === TOPIC_REQUIRED_CODE;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* topicRequiredMessage tells the user how to pair the CLI for this device.
|
|
256
|
+
*
|
|
257
|
+
* The same guidance covers "never paired" and "stored topic is stale" (the
|
|
258
|
+
* device rotated its topic): pairing again replaces the keyring entry. A device
|
|
259
|
+
* without a stable ID cannot be paired, so it is told to re-add first.
|
|
260
|
+
*/
|
|
261
|
+
function topicRequiredMessage(device) {
|
|
262
|
+
const label = device.name || device.host;
|
|
263
|
+
const pairCommand = `ff-cli device pair ${quoteArg(label)}`;
|
|
264
|
+
const base = `This FF1 requires ff-cli to be paired before SSH access can be changed.`;
|
|
265
|
+
// Securing first makes the pairing an owner grant, which keeps working once
|
|
266
|
+
// the FF1 no longer has a relayer topic (feral-file#3551 relay v2).
|
|
267
|
+
const secure = `In the Feral File app, choose "Secure this FF1" for this device if it is offered`;
|
|
268
|
+
if (!device.id) {
|
|
269
|
+
return `${base} Re-add the device with its ID (ff-cli device add --id <FF1 device ID>). ${secure}, then run \`${pairCommand}\` and choose "Pair with ff-cli".`;
|
|
270
|
+
}
|
|
271
|
+
return `${base} ${secure}, then run \`${pairCommand}\` and choose "Pair with ff-cli", then retry.`;
|
|
272
|
+
}
|
|
273
|
+
/** quoteArg double-quotes a shell argument that contains whitespace or quotes. */
|
|
274
|
+
function quoteArg(value) {
|
|
275
|
+
return /[\s"'$`\\]/.test(value) ? JSON.stringify(value) : value;
|
|
142
276
|
}
|