diff --git a/src/apps.js b/src/apps.js index 82495a34c..f73d8c46f 100644 --- a/src/apps.js +++ b/src/apps.js @@ -2896,7 +2896,7 @@ async function uploadFile(app, sourceFilePath, destFilePath) { // the built-in bash printf understands "%q" but not /usr/bin/printf. // ' gets replaced with '\'' . the first closes the quote and last one starts a new one - const escapedDestFilePath = await shell.exec(`printf %q '${destFilePath.replace(/'/g, '\'\\\'\'')}'`, { shell: '/bin/bash' }); + const escapedDestFilePath = await shell.bash(`printf %q '${destFilePath.replace(/'/g, '\'\\\'\'')}'`, { encoding: 'utf8' }); debug(`uploadFile: ${sourceFilePath} -> ${escapedDestFilePath}`); const execId = await createExec(app, { cmd: [ 'bash', '-c', `cat - > ${escapedDestFilePath}` ], tty: false }); diff --git a/src/database.js b/src/database.js index 46034b4d5..8257f69e1 100644 --- a/src/database.js +++ b/src/database.js @@ -80,7 +80,7 @@ async function clear() { await fs.promises.writeFile('/tmp/extra.cnf', `[client]\nhost=${gDatabase.hostname}\nuser=${gDatabase.username}\npassword=${gDatabase.password}\ndatabase=${gDatabase.name}`, 'utf8'); const cmd = 'mysql --defaults-extra-file=/tmp/extra.cnf -Nse "SHOW TABLES" | grep -v "^migrations$" | while read table; do mysql --defaults-extra-file=/tmp/extra.cnf -e "SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE $table"; done'; - await shell.exec(cmd, { shell: '/bin/bash' }); + await shell.bash(cmd, {}); } async function query() { @@ -136,9 +136,8 @@ async function importFromFile(file) { assert.strictEqual(typeof file, 'string'); const cmd = `/usr/bin/mysql -h "${gDatabase.hostname}" -u ${gDatabase.username} -p${gDatabase.password} ${gDatabase.name} < ${file}`; - await query('CREATE DATABASE IF NOT EXISTS box'); - const [error] = await safe(shell.exec(cmd, { shell: '/bin/bash' })); + const [error] = await safe(shell.bash(cmd, {})); if (error) throw new BoxError(BoxError.DATABASE_ERROR, error); } @@ -152,6 +151,6 @@ async function exportToFile(file) { const cmd = `/usr/bin/mysqldump -h "${gDatabase.hostname}" -u root -p${gDatabase.password} ${colStats} --single-transaction --routines --triggers ${gDatabase.name} > "${file}"`; - const [error] = await safe(shell.exec(cmd, { shell: '/bin/bash' })); + const [error] = await safe(shell.bash(cmd, {})); if (error) throw new BoxError(BoxError.DATABASE_ERROR, error); } diff --git a/src/mailserver.js b/src/mailserver.js index 319166086..c60d73c43 100644 --- a/src/mailserver.js +++ b/src/mailserver.js @@ -194,7 +194,7 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) { ${readOnly} -v /run -v /tmp ${image} ${cmd}`; debug('configureMail: starting mail container'); - await shell.exec(runCmd, { shell: '/bin/bash' }); + await shell.bash(runCmd, {}); } async function restart() { diff --git a/src/mounts.js b/src/mounts.js index 0aaf4e0e1..27263da43 100644 --- a/src/mounts.js +++ b/src/mounts.js @@ -80,7 +80,7 @@ async function renderMountFile(mount) { let options, what, type; switch (mountType) { case 'cifs': { - const out = await shell.spawn('systemd-escape', [ '-p', hostPath ], {}); // this ensures uniqueness of creds file + const out = await shell.spawn('systemd-escape', [ '-p', hostPath ], { encoding: 'utf8' }); // this ensures uniqueness of creds file const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`); if (!safe.fs.writeFileSync(credentialsFilePath, `username=${mountOptions.username}\npassword=${mountOptions.password}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, `Could not write credentials file: ${safe.error.message}`); @@ -139,7 +139,7 @@ async function removeMount(mount) { const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${mountOptions.host}`); safe.fs.unlinkSync(keyFilePath); } else if (mountType === 'cifs') { - const out = await shell.spawn('systemd-escape', [ '-p', hostPath ], {}); + const out = await shell.spawn('systemd-escape', [ '-p', hostPath ], { encoding: 'utf8' }); const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`); safe.fs.unlinkSync(credentialsFilePath); } @@ -160,7 +160,8 @@ async function getStatus(mountType, hostPath) { let message; if (state !== 'active') { // find why it failed - const logsJson = await shell.exec(`journalctl -u $(systemd-escape -p --suffix=mount ${hostPath}) -n 10 --no-pager -o json`, { shell: '/bin/bash' }); + const unitName = await shell.spawn('systemd-escape', ['-p', '--suffix=mount', hostPath], { encoding: 'utf8' }); + const logsJson = await shell.spawn('journalctl', ['-u', unitName, '-n', '10', '--no-pager', '-o', 'json'], { encoding: 'utf8' }); if (logsJson) { const lines = logsJson.trim().split('\n').map(l => JSON.parse(l)); // array of json diff --git a/src/platform.js b/src/platform.js index d6656c80a..27b4f50a5 100644 --- a/src/platform.js +++ b/src/platform.js @@ -50,7 +50,7 @@ async function pruneInfraImages() { // cannot blindly remove all unused images since redis image may not be used const imageNames = Object.keys(infra.images).map(addon => infra.images[addon]); - const [error, output] = await safe(shell.exec('docker images --digests --format "{{.ID}} {{.Repository}} {{.Tag}} {{.Digest}}"', { shell: '/bin/bash' })); + const [error, output] = await safe(shell.bash('docker images --digests --format "{{.ID}} {{.Repository}} {{.Tag}} {{.Digest}}"', { encoding: 'utf8' })); if (error) { debug(`Failed to list images ${error.message}`); throw error; @@ -95,8 +95,12 @@ async function createDockerNetwork() { async function removeAllContainers() { debug('removeAllContainers: removing all containers for infra upgrade'); - await shell.exec('docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker stop', { shell: '/bin/bash' }); - await shell.exec('docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker rm -f', { shell: '/bin/bash' }); + const output = await shell.spawn('docker', ['ps', '-qa', '--filter', 'label=isCloudronManaged'], { encoding: 'utf8' }); + for (const containerId of output.trim().split('\n')) { + debug(`removeAllContainers: stopping and removing ${containerId}`); + await shell.spawn('docker', ['stop', containerId], {}); + await shell.spawn('docker', ['rm', '-f', containerId], {}); + } } async function markApps(existingInfra, restoreOptions) { diff --git a/src/services.js b/src/services.js index 3a26df043..60657e704 100644 --- a/src/services.js +++ b/src/services.js @@ -969,7 +969,7 @@ async function startTurn(existingInfra) { await docker.deleteContainer('turn'); debug('startTurn: starting turn container'); - await shell.exec(runCmd, { shell: '/bin/bash' }); + await shell.bash(runCmd, {}); } async function teardownTurn(app, options) { @@ -1177,7 +1177,7 @@ async function startMysql(existingInfra) { await docker.deleteContainer('mysql'); debug('startMysql: starting mysql container'); - await shell.exec(runCmd, { shell: '/bin/bash' }); + await shell.bash(runCmd, {}); if (!serviceConfig.recoveryMode) { await waitForContainer('mysql', 'CLOUDRON_MYSQL_TOKEN'); @@ -1395,7 +1395,7 @@ async function startPostgresql(existingInfra) { await docker.deleteContainer('postgresql'); debug('startPostgresql: starting postgresql container'); - await shell.exec(runCmd, { shell: '/bin/bash' }); + await shell.bash(runCmd, {}); if (!serviceConfig.recoveryMode) { await waitForContainer('postgresql', 'CLOUDRON_POSTGRESQL_TOKEN'); @@ -1544,7 +1544,7 @@ async function startMongodb(existingInfra) { } debug('startMongodb: starting mongodb container'); - await shell.exec(runCmd, { shell: '/bin/bash' }); + await shell.bash(runCmd, {}); if (!serviceConfig.recoveryMode) { await waitForContainer('mongodb', 'CLOUDRON_MONGODB_TOKEN'); @@ -1715,7 +1715,7 @@ async function startGraphite(existingInfra) { if (upgrading) await shell.promises.sudo([ RMADDONDIR_CMD, 'graphite' ], {}); debug('startGraphite: starting graphite container'); - await shell.exec(runCmd, { shell: '/bin/bash' }); + await shell.bash(runCmd, {}); // restart collectd to get the disk stats after graphite starts. currently, there is no way to do graphite health check setTimeout(async () => await safe(shell.promises.sudo([ RESTART_SERVICE_CMD, 'collectd' ], {})), 60000); @@ -1860,7 +1860,7 @@ async function setupRedis(app, options) { const [inspectError, result] = await safe(docker.inspect(redisName)); if (inspectError) { - await shell.exec(runCmd, { shell: '/bin/bash' }); + await shell.bash(runCmd, {}); } else { // fast path debug(`Re-using existing redis container with state: ${JSON.stringify(result.State)}`); } diff --git a/src/sftp.js b/src/sftp.js index 6ac9f10ad..8988924a6 100644 --- a/src/sftp.js +++ b/src/sftp.js @@ -34,7 +34,7 @@ async function ensureKeys() { debug(`ensureSecrets: generating new sftp keys of type ${keyType}`); safe.fs.unlinkSync(publicKeyFile); safe.fs.unlinkSync(privateKeyFile); - const [error] = await safe(shell.exec(`ssh-keygen -m PEM -t ${keyType} -f ${paths.SFTP_KEYS_DIR}/ssh_host_${keyType}_key -q -N ""`, { shell: '/bin/bash' })); + const [error] = await safe(shell.spawn('ssh-keygen', ['-m', 'PEM', '-t', keyType, '-f', `${paths.SFTP_KEYS_DIR}/ssh_host_${keyType}_key`, '-q', '-N', ''], {})); if (error) throw new BoxError(BoxError.OPENSSL_ERROR, `Could not generate sftp ${keyType} keys: ${error.message}`); const newPublicKey = safe.fs.readFileSync(publicKeyFile); await blobs.set(`sftp_${keyType}_public_key`, newPublicKey); @@ -124,7 +124,7 @@ async function start(existingInfra) { await docker.deleteContainer('sftp'); debug('startSftp: starting sftp container'); - await shell.exec(runCmd, { shell: '/bin/bash' }); + await shell.bash(runCmd, {}); } async function status() { diff --git a/src/shell.js b/src/shell.js index b8ed3743e..e27f72ccb 100644 --- a/src/shell.js +++ b/src/shell.js @@ -13,7 +13,7 @@ function shell(tag) { assert.strictEqual(typeof tag, 'string'); return { - exec: exec.bind(null, tag), + bash: bash.bind(null, tag), spawn: spawn.bind(null, tag), sudo: sudo.bind(null, tag), promises: { sudo: util.promisify(sudo.bind(null, tag)) } @@ -22,19 +22,16 @@ function shell(tag) { const SUDO = '/usr/bin/sudo'; -// default no shell, handles input, separate args, wait for process to finish function spawn(tag, file, args, options) { assert.strictEqual(typeof tag, 'string'); assert.strictEqual(typeof file, 'string'); assert(Array.isArray(args)); - assert.strictEqual(typeof options, 'object'); + assert.strictEqual(typeof options, 'object'); // note: spawn() has no encoding option of it's own - debug(`${tag}: ${file} ${JSON.stringify(args)}`); - - const spawnOptions = Object.assign({ shell: false }, options); // note: no encoding! + debug(`${tag}: ${file} ${args.join(' ').replace(/\n/g, '\\n')}`); return new Promise((resolve, reject) => { - const cp = child_process.spawn(file, args, spawnOptions); + const cp = child_process.spawn(file, args, options); const stdoutBuffers = [], stderrBuffers = []; cp.stdout.on('data', (data) => stdoutBuffers.push(data)); @@ -71,39 +68,12 @@ function spawn(tag, file, args, options) { }); } -// default encoding utf8, no shell, handles input, full command, wait for process to finish -async function exec(tag, cmd, options) { +async function bash(tag, script, options) { assert.strictEqual(typeof tag, 'string'); - assert.strictEqual(typeof cmd, 'string'); + assert.strictEqual(typeof script, 'string'); assert.strictEqual(typeof options, 'object'); - if (!options.shell) { - cmd = cmd.replace(/\s+/g, ' '); // collapse spaces when not using shell. note: no more complexity like parsing quotes here! - const [file, ...args] = cmd.split(' '); - return await spawn(tag, file, args, options); - } - - debug(`${tag} exec: ${cmd}`); - - return new Promise((resolve, reject) => { - const cp = child_process.exec(cmd, options, function (error, stdout, stderr) { - if (!error) return resolve(stdout); - - const e = new BoxError(BoxError.SHELL_ERROR, `${tag} errored with code ${error.code} message ${error.message}`); - e.code = error.code; - e.signal = error.signal; - e.stdout = stdout; // when promisified, this is the way to get stdout - e.stderr = stderr; // when promisified, this is the way to get stderr - debug(`${tag}: ${cmd} errored`, error); - reject(e); - }); - - // https://github.com/nodejs/node/issues/25231 - if (options.input) { - cp.stdin.write(options.input); - cp.stdin.end(); - } - }); + return await spawn(tag, '/bin/bash', [ '-c', script ], options); } function sudo(tag, args, options, callback) { diff --git a/src/test/shell-test.js b/src/test/shell-test.js index de69563f7..d93fe923e 100644 --- a/src/test/shell-test.js +++ b/src/test/shell-test.js @@ -64,14 +64,17 @@ describe('shell', function () { const [error] = await safe(shell.spawn('sleep', ['20'], { timeout: 1000 })); expect(error.reason).to.be(BoxError.SHELL_ERROR); }); + }); - it('cannot exec a shell program by default', async function () { - const [error] = await safe(shell.exec('ls -l | wc -c', {})); - expect(error.reason).to.be(BoxError.SHELL_ERROR); + describe('bash', function () { + it('can bash a shell program', async function () { + const out = await shell.bash('ls -l | wc -c', {}); + expect(Buffer.isBuffer(out)).to.be(true); }); - it('cannot exec a shell program b', async function () { - await shell.exec('ls -l | wc -c', { shell: '/bin/bash' }); + it('can bash a shell program', async function () { + const out = await shell.bash('ls -l | wc -c', { encoding: 'utf8' }); + expect(out).to.be.a('string'); }); }); }); diff --git a/src/updater.js b/src/updater.js index ffa0f62c5..ada180db2 100644 --- a/src/updater.js +++ b/src/updater.js @@ -117,7 +117,13 @@ async function verifyUpdateInfo(versionsFile, updateInfo) { async function downloadAndVerifyRelease(updateInfo) { assert.strictEqual(typeof updateInfo, 'object'); - await safe(shell.exec(`rm -rf ${path.join(os.tmpdir(), 'box-*')}`, { shell: '/bin/bash' }), { debug }); // remove any old artifacts + const filenames = await fs.promises.readdir(os.tmpdir()); + const oldArtifactNames = filenames.filter(f => f.startsWith('box-')); + for (const artifactName of oldArtifactNames) { + const fullPath = path.join(os.tmpdir(), artifactName); + debug(`downloadAndVerifyRelease: removing old artifact ${fullPath}`); + await fs.promises.rm(fullPath, { recursive: true, force: true }); + } await downloadUrl(updateInfo.boxVersionsUrl, `${paths.UPDATE_DIR}/versions.json`); await downloadUrl(updateInfo.boxVersionsSigUrl, `${paths.UPDATE_DIR}/versions.json.sig`);