UNPKG

heroku

Version:

CLI to interact with Heroku

204 lines (203 loc) 7.69 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const command_1 = require("@heroku-cli/command"); const core_1 = require("@oclif/core"); const readline = require("readline"); const ssh2_1 = require("ssh2"); const Parser = require("redis-parser"); const portfinder = require("portfinder"); const confirmCommand_1 = require("../../lib/confirmCommand"); const tls = require("tls"); const node_util_1 = require("node:util"); const net = require("net"); const api_1 = require("../../lib/redis/api"); const REPLY_OK = 'OK'; async function redisCLI(uri, client) { const io = readline.createInterface(process.stdin, process.stdout); const reply = new Parser({ returnReply(reply) { switch (state) { case 'monitoring': if (reply !== REPLY_OK) { console.log(reply); } break; case 'subscriber': if (Array.isArray(reply)) { reply.forEach(function (value, i) { console.log(`${i + 1}) ${value}`); }); } else { console.log(reply); } break; case 'connect': if (reply !== REPLY_OK) { console.log(reply); } state = 'normal'; io.prompt(); break; case 'closing': if (reply !== REPLY_OK) { console.log(reply); } break; default: if (Array.isArray(reply)) { reply.forEach(function (value, i) { console.log(`${i + 1}) ${value}`); }); } else { console.log(reply); } io.prompt(); break; } }, returnError(err) { console.log(err.message); io.prompt(); }, returnFatalError(err) { client.emit('error', err); console.dir(err); }, }); let state = 'connect'; client.write(`AUTH ${uri.password}\n`); io.setPrompt(uri.host + '> '); io.on('line', function (line) { switch (line.split(' ')[0]) { case 'MONITOR': state = 'monitoring'; break; case 'PSUBSCRIBE': case 'SUBSCRIBE': state = 'subscriber'; break; } client.write(`${line}\n`); }); io.on('close', function () { state = 'closing'; client.write('QUIT\n'); }); client.on('data', function (data) { reply.execute(data); }); return new Promise((resolve, reject) => { client.on('error', reject); client.on('end', function () { console.log('\nDisconnected from instance.'); io.close(); resolve(); }); }); } async function bastionConnect(uri, bastions, config, preferNativeTls) { const tunnel = await new Promise(resolve => { var _a; const ssh2 = new ssh2_1.Client(); ssh2.once('ready', () => resolve(ssh2)); ssh2.connect({ host: bastions.split(',')[0], username: 'bastion', privateKey: (_a = match(config, /_BASTION_KEY/)) !== null && _a !== void 0 ? _a : '', }); }); const localPort = await portfinder.getPortPromise({ startPort: 49152, stopPort: 65535 }); const stream = await (0, node_util_1.promisify)(tunnel.forwardOut.bind(tunnel))('localhost', localPort, uri.hostname, Number.parseInt(uri.port, 10)); let client = stream; if (preferNativeTls) { client = tls.connect({ socket: stream, port: Number.parseInt(uri.port, 10), host: uri.hostname, rejectUnauthorized: false, }); } stream.on('close', () => tunnel.end()); stream.on('end', () => client.end()); return redisCLI(uri, client); } function match(config, lookup) { for (const key in config) { if (lookup.test(key)) { return config[key]; } } return null; } async function maybeTunnel(redis, config) { var _a; const bastions = match(config, /_BASTIONS/); const hobby = redis.plan.indexOf('hobby') === 0; const preferNativeTls = redis.prefer_native_tls; const uri = preferNativeTls && hobby ? new URL((_a = match(config, /_TLS_URL/)) !== null && _a !== void 0 ? _a : '') : new URL(redis.resource_url); if (bastions !== null) { return bastionConnect(uri, bastions, config, preferNativeTls); } let client; if (preferNativeTls) { client = tls.connect({ port: Number.parseInt(uri.port, 10), host: uri.hostname, rejectUnauthorized: false, }); } else if (hobby) { client = net.connect({ port: Number.parseInt(uri.port, 10), host: uri.hostname }); } else { client = tls.connect({ port: Number.parseInt(uri.port, 10) + 1, host: uri.hostname, rejectUnauthorized: false, }); } return redisCLI(uri, client); } class Cli extends command_1.Command { async run() { const { flags, args } = await this.parse(Cli); const api = (0, api_1.default)(flags.app, args.database, false, this.heroku); const addon = await api.getRedisAddon(); const configVars = await getRedisConfigVars(addon, this.heroku); const { body: redis } = await api.request(`/redis/v0/databases/${addon.id}`); if (redis.plan.startsWith('shield-')) { core_1.ux.error('\n Using redis:cli on Heroku Redis shield plans is not supported.\n Please see Heroku DevCenter for more details: https://devcenter.heroku.com/articles/shield-private-space#shield-features\n ', { exit: 1 }); } const hobby = redis.plan.indexOf('hobby') === 0; const prefer_native_tls = redis.prefer_native_tls; if (!prefer_native_tls && hobby) { await (0, confirmCommand_1.default)(flags.app, flags.confirm, 'WARNING: Insecure action.\nAll data, including the Redis password, will not be encrypted.'); } const nonBastionVars = Object.keys(configVars) .filter(function (configVar) { return !(/(?:BASTIONS|BASTION_KEY|BASTION_REKEYS_AFTER)$/.test(configVar)); }) .join(', '); core_1.ux.log(`Connecting to ${addon.name} (${nonBastionVars}):`); return maybeTunnel(redis, configVars); } } exports.default = Cli; Cli.topic = 'redis'; Cli.description = 'opens a redis prompt'; Cli.flags = { confirm: command_1.flags.string({ char: 'c' }), app: command_1.flags.app({ required: true }), remote: command_1.flags.remote(), }; Cli.args = { database: core_1.Args.string({ description: 'name of the Key-Value Store database. If omitted, it defaults to the primary database associated with the app.' }), }; Cli.examples = [ '$ heroku redis:cli --app=my-app my-database', '$ heroku redis:cli --app=my-app --confirm my-database', ]; async function getRedisConfigVars(addon, heroku) { const { body: config } = await heroku.get(`/apps/${addon.billing_entity.name}/config-vars`); const redisConfigVars = {}; addon.config_vars.forEach(configVar => { redisConfigVars[configVar] = config[configVar]; }); return redisConfigVars; }