Fix 'balena ssh' on MSYS Windows shell ("unexpected end of file")

Resolves: #1681
Change-type: patch
This commit is contained in:
Paulo Castro 2020-03-27 01:26:37 +00:00
parent d6a065a230
commit be76b8adbd
8 changed files with 129 additions and 148 deletions

View File

@ -1205,7 +1205,8 @@ support) please check:
#### --port, -p <port>
SSH gateway port
SSH server port number (default 22222) if the target is an IP address or .local
hostname. Otherwise, port number for the balenaCloud gateway (default 22).
#### --verbose, -v
@ -1213,8 +1214,7 @@ Increase verbosity
#### --noproxy
Don't use the proxy configuration for this connection. This flag
only make sense if you've configured a proxy globally.
Bypass global proxy configuration for the ssh connection
## tunnel <deviceOrApplication>

View File

@ -58,5 +58,3 @@ exports.pipeContainerStream = Promise.method ({ deviceIp, name, outStream, follo
if err is '404'
return console.log(getChalk().red.bold("Container '#{name}' not found."))
throw err
exports.getSubShellCommand = require('../../utils/helpers').getSubShellCommand

View File

@ -1,5 +1,5 @@
/*
Copyright 2016-2019 Balena
Copyright 2016-2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,7 +17,6 @@ import * as BalenaSdk from 'balena-sdk';
import { CommandDefinition } from 'capitano';
import { stripIndent } from 'common-tags';
import { BalenaDeviceNotFound } from 'balena-errors';
import { getBalenaSdk } from '../utils/lazy';
import { validateDotLocalUrl, validateIPAddress } from '../utils/validation';
@ -27,7 +26,7 @@ async function getContainerId(
serviceName: string,
sshOpts: {
port?: number;
proxyCommand?: string;
proxyCommand?: string[];
proxyUrl: string;
username: string;
},
@ -70,7 +69,7 @@ async function getContainerId(
}
containerId = body.services[serviceName];
} else {
console.log(stripIndent`
console.error(stripIndent`
Using legacy method to detect container ID. This will be slow.
To speed up this process, please update your device to an OS
which has a supervisor version of at least v8.6.0.
@ -80,30 +79,29 @@ async function getContainerId(
// container
const { child_process } = await import('mz');
const escapeRegex = await import('lodash/escapeRegExp');
const { getSubShellCommand } = await import('../utils/helpers');
const { which } = await import('../utils/helpers');
const { deviceContainerEngineBinary } = await import('../utils/device/ssh');
const command = generateVpnSshCommand({
const sshBinary = await which('ssh');
const sshArgs = generateVpnSshCommand({
uuid,
verbose: false,
port: sshOpts.port,
command: `host ${uuid} '"${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"'`,
command: `host ${uuid} "${deviceContainerEngineBinary}" ps --format "{{.ID}} {{.Names}}"`,
proxyCommand: sshOpts.proxyCommand,
proxyUrl: sshOpts.proxyUrl,
username: sshOpts.username,
});
const subShellCommand = getSubShellCommand(command);
const subprocess = child_process.spawn(
subShellCommand.program,
subShellCommand.args,
{
stdio: [null, 'pipe', null],
},
);
if (process.env.DEBUG) {
console.error(`[debug] [${sshBinary}, ${sshArgs.join(', ')}]`);
}
const subprocess = child_process.spawn(sshBinary, sshArgs, {
stdio: [null, 'pipe', null],
});
const containers = await new Promise<string>((resolve, reject) => {
let output = '';
subprocess.stdout.on('data', chunk => (output += chunk.toString()));
const output: string[] = [];
subprocess.stdout.on('data', chunk => output.push(chunk.toString()));
subprocess.on('close', (code: number) => {
if (code !== 0) {
reject(
@ -112,7 +110,7 @@ async function getContainerId(
),
);
} else {
resolve(output);
resolve(output.join(''));
}
});
});
@ -143,17 +141,21 @@ function generateVpnSshCommand(opts: {
port?: number;
username: string;
proxyUrl: string;
proxyCommand?: string;
proxyCommand?: string[];
}) {
return (
`ssh ${
opts.verbose ? '-vvv' : ''
} -t -o LogLevel=ERROR -o StrictHostKeyChecking=no ` +
`-o UserKnownHostsFile=/dev/null ` +
`${opts.proxyCommand != null ? opts.proxyCommand : ''} ` +
`${opts.port != null ? `-p ${opts.port}` : ''} ` +
`${opts.username}@ssh.${opts.proxyUrl} ${opts.command}`
);
return [
...(opts.verbose ? ['-vvv'] : []),
'-t',
...['-o', 'LogLevel=ERROR'],
...['-o', 'StrictHostKeyChecking=no'],
...['-o', 'UserKnownHostsFile=/dev/null'],
...(opts.proxyCommand && opts.proxyCommand.length
? ['-o', `ProxyCommand=${opts.proxyCommand.join(' ')}`]
: []),
...(opts.port ? ['-p', opts.port.toString()] : []),
`${opts.username}@ssh.${opts.proxyUrl}`,
opts.command,
];
}
export const ssh: CommandDefinition<
@ -207,7 +209,9 @@ export const ssh: CommandDefinition<
{
signature: 'port',
parameter: 'port',
description: 'SSH gateway port',
description: stripIndent`
SSH server port number (default 22222) if the target is an IP address or .local
hostname. Otherwise, port number for the balenaCloud gateway (default 22).`,
alias: 'p',
},
{
@ -219,24 +223,19 @@ export const ssh: CommandDefinition<
{
signature: 'noproxy',
boolean: true,
description: stripIndent`
Don't use the proxy configuration for this connection. This flag
only make sense if you've configured a proxy globally.`,
description: 'Bypass global proxy configuration for the ssh connection',
},
],
action: async (params, options) => {
const applicationOrDevice =
params.applicationOrDevice_raw || params.applicationOrDevice;
const bash = await import('bash');
const { getProxyConfig, getSubShellCommand, which } = await import(
const { ExpectedError } = await import('../errors');
const { getProxyConfig, which, whichSpawn } = await import(
'../utils/helpers'
);
const { child_process } = await import('mz');
const {
exitIfNotLoggedIn,
exitWithExpectedError,
getOnlineTargetUuid,
} = await import('../utils/patterns');
const { checkLoggedIn, getOnlineTargetUuid } = await import(
'../utils/patterns'
);
const sdk = getBalenaSdk();
const verbose = options.verbose === true;
@ -259,34 +258,30 @@ export const ssh: CommandDefinition<
}
// this will be a tunnelled SSH connection...
await exitIfNotLoggedIn();
await checkLoggedIn();
const uuid = await getOnlineTargetUuid(sdk, applicationOrDevice);
let version: string | undefined;
let id: number | undefined;
try {
const device = await sdk.models.device.get(uuid, {
$select: ['id', 'supervisor_version', 'is_online'],
});
id = device.id;
version = device.supervisor_version;
} catch (e) {
if (e instanceof BalenaDeviceNotFound) {
exitWithExpectedError(`Could not find device: ${uuid}`);
}
}
const device = await sdk.models.device.get(uuid, {
$select: ['id', 'supervisor_version', 'is_online'],
});
id = device.id;
version = device.supervisor_version;
const [whichProxytunnel, username, proxyUrl] = await Promise.all([
useProxy ? which('proxytunnel', false) : undefined,
sdk.auth.whoami(),
// note that `proxyUrl` refers to the balenaCloud "resin-proxy"
// service, currently "balena-devices.com", rather than some
// local proxy server URL
sdk.settings.get('proxyUrl'),
]);
const getSshProxyCommand = () => {
if (!useProxy) {
return '';
if (!proxyConfig) {
return;
}
if (!whichProxytunnel) {
console.warn(stripIndent`
Proxy is enabled but the \`proxytunnel\` binary cannot be found.
@ -295,27 +290,32 @@ export const ssh: CommandDefinition<
for the \`ssh\` requests.
Attempting the unproxied request for now.`);
return '';
return;
}
const p = proxyConfig!;
const tunnelOptions: Dictionary<string> = {
proxy: `${p.host}:${p.port}`,
dest: '%h:%p',
};
const p = proxyConfig;
if (p.username && p.password) {
tunnelOptions.user = p.username;
tunnelOptions.pass = p.password;
// proxytunnel understands these variables for proxy authentication.
// Setting the variables instead of command-line options avoids the
// need for shell-specific escaping of special characters like '$'.
process.env.PROXYUSER = p.username;
process.env.PROXYPASS = p.password;
}
const ProxyCommand = `proxytunnel ${bash.args(tunnelOptions, '--', '=')}`;
return `-o ${bash.args({ ProxyCommand }, '', '=')}`;
return [
'proxytunnel',
`--proxy=${p.host}:${p.port}`,
// ssh replaces these %h:%p variables in the ProxyCommand option
// https://linux.die.net/man/5/ssh_config
'--dest=%h:%p',
...(verbose ? ['--verbose'] : []),
];
};
const proxyCommand = getSshProxyCommand();
const proxyCommand = useProxy ? getSshProxyCommand() : undefined;
if (username == null) {
exitWithExpectedError(
throw new ExpectedError(
`Opening an SSH connection to a remote device requires you to be logged in.`,
);
}
@ -356,9 +356,6 @@ export const ssh: CommandDefinition<
username: username!,
});
const subShellCommand = getSubShellCommand(command);
await child_process.spawn(subShellCommand.program, subShellCommand.args, {
stdio: 'inherit',
});
await whichSpawn('ssh', command);
},
};

View File

@ -1,5 +1,5 @@
/*
Copyright 2016-2019 Balena
Copyright 2016-2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -27,12 +27,10 @@ export const deviceContainerEngineBinary = `$(if [ -f /usr/bin/balena ]; then ec
export async function performLocalDeviceSSH(
opts: DeviceSSHOpts,
): Promise<void> {
const childProcess = await import('child_process');
const reduce = await import('lodash/reduce');
const { getSubShellCommand } = await import('../helpers');
const { exitWithExpectedError } = await import('../patterns');
const { whichSpawn } = await import('../helpers');
const { ExpectedError } = await import('../../errors');
const { stripIndent } = await import('common-tags');
const os = await import('os');
let command = '';
@ -57,10 +55,9 @@ export async function performLocalDeviceSSH(
try {
allContainers = await docker.listContainers();
} catch (_e) {
exitWithExpectedError(stripIndent`
throw new ExpectedError(stripIndent`
Could not access docker daemon on device ${opts.address}.
Please ensure the device is in local mode.`);
return;
}
const serviceNames: string[] = [];
@ -80,7 +77,7 @@ export async function performLocalDeviceSSH(
.filter(c => c != null);
if (containers.length === 0) {
exitWithExpectedError(
throw new ExpectedError(
`Could not find a service on device with name ${opts.service}. ${
serviceNames.length > 0
? `Available services:\n${reduce(
@ -93,36 +90,26 @@ export async function performLocalDeviceSSH(
);
}
if (containers.length > 1) {
exitWithExpectedError(stripIndent`
throw new ExpectedError(stripIndent`
Found more than one container with a service name ${opts.service}.
This state is not supported, please contact support.
`);
}
// Getting a command to work on all platforms is a pain,
// so we just define slightly different ones for windows
if (os.platform() !== 'win32') {
const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`;
command = `'${deviceContainerEngineBinary}' exec -ti ${
containers[0]!.id
} '${shellCmd}'`;
} else {
const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`;
command = `${deviceContainerEngineBinary} exec -ti ${
containers[0]!.id
} ${shellCmd}`;
}
const shellCmd = `/bin/sh -c "if [ -e /bin/bash ]; then exec /bin/bash; else exec /bin/sh; fi"`;
command = `${deviceContainerEngineBinary} exec -ti ${
containers[0]!.id
} ${shellCmd}`;
}
// Generate the SSH command
const sshCommand = `ssh \
${opts.verbose ? '-vvv' : ''} \
-t \
-p ${opts.port ? opts.port : 22222} \
-o LogLevel=ERROR \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
root@${opts.address} ${command}`;
const subShell = getSubShellCommand(sshCommand);
childProcess.spawn(subShell.program, subShell.args, { stdio: 'inherit' });
return whichSpawn('ssh', [
...(opts.verbose ? ['-vvv'] : []),
'-t',
...['-p', opts.port ? opts.port.toString() : '22222'],
...['-o', 'LogLevel=ERROR'],
...['-o', 'StrictHostKeyChecking=no'],
...['-o', 'UserKnownHostsFile=/dev/null'],
`root@${opts.address}`,
...(command ? [command] : []),
]);
}

View File

@ -16,9 +16,10 @@ limitations under the License.
import { InitializeEmitter, OperationState } from 'balena-device-init';
import * as BalenaSdk from 'balena-sdk';
import Bluebird = require('bluebird');
import _ = require('lodash');
import os = require('os');
import * as Bluebird from 'bluebird';
import { spawn, SpawnOptions } from 'child_process';
import * as _ from 'lodash';
import * as os from 'os';
import * as ShellEscape from 'shell-escape';
import { ExpectedError } from '../errors';
@ -187,35 +188,6 @@ export function getApplication(applicationName: string) {
return balena.models.application.get(applicationName, extraOptions);
}
/**
* Choose between 'cmd.exe' and '/bin/sh' for running the given command string,
* depending on the value of `os.platform()`.
* When writing new code, consider whether it would be possible to avoid using a
* shell at all, using the which() function in this module to obtain a program's
* full path, executing the program directly and passing the arguments as an
* array instead of a long string. Avoiding a shell has several benefits:
* - Avoids the need to shell-escape arguments, especially nested commands.
* - Bypasses the incompatibilities between cmd.exe and /bin/sh.
* - Reduces the security risks of lax input validation.
* Code example avoiding a shell:
* const program = await which('ssh');
* const args = ['root@192.168.1.1', 'cat /etc/os-release'];
* const child = spawn(program, args);
*/
export function getSubShellCommand(command: string) {
if (os.platform() === 'win32') {
return {
program: 'cmd.exe',
args: ['/s', '/c', command],
};
} else {
return {
program: '/bin/sh',
args: ['-c', command],
};
}
}
/**
* Call `func`, and if func() throws an error or returns a promise that
* eventually rejects, retry it `times` many times, each time printing a
@ -406,6 +378,40 @@ export async function which(
return programPath;
}
/**
* Call which(programName) and spawn() with the given arguments.
* Reject the promise if the process exit code is not zero.
*/
export async function whichSpawn(
programName: string,
args: string[],
options: SpawnOptions = { stdio: 'inherit' },
): Promise<void> {
const program = await which(programName);
if (process.env.DEBUG) {
console.error(`[debug] [${program}, ${args.join(', ')}]`);
}
let error: Error | undefined;
let exitCode: number | undefined;
try {
exitCode = await new Promise<number>((resolve, reject) => {
spawn(program, args, options)
.on('error', reject)
.on('close', resolve);
});
} catch (err) {
error = err;
}
if (error || exitCode) {
const msg = [
`${programName} failed with exit code ${exitCode}:`,
`[${program}, ${args.join(', ')}]`,
...(error ? [`${error}`] : []),
];
throw new Error(msg.join('\n'));
}
}
export interface ProxyConfig {
host: string;
port: string;

5
npm-shrinkwrap.json generated
View File

@ -2431,11 +2431,6 @@
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz",
"integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw=="
},
"bash": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/bash/-/bash-0.0.1.tgz",
"integrity": "sha1-nuDnp1K8Xu8Wi8SHGVVqdFSKrgw="
},
"bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",

View File

@ -177,7 +177,6 @@
"balena-semver": "^2.2.0",
"balena-settings-client": "^4.0.4",
"balena-sync": "^10.2.0",
"bash": "0.0.1",
"bluebird": "^3.7.2",
"body-parser": "^1.19.0",
"capitano": "^1.9.2",

View File

@ -1 +0,0 @@
declare module 'bash';