balena-cli 13.2.1 → 13.3.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/CHANGELOG.md +5 -0
- package/build/commands/ssh.d.ts +0 -7
- package/build/commands/ssh.js +10 -73
- package/build/commands/ssh.js.map +1 -1
- package/build/utils/device/ssh.d.ts +6 -5
- package/build/utils/device/ssh.js +37 -41
- package/build/utils/device/ssh.js.map +1 -1
- package/build/utils/ssh.d.ts +2 -2
- package/build/utils/ssh.js +12 -5
- package/build/utils/ssh.js.map +1 -1
- package/lib/commands/ssh.ts +11 -113
- package/lib/utils/device/ssh.ts +91 -70
- package/lib/utils/ssh.ts +19 -14
- package/npm-shrinkwrap.json +1 -1
- package/oclif.manifest.json +1 -1
- package/package.json +2 -2
package/lib/utils/device/ssh.ts
CHANGED
|
@@ -13,53 +13,88 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import type { ContainerInfo } from 'dockerode';
|
|
17
16
|
|
|
18
17
|
import { ExpectedError } from '../../errors';
|
|
19
18
|
import { stripIndent } from '../lazy';
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
import {
|
|
21
|
+
findBestUsernameForDevice,
|
|
22
|
+
getRemoteCommandOutput,
|
|
23
|
+
runRemoteCommand,
|
|
24
|
+
SshRemoteCommandOpts,
|
|
25
|
+
} from '../ssh';
|
|
26
|
+
|
|
27
|
+
export interface DeviceSSHOpts extends SshRemoteCommandOpts {
|
|
24
28
|
forceTTY?: boolean;
|
|
25
|
-
verbose: boolean;
|
|
26
29
|
service?: string;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then echo "balena"; else echo "docker"; fi)`;
|
|
30
33
|
|
|
31
34
|
/**
|
|
32
|
-
* List the running containers on the device
|
|
33
|
-
* container
|
|
35
|
+
* List the running containers on the device over ssh, and return the full
|
|
36
|
+
* container name that matches the given service name.
|
|
37
|
+
*
|
|
38
|
+
* Note: In the past, two other approaches were implemented for this function:
|
|
39
|
+
*
|
|
40
|
+
* - Obtaining container IDs through a supervisor API call:
|
|
41
|
+
* '/supervisor/v2/containerId' endpoint, via cloud.
|
|
42
|
+
* - Obtaining container IDs using 'dockerode' connected directly to
|
|
43
|
+
* balenaEngine on a device, TCP port 2375.
|
|
44
|
+
*
|
|
45
|
+
* The problem with using the supervisor API is that it means that 'balena ssh'
|
|
46
|
+
* becomes dependent on the supervisor being up an running, but sometimes ssh
|
|
47
|
+
* is needed to investigate devices issues where the supervisor has got into
|
|
48
|
+
* trouble (e.g. supervisor in restart loop). This is the subject of CLI issue
|
|
49
|
+
* https://github.com/balena-io/balena-cli/issues/1560 .
|
|
50
|
+
*
|
|
51
|
+
* The problem with using dockerode to connect directly to port 2375 (balenaEngine)
|
|
52
|
+
* is that it only works with development variants of balenaOS. Production variants
|
|
53
|
+
* block access to port 2375 for security reasons. 'balena ssh' should support
|
|
54
|
+
* production variants as well, especially after balenaOS v2.44.0 that introduced
|
|
55
|
+
* support for using the cloud account username for ssh authentication.
|
|
56
|
+
*
|
|
57
|
+
* Overall, the most reliable approach is to run 'balena-engine ps' over ssh.
|
|
58
|
+
* It is OK to depend on balenaEngine because ssh to a container is implemented
|
|
59
|
+
* through 'balena-engine exec' anyway, and of course it is OK to depend on ssh
|
|
60
|
+
* itself.
|
|
34
61
|
*/
|
|
35
|
-
async function getContainerIdForService(
|
|
36
|
-
service: string,
|
|
37
|
-
deviceAddress: string,
|
|
62
|
+
export async function getContainerIdForService(
|
|
63
|
+
opts: SshRemoteCommandOpts & { service: string; deviceUuid?: string },
|
|
38
64
|
): Promise<string> {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
host
|
|
43
|
-
port: 2375,
|
|
44
|
-
});
|
|
45
|
-
const regex = new RegExp(`(^|\\/)${escapeRegExp(service)}_\\d+_\\d+`);
|
|
46
|
-
const nameRegex = /\/?([a-zA-Z0-9_-]+)_\d+_\d+/;
|
|
47
|
-
let allContainers: ContainerInfo[];
|
|
48
|
-
try {
|
|
49
|
-
allContainers = await docker.listContainers();
|
|
50
|
-
} catch (_e) {
|
|
51
|
-
throw new ExpectedError(stripIndent`
|
|
52
|
-
Could not access docker daemon on device ${deviceAddress}.
|
|
53
|
-
Please ensure the device is in local mode.`);
|
|
65
|
+
opts.cmd = `"${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`;
|
|
66
|
+
if (opts.deviceUuid) {
|
|
67
|
+
// If a device UUID is given, perform ssh via cloud proxy 'host' command
|
|
68
|
+
opts.cmd = `host ${opts.deviceUuid} ${opts.cmd}`;
|
|
54
69
|
}
|
|
55
70
|
|
|
71
|
+
const psLines: string[] = (
|
|
72
|
+
await getRemoteCommandOutput({ ...opts, stderr: 'inherit' })
|
|
73
|
+
).stdout
|
|
74
|
+
.toString()
|
|
75
|
+
.split('\n')
|
|
76
|
+
.filter((l) => l);
|
|
77
|
+
|
|
78
|
+
const { escapeRegExp } = await import('lodash');
|
|
79
|
+
const regex = new RegExp(`(?:^|\\/)${escapeRegExp(opts.service)}_\\d+_\\d+`);
|
|
80
|
+
// Old balenaOS container name pattern:
|
|
81
|
+
// main_1234567_2345678
|
|
82
|
+
// New balenaOS container name patterns:
|
|
83
|
+
// main_1234567_2345678_a000b111c222d333e444f555a666b777
|
|
84
|
+
// main_1_1_localrelease
|
|
85
|
+
const nameRegex = /(?:^|\/)([a-zA-Z0-9_-]+)_\d+_\d+(?:_.+)?$/;
|
|
86
|
+
|
|
56
87
|
const serviceNames: string[] = [];
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
const containerNames: string[] = [];
|
|
89
|
+
let containerId: string | undefined;
|
|
90
|
+
|
|
91
|
+
// sample psLine: 'b603c74e951e bar_4587562_2078151_3261c9d4c22f2c53a5267be459c89990'
|
|
92
|
+
for (const psLine of psLines) {
|
|
93
|
+
const [cId, name] = psLine.split(' ');
|
|
94
|
+
if (cId && name) {
|
|
60
95
|
if (regex.test(name)) {
|
|
61
|
-
|
|
62
|
-
|
|
96
|
+
containerNames.push(name);
|
|
97
|
+
containerId = cId;
|
|
63
98
|
}
|
|
64
99
|
const match = name.match(nameRegex);
|
|
65
100
|
if (match) {
|
|
@@ -67,23 +102,21 @@ async function getContainerIdForService(
|
|
|
67
102
|
}
|
|
68
103
|
}
|
|
69
104
|
}
|
|
70
|
-
|
|
105
|
+
|
|
106
|
+
if (containerNames.length > 1) {
|
|
107
|
+
const [s, d] = [opts.service, opts.deviceUuid || opts.hostname];
|
|
71
108
|
throw new ExpectedError(stripIndent`
|
|
72
|
-
Found more than one container matching service name "${
|
|
73
|
-
${
|
|
109
|
+
Found more than one container matching service name "${s}" on device "${d}":
|
|
110
|
+
${containerNames.join(', ')}
|
|
74
111
|
Use different service names to avoid ambiguity.
|
|
75
112
|
`);
|
|
76
113
|
}
|
|
77
|
-
const containerId = containers.length ? containers[0].id : '';
|
|
78
114
|
if (!containerId) {
|
|
115
|
+
const [s, d] = [opts.service, opts.deviceUuid || opts.hostname];
|
|
79
116
|
throw new ExpectedError(
|
|
80
|
-
`Could not find a
|
|
117
|
+
`Could not find a container matching service name "${s}" on device "${d}".${
|
|
81
118
|
serviceNames.length > 0
|
|
82
|
-
?
|
|
83
|
-
serviceNames,
|
|
84
|
-
(str, name) => `${str}\t${name}\n`,
|
|
85
|
-
'',
|
|
86
|
-
)}`
|
|
119
|
+
? `\nAvailable services:\n\t${serviceNames.join('\n\t')}`
|
|
87
120
|
: ''
|
|
88
121
|
}`,
|
|
89
122
|
);
|
|
@@ -94,13 +127,25 @@ async function getContainerIdForService(
|
|
|
94
127
|
export async function performLocalDeviceSSH(
|
|
95
128
|
opts: DeviceSSHOpts,
|
|
96
129
|
): Promise<void> {
|
|
130
|
+
// Before we started using `findBestUsernameForDevice`, we tried the approach
|
|
131
|
+
// of attempting ssh with the 'root' username first and, if that failed, then
|
|
132
|
+
// attempting ssh with a regular user (balenaCloud username). The problem with
|
|
133
|
+
// that approach was that it would print the following message to the console:
|
|
134
|
+
// "root@192.168.1.36: Permission denied (publickey)"
|
|
135
|
+
// ... right before having success as a regular user, which looked broken or
|
|
136
|
+
// confusing from users' point of view. Capturing stderr to prevent that
|
|
137
|
+
// message from being printed is tricky because the messages printed to stderr
|
|
138
|
+
// may include the stderr output of remote commands that are of interest to
|
|
139
|
+
// the user.
|
|
140
|
+
const username = await findBestUsernameForDevice(opts.hostname, opts.port);
|
|
97
141
|
let cmd = '';
|
|
98
142
|
|
|
99
143
|
if (opts.service) {
|
|
100
|
-
const containerId = await getContainerIdForService(
|
|
101
|
-
opts
|
|
102
|
-
opts.
|
|
103
|
-
|
|
144
|
+
const containerId = await getContainerIdForService({
|
|
145
|
+
...opts,
|
|
146
|
+
service: opts.service,
|
|
147
|
+
username,
|
|
148
|
+
});
|
|
104
149
|
|
|
105
150
|
const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`;
|
|
106
151
|
// stdin (fd=0) is not a tty when data is piped in, for example
|
|
@@ -112,29 +157,5 @@ export async function performLocalDeviceSSH(
|
|
|
112
157
|
cmd = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`;
|
|
113
158
|
}
|
|
114
159
|
|
|
115
|
-
|
|
116
|
-
'../ssh'
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
// Before we started using `findBestUsernameForDevice`, we tried the approach
|
|
120
|
-
// of attempting ssh with the 'root' username first and, if that failed, then
|
|
121
|
-
// attempting ssh with a regular user (balenaCloud username). The problem with
|
|
122
|
-
// that approach was that it would print the following message to the console:
|
|
123
|
-
// "root@192.168.1.36: Permission denied (publickey)"
|
|
124
|
-
// ... right before having success as a regular user, which looked broken or
|
|
125
|
-
// confusing from users' point of view. Capturing stderr to prevent that
|
|
126
|
-
// message from being printed is tricky because the messages printed to stderr
|
|
127
|
-
// may include the stderr output of remote commands that are of interest to
|
|
128
|
-
// the user. Workarounds based on delays (timing) are tricky too because a
|
|
129
|
-
// ssh session length may vary from a fraction of a second (non interactive)
|
|
130
|
-
// to hours or days.
|
|
131
|
-
const username = await findBestUsernameForDevice(opts.address);
|
|
132
|
-
|
|
133
|
-
await runRemoteCommand({
|
|
134
|
-
cmd,
|
|
135
|
-
hostname: opts.address,
|
|
136
|
-
port: Number(opts.port) || 'local',
|
|
137
|
-
username,
|
|
138
|
-
verbose: opts.verbose,
|
|
139
|
-
});
|
|
160
|
+
await runRemoteCommand({ ...opts, cmd, username });
|
|
140
161
|
}
|
package/lib/utils/ssh.ts
CHANGED
|
@@ -247,14 +247,15 @@ export async function getLocalDeviceCmdStdout(
|
|
|
247
247
|
cmd: string,
|
|
248
248
|
stdout: 'capture' | 'ignore' | 'inherit' | NodeJS.WritableStream = 'capture',
|
|
249
249
|
): Promise<Buffer> {
|
|
250
|
+
const port = 'local';
|
|
250
251
|
return (
|
|
251
252
|
await getRemoteCommandOutput({
|
|
252
253
|
cmd,
|
|
253
254
|
hostname,
|
|
254
|
-
port
|
|
255
|
+
port,
|
|
255
256
|
stdout,
|
|
256
257
|
stderr: 'inherit',
|
|
257
|
-
username: await findBestUsernameForDevice(hostname),
|
|
258
|
+
username: await findBestUsernameForDevice(hostname, port),
|
|
258
259
|
})
|
|
259
260
|
).stdout;
|
|
260
261
|
}
|
|
@@ -267,16 +268,14 @@ export async function getLocalDeviceCmdStdout(
|
|
|
267
268
|
* added to the device's 'config.json' file.
|
|
268
269
|
* @return True if succesful, false on any errors.
|
|
269
270
|
*/
|
|
270
|
-
export const isRootUserGood = _.memoize(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
},
|
|
279
|
-
);
|
|
271
|
+
export const isRootUserGood = _.memoize(async (hostname: string, port) => {
|
|
272
|
+
try {
|
|
273
|
+
await runRemoteCommand({ cmd: 'exit 0', hostname, port, ...stdioIgnore });
|
|
274
|
+
} catch (e) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
});
|
|
280
279
|
|
|
281
280
|
/**
|
|
282
281
|
* Determine whether the given local device (hostname or IP address) should be
|
|
@@ -291,7 +290,7 @@ export const isRootUserGood = _.memoize(
|
|
|
291
290
|
* universally possible.
|
|
292
291
|
*/
|
|
293
292
|
export const findBestUsernameForDevice = _.memoize(
|
|
294
|
-
async (hostname: string, port
|
|
293
|
+
async (hostname: string, port): Promise<string> => {
|
|
295
294
|
let username: string | undefined;
|
|
296
295
|
if (await isRootUserGood(hostname, port)) {
|
|
297
296
|
username = 'root';
|
|
@@ -299,7 +298,13 @@ export const findBestUsernameForDevice = _.memoize(
|
|
|
299
298
|
const { getCachedUsername } = await import('./bootstrap');
|
|
300
299
|
username = (await getCachedUsername())?.username;
|
|
301
300
|
}
|
|
302
|
-
|
|
301
|
+
if (!username) {
|
|
302
|
+
const { stripIndent } = await import('./lazy');
|
|
303
|
+
throw new ExpectedError(stripIndent`
|
|
304
|
+
SSH authentication failed for 'root@${hostname}'.
|
|
305
|
+
Please login with 'balena login' for alternative authentication.`);
|
|
306
|
+
}
|
|
307
|
+
return username;
|
|
303
308
|
},
|
|
304
309
|
);
|
|
305
310
|
|