diff --git a/package-lock.json b/package-lock.json index dfe5ce81d..3708fc143 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,13 +56,15 @@ "xml2js": "^0.4.23" }, "devDependencies": { + "commander": "^10.0.0", "eslint": "^8.36.0", "expect.js": "*", "hock": "^1.4.1", "js2xmlparser": "^5.0.0", "mocha": "^10.2.0", "mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git", - "nock": "^13.3.0" + "nock": "^13.3.0", + "ssh2": "^1.11.0" } }, "node_modules/@balena/dockerignore": { @@ -803,6 +805,16 @@ "version": "1.0.1", "license": "BSD-3-Clause" }, + "node_modules/buildcheck": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.3.tgz", + "integrity": "sha512-pziaA+p/wdVImfcbsZLNF32EiWyujlQLwolMqUQE8xpKNOH7KmZQaY8sXN7DGOEzPAElo9QTaeNRfGnf3iOJbA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "license": "MIT", @@ -1004,6 +1016,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.0.tgz", + "integrity": "sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/component-emitter": { "version": "1.3.0", "license": "MIT" @@ -1214,6 +1235,21 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/cpu-features": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.4.tgz", + "integrity": "sha512-fKiZ/zp1mUwQbnzb9IghXtHtDoTMtNeb8oYGx6kX2SYfhnG0HNdBEBIzB9b5KlXu5DQPhfy3mInbBxFcgwAr3A==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "0.0.3", + "nan": "^2.15.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cron": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cron/-/cron-2.3.0.tgz", @@ -3923,6 +3959,13 @@ "version": "2.1.2", "license": "ISC" }, + "node_modules/nan": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", + "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==", + "dev": true, + "optional": true + }, "node_modules/nanoid": { "version": "3.3.3", "dev": true, @@ -4961,16 +5004,27 @@ } }, "node_modules/ssh2": { - "version": "0.5.4", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.11.0.tgz", + "integrity": "sha512-nfg0wZWGSsfUe/IBJkXVll3PEZ//YH2guww+mP88gTpuSU4FtZN7zu9JoeTGOyCNx2dTDtT9fOpWwlzyj4uOOw==", + "dev": true, + "hasInstallScript": true, "dependencies": { - "ssh2-streams": "~0.1.15" + "asn1": "^0.2.4", + "bcrypt-pbkdf": "^1.0.2" }, "engines": { - "node": ">=0.10.0" + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.4", + "nan": "^2.16.0" } }, "node_modules/ssh2-streams": { "version": "0.1.20", + "resolved": "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.1.20.tgz", + "integrity": "sha512-uqI2NfwMXF0PgY1IWivWWlfr4Ws6wsFF5Eug/bmpyyVn/k7T2VoNfJT6ynhM0JW1NpeIZuYHOENUCLx6NFK6Jw==", "dependencies": { "asn1": "~0.2.0", "semver": "^5.1.0", @@ -4982,7 +5036,8 @@ }, "node_modules/ssh2-streams/node_modules/semver": { "version": "5.7.1", - "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", "bin": { "semver": "bin/semver" } @@ -5291,6 +5346,17 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/tunnel-ssh/node_modules/ssh2": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-0.5.4.tgz", + "integrity": "sha512-ZnC+u9CRgg0BKkYrpygna2723zhRxOtsEcrjsSCwJCZvj95fbE5qdCEndqQtbzA3IgftlmAgNafyy20kh9tbqw==", + "dependencies": { + "ssh2-streams": "~0.1.15" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tv4": { "version": "1.3.0", "license": [ diff --git a/package.json b/package.json index 95aa510ae..11662756b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "type": "git", "url": "https://git.cloudron.io/cloudron/box.git" }, + "bin": { + "hotfix": "./scripts/hotfix" + }, "dependencies": { "@google-cloud/dns": "^3.0.2", "@google-cloud/storage": "^6.9.4", @@ -59,13 +62,15 @@ "xml2js": "^0.4.23" }, "devDependencies": { + "commander": "^10.0.0", "eslint": "^8.36.0", "expect.js": "*", "hock": "^1.4.1", "js2xmlparser": "^5.0.0", "mocha": "^10.2.0", "mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git", - "nock": "^13.3.0" + "nock": "^13.3.0", + "ssh2": "^1.11.0" }, "scripts": { "test": "./run-tests" diff --git a/scripts/hotfix b/scripts/hotfix new file mode 100755 index 000000000..e56742d3f --- /dev/null +++ b/scripts/hotfix @@ -0,0 +1,160 @@ +#!/usr/bin/env node + +'use strict'; + +const program = require('commander'), + assert = require('assert'), + async = require('async'), + execSync = require('child_process').execSync, + fs = require('fs'), + ipaddr = require('ipaddr.js'), + os = require('os'), + path = require('path'), + safe = require('safetydance'), + SshClient = require('ssh2').Client, + util = require('util'); + +function exit(error) { + if (error instanceof Error) console.log(error.message); + else if (error) console.error(util.format.apply(null, Array.prototype.slice.call(arguments))); + + process.exit(error ? 1 : 0); +} + +function findSshKey(key) { + assert.strictEqual(typeof key, 'string'); + + let test = key; + + // remove .pub in case the user passed in the public key + if (path.extname(test) === '.pub') test = test.slice(0, -4); + + if (fs.existsSync(test)) return test; + + test = path.join(os.homedir(), '.ssh', key); + if (fs.existsSync(test)) return test; + + test = path.join(os.homedir(), '.ssh', 'id_ed25519_' + key); + if (fs.existsSync(test)) return test; + + test = path.join(os.homedir(), '.ssh', 'id_rsa_' + key); + if (fs.existsSync(test)) return test; + + test = path.join(os.homedir(), '.ssh', 'id_rsa_caas_' + key); + if (fs.existsSync(test)) return test; + + return null; +} + +function sshExec(host, port, sshKey, username, cmds, callback) { + const passphrase = process.env.SSH_PASSPHRASE || undefined; + console.log(`connecting ${username}@${host}:${port} with ${sshKey} (passphrase: ${passphrase || ''})`); + + const sshClient = new SshClient(); + sshClient.connect({ + host, + port, + username, + privateKey: fs.readFileSync(sshKey), + passphrase, + // debug: (s) => { console.log(s); } // https://github.com/mscdex/ssh2/issues/989 + }); + + sshClient.on('ready', function () { + console.log('connected'); + + async.eachSeries(cmds, function (cmd, iteratorDone) { + const command = cmd.cmd; + + console.log(`Executing: ${command}`); + + sshClient.exec(command, function(err, stream) { + if (err) return callback(err); + + if (cmd.stdin) cmd.stdin.pipe(stream); + stream.pipe(process.stdout); + stream.on('close', function () { + iteratorDone(); + }).stderr.pipe(process.stderr); + }); + }, function seriesDone(error) { + if (error) return callback(error); + + sshClient.end(); + }); + }); + sshClient.on('end', function () { + console.log('\ndisconnected'); + callback(); + }); + sshClient.on('error', function (error) { + console.log(`SSH client error: ${error.message}`); + callback(error); + }); + sshClient.on('exit', function (exitCode) { + callback(exitCode === 0 ? null : new Error('ssh exec returned + ' + exitCode)); + }); +} + +function hotfix(options) { + assert.strictEqual(typeof options, 'object'); + + if (!options.cloudron) exit('--cloudron is required'); + if (!options.release) exit('--release is required'); + if (!options.sshKey) exit('--ssh-key is required'); + + let ip; + if (ipaddr.isValid(options.cloudron)) { + ip = options.cloudron; + } else { + let out = safe.child_process.execSync(`host -t A ${options.cloudron}`, { encoding: 'utf8' }); + if (!out) exit(`Could not resolve ${options.cloudron}`); + ip = out.trim().split(' ')[3]; + } + + const sshKey = findSshKey(options.sshKey); + if (!sshKey) exit('Unable to find SSH key'); + + const version = options.release; + const sshPort = options.sshPort || 22; + const sshUser = options.sshUser || 'root'; + + let tarballScript = path.join(__dirname, 'create-release-tarball'); + if (!fs.existsSync(tarballScript)) exit('Could not find create-release-taball script. Run this command from the box repo checkout directory.'); + + const tarball = `${os.tmpdir()}/boxtarball-${version}.tar.gz`; + try { + execSync(`${tarballScript} --output ${tarball} --version ${version}`, { stdio: [ null, process.stdout, process.stderr ] }); + } catch (e) { + console.log('Unable to create version tarball.'); + process.exit(1); + } + + const cmds = [ + { cmd: 'sudo rm -rf /tmp/box-src-hotfix' }, + { cmd: 'sudo mkdir -p /tmp/box-src-hotfix' }, + { cmd: 'sudo tar zxf - -C /tmp/box-src-hotfix', stdin: fs.createReadStream(tarball) }, + { cmd: 'sudo dd of=/tmp/remote_hotfix.js', stdin: fs.createReadStream(path.resolve(__dirname, './remote_hotfix.js')) }, + { cmd: 'sudo HOME=/home/yellowtent BOX_ENV=cloudron node /tmp/remote_hotfix.js' }, + { cmd: 'sudo rm -rf /tmp/box-src-hotfix' } + ]; + + sshExec(ip, sshPort, sshKey, sshUser, cmds, function (error) { + if (error) exit(error); + + console.log('Done patching'); + }); +} + +// main commander setup +program.description('Hotfix Cloudron with latest code') + .version('1.0.0') + .option('--cloudron ', 'Cloudron domain or IP') + .option('--release ', 'Cloudron release version') + .option('--ssh-port ', 'SSH port') + .option('--ssh-key ', 'SSH Key file path') + .option('--ssh-user ', 'SSH username'); + +program.parse(); + +hotfix(program.opts()); diff --git a/scripts/remote_hotfix.js b/scripts/remote_hotfix.js new file mode 100644 index 000000000..8ea9b46cd --- /dev/null +++ b/scripts/remote_hotfix.js @@ -0,0 +1,22 @@ +'use strict'; + +if (process.env.BOX_ENV !== 'cloudron') { + console.error('!! This is only meant to be run with cloudron hotfix'); + process.exit(1); +} + +const spawn = require('child_process').spawn, + path = require('path'); + +const NEW_BOX_SOURCE_DIR = '/tmp/box-src-hotfix'; + +console.log('=> Running installer.sh'); +const installer = spawn(path.join(NEW_BOX_SOURCE_DIR, 'scripts/installer.sh'), []); + +installer.stdout.pipe(process.stdout); +installer.stderr.pipe(process.stderr); + +installer.on('exit', function (code) { + console.log('Finished with code', code); + process.exit(code); +});