diff --git a/.eslintrc b/.eslintrc index 4e7faed021..63d9d98e8f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,7 +8,7 @@ "rules": { "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-unused-vars": "warn", // TODO: fix issues and turn this back on + "@typescript-eslint/no-unused-vars": ["warn", {"ignoreRestSiblings": true}], // TODO: fix issues and turn this back on "camelcase":"off", "import/no-unresolved": "error", "indent": ["error", 2, {"MemberExpression": 1}], diff --git a/packages/cli/package.json b/packages/cli/package.json index 7968f5ccde..5c3cf18296 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -63,13 +63,16 @@ "node-fetch": "^2.6.7", "open": "^8.4.2", "phoenix": "^1.6.14", + "portfinder": "^1.0.32", "printf": "0.6.1", "psl": "^1.9.0", + "redis-parser": "^3.0.0", "rollbar": "^2.26.2", "semver": "5.6.0", "shell-escape": "^0.2.0", "shell-quote": "^1.8.1", "sparkline": "^0.2.0", + "ssh2": "^1.15.0", "stdout-stderr": "^0.1.13", "strftime": "^0.10.0", "strip-ansi": "^6", @@ -103,8 +106,10 @@ "@types/phoenix": "^1.4.0", "@types/proxyquire": "^1.3.28", "@types/psl": "^1.1.3", + "@types/redis-parser": "^3.0.3", "@types/shell-escape": "^0.2.0", "@types/shell-quote": "^1.7.5", + "@types/ssh2": "^1.15.0", "@types/std-mocks": "^1.0.4", "@types/strftime": "^0.9.8", "@types/supports-color": "^5.3.0", diff --git a/packages/cli/src/commands/redis/cli.ts b/packages/cli/src/commands/redis/cli.ts new file mode 100644 index 0000000000..4661d6f0bc --- /dev/null +++ b/packages/cli/src/commands/redis/cli.ts @@ -0,0 +1,224 @@ +import {APIClient, Command, flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import * as Heroku from '@heroku-cli/schema' +import * as readline from 'readline' +import {Client} from 'ssh2' +import Parser = require('redis-parser') +import type {Writable} from 'node:stream' +import portfinder = require('portfinder') +import confirmApp from '../../lib/apps/confirm-app' +import * as tls from 'tls' +import type {Socket} from 'node:net' +import type {Duplex} from 'stream' +import {promisify} from 'node:util' +import * as net from 'net' +import type {RedisFormationResponse} from '../../lib/redis/api' +import apiFactory from '../../lib/redis/api' + +const REPLY_OK = 'OK' + +async function redisCLI(uri: URL, client: Writable): Promise { + 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: URL, bastions: string, config: Record, preferNativeTls: boolean) { + const tunnel: Client = await new Promise(resolve => { + const ssh2 = new Client() + resolve(ssh2) + ssh2.once('ready', () => resolve(ssh2)) + ssh2.connect({ + host: bastions.split(',')[0], + username: 'bastion', + privateKey: match(config, /_BASTION_KEY/) ?? '', + }) + }) + const localPort = await portfinder.getPortPromise({startPort: 49152, stopPort: 65535}) + const stream: Duplex = await promisify(tunnel.forwardOut)('localhost', localPort, uri.hostname, Number.parseInt(uri.port, 10)) + + let client: Duplex = stream + if (preferNativeTls) { + client = tls.connect({ + socket: stream as Socket, + 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: Record, lookup: RegExp): string | null { + for (const key in config) { + if (lookup.test(key)) { + return config[key] as string + } + } + + return null +} + +async function maybeTunnel(redis: RedisFormationResponse, config: Record) { + const bastions = match(config, /_BASTIONS/) + const hobby = redis.plan.indexOf('hobby') === 0 + const preferNativeTls = redis.prefer_native_tls + const uri = preferNativeTls && hobby ? new URL(match(config, /_TLS_URL/) ?? '') : 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) +} + +export default class Cli extends Command { + static topic = 'redis' + static description = 'opens a redis prompt' + static flags = { + confirm: flags.string({char: 'c'}), + app: flags.app({required: true}), + } + + static args = { + database: Args.string(), + } + + static examples = [ + '$ heroku redis:cli --app=my-app my-database', + '$ heroku redis:cli --app=my-app --confirm my-database', + ] + + public async run(): Promise { + const {flags, args} = await this.parse(Cli) + const api = apiFactory(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-')) { + 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 confirmApp(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(', ') + ux.log(`Connecting to ${addon.name} (${nonBastionVars}):`) + return maybeTunnel(redis, configVars) + } +} + +async function getRedisConfigVars(addon: Required, heroku: APIClient): Promise> { + const {body: config} = await heroku.get>(`/apps/${addon.billing_entity.name}/config-vars`) + const redisConfigVars: Record = {} + addon.config_vars.forEach(configVar => { + redisConfigVars[configVar] = config[configVar] + }) + return redisConfigVars +} diff --git a/packages/cli/src/lib/redis/api.ts b/packages/cli/src/lib/redis/api.ts index 1d8c0c731a..7fd7776c17 100644 --- a/packages/cli/src/lib/redis/api.ts +++ b/packages/cli/src/lib/redis/api.ts @@ -111,7 +111,7 @@ export default (app: string, database: string | undefined, json: boolean, heroku return onResponse }, - async getRedisAddon() { + async getRedisAddon(): Promise> { const {body: addons} = await heroku.get[]>(`/apps/${app}/addons`) const addonsFilter = this.makeAddonsFilter(database) @@ -126,7 +126,7 @@ export default (app: string, database: string | undefined, json: boolean, heroku ux.error(`Please specify a single instance. Found: ${names.join(', ')}`, {exit: 1}) } - return addons[0] + return redisAddons[0] as Required }, async info() { diff --git a/packages/cli/test/unit/commands/redis/cli.unit.test.ts b/packages/cli/test/unit/commands/redis/cli.unit.test.ts new file mode 100644 index 0000000000..0e63158803 --- /dev/null +++ b/packages/cli/test/unit/commands/redis/cli.unit.test.ts @@ -0,0 +1,365 @@ +import {stdout} from 'stdout-stderr' +import runCommand, {GenericCmd} from '../../../helpers/runCommand' +import {SinonStub} from 'sinon' +import {CLIError} from '@oclif/core/lib/errors' + +import * as nock from 'nock' +import * as sinon from 'sinon' +import {noCallThru} from 'proxyquire' +import {expect} from 'chai' +import {Duplex} from 'node:stream' + +const EventEmitter = require('events').EventEmitter + +class Client extends Duplex { + _write() {} + + _read() { + this.emit('end') + } +} + +class Tunnel extends EventEmitter { + forwardOut = sinon.stub().yields(null, new Client()) + connect = sinon.stub().callsFake(() => this.emit('ready')) + end = sinon.stub() +} +const addonId = '1dcb269b-8be5-4132-8aeb-e3f3c7364958' +const appId = '7b0ae612-8775-4502-a5b5-2b45a4d18b2d' +describe('heroku redis:cli', async () => { + describe('heroku redis:cli', async () => { + const proxyquire = noCallThru() + const command = proxyquire('../../../commands/cli.js', {net: {}, tls: {}, ssh2: {}}) + require('../../lib/redis/shared.unit.test.ts').shouldHandleArgs(command) + }) + + let command: GenericCmd + let net: {connect: SinonStub} + let tls: {connect: SinonStub} + let tunnel: {forwardOut: SinonStub, connect: SinonStub, end: SinonStub} + + beforeEach(function () { + net = { + connect: sinon.stub().returns(new Client()), + } + tls = { + connect: sinon.stub().returns(new Client()), + } + + const ssh2 = { + Client: function () { + tunnel = new Tunnel() + return tunnel + }, + } + const proxyquire = noCallThru() + const {default: Cmd} = proxyquire('../../../../src/commands/redis/cli', {net, tls, ssh2}) + command = Cmd + }) + + it('# for hobby it uses net.connect', async () => { + const app = nock('https://api.heroku.com:443') + .get('/apps/example/addons') + .reply(200, [ + { + id: addonId, + name: 'redis-haiku', + addon_service: {name: 'heroku-redis'}, + config_vars: ['REDIS_FOO', 'REDIS_BAR'], + billing_entity: { + id: appId, name: 'example', + }, + }, + ]) + const configVars = nock('https://api.heroku.com:443') + .get('/apps/example/config-vars') + .reply(200, {FOO: 'BAR'}) + const redis = nock('https://api.data.heroku.com:443') + .get(`/redis/v0/databases/${addonId}`) + .reply(200, { + resource_url: 'redis://foobar:password@example.com:8649', plan: 'hobby', + }) + await runCommand(command, [ + '--app', + 'example', + '--confirm', + 'example', + ]) + app.done() + configVars.done() + redis.done() + const outputParts = stdout.output.split('\n') + expect(outputParts[0]).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR):') + expect(outputParts[1]).to.equal('') + expect(outputParts[2]).to.equal('Disconnected from instance.') + expect(net.connect.called).to.equal(true) + }) + + it('# for hobby it uses TLS if prefer_native_tls', async () => { + const app = nock('https://api.heroku.com:443') + .get('/apps/example/addons') + .reply(200, [ + { + id: addonId, + name: 'redis-haiku', + addon_service: {name: 'heroku-redis'}, + config_vars: ['REDIS_FOO', 'REDIS_BAR', 'REDIS_TLS_URL'], + billing_entity: { + id: appId, name: 'example', + }, + }, + ]) + const configVars = nock('https://api.heroku.com:443') + .get('/apps/example/config-vars') + .reply(200, {REDIS_TLS_URL: 'rediss://foobar:password@example.com:8649'}) + const redis = nock('https://api.data.heroku.com:443') + .get(`/redis/v0/databases/${addonId}`) + .reply(200, { + resource_url: 'redis://foobar:password@example.com:8649', plan: 'hobby', prefer_native_tls: true, + }) + await runCommand(command, [ + '--app', + 'example', + '--confirm', + 'example', + ]) + app.done() + configVars.done() + redis.done() + const outputParts = stdout.output.split('\n') + expect(outputParts[0]).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR, REDIS_TLS_URL):') + expect(outputParts[1]).to.equal('') + expect(outputParts[2]).to.equal('Disconnected from instance.') + expect(tls.connect.called).to.equal(true) + }) + + it('# for premium it uses tls.connect', async () => { + const app = nock('https://api.heroku.com:443') + .get('/apps/example/addons') + .reply(200, [ + { + id: addonId, + name: 'redis-haiku', + addon_service: {name: 'heroku-redis'}, + config_vars: ['REDIS_FOO', 'REDIS_BAR'], + billing_entity: { + id: appId, name: 'example', + }, + }, + ]) + const configVars = nock('https://api.heroku.com:443') + .get('/apps/example/config-vars') + .reply(200, {FOO: 'BAR'}) + const redis = nock('https://api.data.heroku.com:443') + .get(`/redis/v0/databases/${addonId}`) + .reply(200, { + resource_url: 'redis://foobar:password@example.com:8649', plan: 'premium-0', + }) + await runCommand(command, [ + '--app', + 'example', + '--confirm', + 'example', + ]) + app.done() + configVars.done() + redis.done() + const outputParts = stdout.output.split('\n') + expect(outputParts[0]).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR):') + expect(outputParts[1]).to.equal('') + expect(outputParts[2]).to.equal('Disconnected from instance.') + expect(tls.connect.called).to.equal(true) + }) + + it('# exits with an error with shield databases', async function () { + const app = nock('https://api.heroku.com:443') + .get('/apps/example/addons') + .reply(200, [ + { + id: addonId, + name: 'redis-haiku', + addon_service: {name: 'heroku-redis'}, + config_vars: ['REDIS_FOO', 'REDIS_BAR'], + billing_entity: { + id: appId, name: 'example', + }, + }, + ]) + const configVars = nock('https://api.heroku.com:443') + .get('/apps/example/config-vars') + .reply(200, {FOO: 'BAR'}) + const redis = nock('https://api.data.heroku.com:443') + .get(`/redis/v0/databases/${addonId}`) + .reply(200, { + resource_url: 'redis://foobar:password@example.com:8649', plan: 'shield-9', + }) + try { + await runCommand(command, [ + '--app', + 'example', + '--confirm', + 'example', + ]) + expect(true, 'cli command should fail!').to.equal(false) + } catch (error) { + expect(error).to.be.an.instanceof(CLIError) + + if (error instanceof CLIError) { + const {oclif: {exit}, message} = error + expect(exit).to.equal(1) + expect(message).to.contain('Using redis:cli on Heroku Redis shield plans is not supported.') + } + } + + app.done() + redis.done() + configVars.done() + }) + + it('# for bastion it uses tunnel.connect', async () => { + const app = nock('https://api.heroku.com:443') + .get('/apps/example/addons') + .reply(200, [ + { + id: addonId, + name: 'redis-haiku', + addon_service: {name: 'heroku-redis'}, + config_vars: ['REDIS_URL', 'REDIS_BASTIONS', 'REDIS_BASTION_KEY', 'REDIS_BASTION_REKEYS_AFTER'], + billing_entity: { + id: appId, name: 'example', + }, + }, + ]) + const configVars = nock('https://api.heroku.com:443') + .get('/apps/example/config-vars') + .reply(200, {REDIS_BASTIONS: 'example.com'}) + const redis = nock('https://api.data.heroku.com:443') + .get(`/redis/v0/databases/${addonId}`) + .reply(200, { + resource_url: 'redis://foobar:password@example.com:8649', plan: 'premium-0', + }) + + await runCommand(command, [ + '--app', + 'example', + '--confirm', + 'example', + ]) + + app.done() + configVars.done() + redis.done() + const outputParts = stdout.output.split('\n') + expect(outputParts[0]).to.equal('Connecting to redis-haiku (REDIS_URL):') + expect(outputParts[1]).to.equal('') + expect(outputParts[2]).to.equal('Disconnected from instance.') + }) + + it('# for private spaces bastion with prefer_native_tls, it uses tls.connect', async () => { + const app = nock('https://api.heroku.com:443') + .get('/apps/example/addons') + .reply(200, [ + { + id: addonId, + name: 'redis-haiku', + addon_service: {name: 'heroku-redis'}, + config_vars: ['REDIS_URL', 'REDIS_BASTIONS', 'REDIS_BASTION_KEY', 'REDIS_BASTION_REKEYS_AFTER'], + billing_entity: { + id: appId, name: 'example', + }, + }, + ]) + const configVars = nock('https://api.heroku.com:443') + .get('/apps/example/config-vars') + .reply(200, {REDIS_BASTIONS: 'example.com'}) + const redis = nock('https://api.data.heroku.com:443') + .get(`/redis/v0/databases/${addonId}`) + .reply(200, { + resource_url: 'redis://foobar:password@example.com:8649', plan: 'private-7', prefer_native_tls: true, + }) + await runCommand(command, [ + '--app', + 'example', + '--confirm', + 'example', + ]) + app.done() + configVars.done() + redis.done() + const outputParts = stdout.output.split('\n') + expect(outputParts[0]).to.equal('Connecting to redis-haiku (REDIS_URL):') + expect(outputParts[1]).to.equal('') + expect(outputParts[2]).to.equal('Disconnected from instance.') + expect(tls.connect.called).to.equal(true) + }) + + it('# selects correct connection information when multiple redises are present across multiple apps', async () => { + const app = nock('https://api.heroku.com:443') + .get('/apps/example/addons') + .reply(200, [ + { + id: addonId, + name: 'redis-haiku', + addon_service: {name: 'heroku-redis'}, + config_vars: ['REDIS_URL', 'REDIS_BASTIONS', 'REDIS_BASTION_KEY', 'REDIS_BASTION_REKEYS_AFTER'], + billing_entity: { + id: appId, name: 'example', + }, + }, { + id: 'heroku-redis-addon-id-2', + name: 'redis-sonnet', + addon_service: {name: 'heroku-redis'}, + config_vars: ['REDIS_6_URL', 'REDIS_6_BASTIONS', 'REDIS_6_BASTION_KEY', 'REDIS_6_BASTION_REKEYS_AFTER'], + billing_entity: { + id: 'app-2-id', name: 'example-app-2', + }, + }, + ]) + const configVars = nock('https://api.heroku.com:443') + .get('/apps/example-app-2/config-vars') + .reply(200, { + REDIS_6_URL: 'redis-user@redis6-example.com', + REDIS_6_BASTIONS: 'redis-6-bastion.example.com', + REDIS_6_BASTION_KEY: 'key2', + }) + const redis = nock('https://api.data.heroku.com:443') + .get('/redis/v0/databases/heroku-redis-addon-id-2') + .reply(200, { + resource_url: 'redis://foobar:password@redis-6.example.com:8649', plan: 'private-7', prefer_native_tls: true, + }) + await runCommand(command, [ + '--app', + 'example', + '--confirm', + 'example', + 'redis-sonnet', + ]) + app.done() + configVars.done() + redis.done() + const outputParts = stdout.output.split('\n') + expect(outputParts[0]).to.equal('Connecting to redis-sonnet (REDIS_6_URL):') + expect(outputParts[1]).to.equal('') + expect(outputParts[2]).to.equal('Disconnected from instance.') + const connectArgs = tunnel.connect.args[0] + expect(connectArgs).to.have.length(1) + expect(connectArgs[0]).to.deep.equal({ + host: 'redis-6-bastion.example.com', privateKey: 'key2', username: 'bastion', + }) + const tunnelArgs = tunnel.forwardOut.args[0] + const [localAddr, localPort, remoteAddr, remotePort] = tunnelArgs + expect(localAddr).to.equal('localhost') + expect(localPort).to.be.a('number') + expect(remoteAddr).to.equal('redis-6.example.com') + expect(remotePort).to.equal(8649) + const tlsConnectArgs = tls.connect.args[0] + expect(tlsConnectArgs).to.have.length(1) + const tlsConnectOptions = { + ...tlsConnectArgs[0], + } + delete tlsConnectOptions.socket + expect(tlsConnectOptions).to.deep.equal({ + port: 8649, host: 'redis-6.example.com', rejectUnauthorized: false, + }) + }) +}) diff --git a/packages/redis-v5/commands/cli.js b/packages/redis-v5/commands/cli.js deleted file mode 100644 index 266e0f6973..0000000000 --- a/packages/redis-v5/commands/cli.js +++ /dev/null @@ -1,232 +0,0 @@ -'use strict' - -let cli = require('heroku-cli-util') -let net = require('net') -let Parser = require('redis-parser') -let readline = require('readline') -let tls = require('tls') -let url = require('url') -let Client = require('ssh2').Client - -const REPLY_OK = 'OK' - -function redisCLI(uri, client) { - let io = readline.createInterface(process.stdin, process.stdout) - let 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.auth.split(':')[1]}\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} -`) - }) - 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() - }) - }) -} - -function bastionConnect({uri, bastions, config, prefer_native_tls}) { - return new Promise((resolve, reject) => { - let tunnel = new Client() - tunnel.on('ready', function () { - // eslint-disable-next-line no-mixed-operators - let localPort = Math.floor(Math.random() * (65535 - 49152) + 49152) - tunnel.forwardOut('localhost', localPort, uri.hostname, uri.port, function (err, stream) { - if (err) return reject(err) - stream.on('close', () => tunnel.end()) - stream.on('end', () => client.end()) - - let client - if (prefer_native_tls) { - client = tls.connect({ - socket: stream, - port: Number.parseInt(uri.port, 10), - host: uri.hostname, - rejectUnauthorized: false, - }) - } else { - client = stream - } - - redisCLI(uri, client).then(resolve).catch(reject) - }) - }).connect({ - host: bastions.split(',')[0], - username: 'bastion', - privateKey: match(config, /_BASTION_KEY/), - }) - }) -} - -function match(config, lookup) { - for (var key in config) { - if (lookup.test(key)) { - return config[key] - } - } - - return null -} - -function maybeTunnel(redis, config) { - let bastions = match(config, /_BASTIONS/) - let hobby = redis.plan.indexOf('hobby') === 0 - let uri = url.parse(redis.resource_url) - let prefer_native_tls = redis.prefer_native_tls - - if (prefer_native_tls && hobby) { - uri = url.parse(match(config, /_TLS_URL/)) - } - - // eslint-disable-next-line no-negated-condition, no-eq-null, eqeqeq - if (bastions != null) { - return bastionConnect({uri, bastions, config, prefer_native_tls}) - // eslint-disable-next-line no-else-return - } else { - let client - if (prefer_native_tls) { - client = tls.connect({ - port: Number.parseInt(uri.port, 10), - host: uri.hostname, - rejectUnauthorized: false, - }) - } else if (!hobby) { - client = tls.connect({ - port: Number.parseInt(uri.port, 10) + 1, - host: uri.hostname, - rejectUnauthorized: false, - }) - } else { - client = net.connect({port: uri.port, host: uri.hostname}) - } - - return redisCLI(uri, client) - } -} - -module.exports = { - topic: 'redis', - command: 'cli', - needsApp: true, - needsAuth: true, - description: 'opens a redis prompt', - args: [{name: 'database', optional: true}], - flags: [{name: 'confirm', char: 'c', hasValue: true}], - run: cli.command({preauth: true}, async (context, heroku) => { - const api = require('../lib/shared')(context, heroku) - let addon = await api.getRedisAddon() - let configVars = await getRedisConfigVars(addon, heroku) - - let redis = await api.request(`/redis/v0/databases/${addon.name}`) - - if (redis.plan.startsWith('shield-')) { - cli.error(` - Using redis:cli on Heroku Redis shield plans is not supported. - Please see Heroku DevCenter for more details: https://devcenter.heroku.com/articles/shield-private-space#shield-features - `) - cli.exit(1) - } - - let hobby = redis.plan.indexOf('hobby') === 0 - let prefer_native_tls = redis.prefer_native_tls - - if (!prefer_native_tls && hobby) { - await cli.confirmApp(context.app, context.flags.confirm, 'WARNING: Insecure action.\nAll data, including the Redis password, will not be encrypted.') - } - - let nonBastionVars = Object.keys(configVars).filter(function (configVar) { - return !(/(?:BASTIONS|BASTION_KEY|BASTION_REKEYS_AFTER)$/.test(configVar)) - }).join(', ') - - cli.log(`Connecting to ${addon.name} (${nonBastionVars}):`) - return maybeTunnel(redis, configVars) - }), -} - -// try to lookup the right config vars from the billing app -async function getRedisConfigVars(addon, heroku) { - let config = await heroku.get(`/apps/${addon.billing_entity.name}/config-vars`) - - return addon.config_vars.reduce((memo, configVar) => { - memo[configVar] = config[configVar] - return memo - }, {}) -} diff --git a/packages/redis-v5/index.js b/packages/redis-v5/index.js index 1d0dff84c7..70fc94865b 100644 --- a/packages/redis-v5/index.js +++ b/packages/redis-v5/index.js @@ -1,5 +1,4 @@ exports.commands = [ - require('./commands/cli'), require('./commands/wait'), require('./commands/promote'), require('./commands/timeout'), diff --git a/packages/redis-v5/test/unit/commands/cli.unit.test.js b/packages/redis-v5/test/unit/commands/cli.unit.test.js deleted file mode 100644 index 8c80600c9b..0000000000 --- a/packages/redis-v5/test/unit/commands/cli.unit.test.js +++ /dev/null @@ -1,337 +0,0 @@ -'use strict' -/* globals beforeEach cli */ - -let nock = require('nock') -let sinon = require('sinon') -let proxyquire = require('proxyquire').noCallThru() -let expect = require('chai').expect -let Duplex = require('stream').Duplex -let EventEmitter = require('events').EventEmitter - -let command -let net -let tls -let tunnel - -describe('heroku redis:cli', function () { - let command = proxyquire('../../../commands/cli.js', {net: {}, tls: {}, ssh2: {}}) - require('../lib/shared.unit.test').shouldHandleArgs(command) -}) - -describe('heroku redis:cli', function () { - const addonId = '1dcb269b-8be5-4132-8aeb-e3f3c7364958' - const appId = '7b0ae612-8775-4502-a5b5-2b45a4d18b2d' - - beforeEach(function () { - cli.mockConsole() - cli.exit.mock() - - class Client extends Duplex { - _write(chunk, encoding, callback) {} - _read(size) { - this.emit('end') - } - } - - net = { - connect: sinon.stub().returns(new Client()), - } - - tls = { - connect: sinon.stub().returns(new Client()), - } - - class Tunnel extends EventEmitter { - constructor() { - super() - // eslint-disable-next-line unicorn/no-this-assignment - tunnel = this - this.forwardOut = sinon.stub().yields(null, new Client()) - this.connect = sinon.stub().callsFake(() => { - this.emit('ready') - }) - this.end = sinon.stub() - } - } - - let ssh2 = {Client: Tunnel} - - command = proxyquire('../../../commands/cli.js', {net, tls, ssh2}) - }) - - it('# for hobby it uses net.connect', function () { - let app = nock('https://api.heroku.com:443') - .get('/apps/example/addons').reply(200, [ - { - id: addonId, - name: 'redis-haiku', - addon_service: {name: 'heroku-redis'}, - config_vars: ['REDIS_FOO', 'REDIS_BAR'], - billing_entity: { - id: appId, - name: 'example', - }, - }, - ]) - - let configVars = nock('https://api.heroku.com:443') - .get('/apps/example/config-vars').reply(200, {FOO: 'BAR'}) - - let redis = nock('https://api.data.heroku.com:443') - .get('/redis/v0/databases/redis-haiku').reply(200, { - resource_url: 'redis://foobar:password@example.com:8649', - plan: 'hobby', - }) - return command.run({app: 'example', flags: {confirm: 'example'}, args: {}, auth: {username: 'foobar', password: 'password'}}) - .then(() => app.done()) - .then(() => configVars.done()) - .then(() => redis.done()) - .then(() => expect(cli.stdout).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR):\n')) - .then(() => expect(cli.stderr).to.equal('')) - .then(() => expect(net.connect.called).to.equal(true)) - }) - - it('# for hobby it uses TLS if prefer_native_tls', function () { - let app = nock('https://api.heroku.com:443') - .get('/apps/example/addons').reply(200, [ - { - id: addonId, - name: 'redis-haiku', - addon_service: {name: 'heroku-redis'}, - config_vars: ['REDIS_FOO', 'REDIS_BAR', 'REDIS_TLS_URL'], - billing_entity: { - id: appId, - name: 'example', - }, - }, - ]) - - let configVars = nock('https://api.heroku.com:443') - .get('/apps/example/config-vars').reply(200, {REDIS_TLS_URL: 'rediss://foobar:password@example.com:8649'}) - - let redis = nock('https://api.data.heroku.com:443') - .get('/redis/v0/databases/redis-haiku').reply(200, { - resource_url: 'redis://foobar:password@example.com:8649', - plan: 'hobby', - prefer_native_tls: true, - }) - return command.run({app: 'example', flags: {confirm: 'example'}, args: {}, auth: {username: 'foobar', password: 'password'}}) - .then(() => app.done()) - .then(() => configVars.done()) - .then(() => redis.done()) - .then(() => expect(cli.stdout).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR, REDIS_TLS_URL):\n')) - .then(() => expect(cli.stderr).to.equal('')) - .then(() => expect(tls.connect.called).to.equal(true)) - }) - - it('# for premium it uses tls.connect', function () { - let app = nock('https://api.heroku.com:443') - .get('/apps/example/addons').reply(200, [ - { - id: addonId, - name: 'redis-haiku', - addon_service: {name: 'heroku-redis'}, - config_vars: ['REDIS_FOO', 'REDIS_BAR'], - billing_entity: { - id: appId, - name: 'example', - }, - }, - ]) - - let configVars = nock('https://api.heroku.com:443') - .get('/apps/example/config-vars').reply(200, {FOO: 'BAR'}) - - let redis = nock('https://api.data.heroku.com:443') - .get('/redis/v0/databases/redis-haiku').reply(200, { - resource_url: 'redis://foobar:password@example.com:8649', - plan: 'premium-0', - }) - - return command.run({app: 'example', flags: {confirm: 'example'}, args: {}, auth: {username: 'foobar', password: 'password'}}) - .then(() => app.done()) - .then(() => configVars.done()) - .then(() => redis.done()) - .then(() => expect(cli.stdout).to.equal('Connecting to redis-haiku (REDIS_FOO, REDIS_BAR):\n')) - .then(() => expect(cli.stderr).to.equal('')) - .then(() => expect(tls.connect.called).to.equal(true)) - }) - - it('# exits with an error with shield databases', async function () { - let app = nock('https://api.heroku.com:443') - .get('/apps/example/addons').reply(200, [ - { - id: addonId, - name: 'redis-haiku', - addon_service: {name: 'heroku-redis'}, - config_vars: ['REDIS_FOO', 'REDIS_BAR'], - billing_entity: { - id: appId, - name: 'example', - }, - }, - ]) - - let configVars = nock('https://api.heroku.com:443') - .get('/apps/example/config-vars').reply(200, {FOO: 'BAR'}) - - let redis = nock('https://api.data.heroku.com:443') - .get('/redis/v0/databases/redis-haiku').reply(200, { - resource_url: 'redis://foobar:password@example.com:8649', - plan: 'shield-9', - }) - - try { - await command.run({app: 'example', flags: {confirm: 'example'}, args: {}, auth: {username: 'foobar', password: 'password'}}) - expect(true, 'cli command should fail!').to.equal(false) - } catch (error) { - expect(error).to.be.an.instanceof(cli.exit.ErrorExit) - expect(error.code).to.equal(1) - } - - await app.done() - await redis.done() - await configVars.done() - expect(cli.stderr).to.contain('Using redis:cli on Heroku Redis shield plans is not supported.') - }) - - it('# for bastion it uses tunnel.connect', function () { - let app = nock('https://api.heroku.com:443') - .get('/apps/example/addons').reply(200, [ - { - id: addonId, - name: 'redis-haiku', - addon_service: {name: 'heroku-redis'}, - config_vars: ['REDIS_URL', 'REDIS_BASTIONS', 'REDIS_BASTION_KEY', 'REDIS_BASTION_REKEYS_AFTER'], - billing_entity: { - id: appId, - name: 'example', - }, - }, - ]) - - let configVars = nock('https://api.heroku.com:443') - .get('/apps/example/config-vars').reply(200, {REDIS_BASTIONS: 'example.com'}) - - let redis = nock('https://api.data.heroku.com:443') - .get('/redis/v0/databases/redis-haiku').reply(200, { - resource_url: 'redis://foobar:password@example.com:8649', - plan: 'premium-0', - }) - - return command.run({app: 'example', flags: {confirm: 'example'}, args: {}, auth: {username: 'foobar', password: 'password'}}) - .then(() => app.done()) - .then(() => configVars.done()) - .then(() => redis.done()) - .then(() => expect(cli.stdout).to.equal('Connecting to redis-haiku (REDIS_URL):\n')) - .then(() => expect(cli.stderr).to.equal('')) - }) - - it('# for private spaces bastion with prefer_native_tls, it uses tls.connect', function () { - let app = nock('https://api.heroku.com:443') - .get('/apps/example/addons').reply(200, [ - {id: addonId, - name: 'redis-haiku', - addon_service: {name: 'heroku-redis'}, - config_vars: ['REDIS_URL', 'REDIS_BASTIONS', 'REDIS_BASTION_KEY', 'REDIS_BASTION_REKEYS_AFTER'], - billing_entity: { - id: appId, - name: 'example', - }, - }, - ]) - - let configVars = nock('https://api.heroku.com:443') - .get('/apps/example/config-vars').reply(200, {REDIS_BASTIONS: 'example.com'}) - - let redis = nock('https://api.data.heroku.com:443') - .get('/redis/v0/databases/redis-haiku').reply(200, { - resource_url: 'redis://foobar:password@example.com:8649', - plan: 'private-7', - prefer_native_tls: true, - }) - - return command.run({app: 'example', flags: {confirm: 'example'}, args: {}, auth: {username: 'foobar', password: 'password'}}) - .then(() => app.done()) - .then(() => configVars.done()) - .then(() => redis.done()) - .then(() => expect(cli.stdout).to.equal('Connecting to redis-haiku (REDIS_URL):\n')) - .then(() => expect(cli.stderr).to.equal('')) - .then(() => expect(tls.connect.called).to.equal(true)) - }) - - it('# selects correct connection information when multiple redises are present across multiple apps', async () => { - let app = nock('https://api.heroku.com:443') - .get('/apps/example/addons').reply(200, [ - { - id: addonId, - name: 'redis-haiku', - addon_service: {name: 'heroku-redis'}, - config_vars: ['REDIS_URL', 'REDIS_BASTIONS', 'REDIS_BASTION_KEY', 'REDIS_BASTION_REKEYS_AFTER'], - billing_entity: { - id: appId, - name: 'example', - }, - }, - { - id: 'heroku-redis-addon-id-2', - name: 'redis-sonnet', - addon_service: {name: 'heroku-redis'}, - config_vars: ['REDIS_6_URL', 'REDIS_6_BASTIONS', 'REDIS_6_BASTION_KEY', 'REDIS_6_BASTION_REKEYS_AFTER'], - billing_entity: { - id: 'app-2-id', - name: 'example-app-2', - }, - }, - ]) - - let configVars = nock('https://api.heroku.com:443') - .get('/apps/example-app-2/config-vars').reply(200, { - REDIS_6_URL: 'redis-user@redis6-example.com', - REDIS_6_BASTIONS: 'redis-6-bastion.example.com', - REDIS_6_BASTION_KEY: 'key2', - }) - - let redis = nock('https://api.data.heroku.com:443') - .get('/redis/v0/databases/redis-sonnet').reply(200, { - resource_url: 'redis://foobar:password@redis-6.example.com:8649', - plan: 'private-7', - prefer_native_tls: true, - }) - - await command.run({app: 'example', flags: {confirm: 'example'}, args: {database: 'redis-sonnet'}, auth: {username: 'foobar', password: 'password'}}) - app.done() - configVars.done() - redis.done() - - expect(cli.stdout).to.equal('Connecting to redis-sonnet (REDIS_6_URL):\n') - expect(cli.stderr).to.equal('') - - const connectArgs = tunnel.connect.args[0] - expect(connectArgs).to.have.length(1) - expect(connectArgs[0]).to.deep.equal({ - host: 'redis-6-bastion.example.com', - privateKey: 'key2', - username: 'bastion', - }) - - const tunnelArgs = tunnel.forwardOut.args[0] - const [localAddr, localPort, remoteAddr, remotePort] = tunnelArgs - expect(localAddr).to.equal('localhost') - expect(localPort).to.be.a('number') - expect(remoteAddr).to.equal('redis-6.example.com') - expect(remotePort).to.equal('8649') - - const tlsConnectArgs = tls.connect.args[0] - expect(tlsConnectArgs).to.have.length(1) - const tlsConnectOptions = { - ...tlsConnectArgs[0], - } - delete tlsConnectOptions.socket - expect(tlsConnectOptions).to.deep.equal({ - port: 8649, - host: 'redis-6.example.com', - rejectUnauthorized: false, - }) - }) -}) diff --git a/yarn.lock b/yarn.lock index 425344f838..60f53166d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5297,6 +5297,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^18.11.18": + version: 18.19.24 + resolution: "@types/node@npm:18.19.24" + dependencies: + undici-types: ~5.26.4 + checksum: 9b5d3ee4269c0934908dd8a0aa2adbc59467d90dcbad2967db4e63c4012b7142f1c7ea07a72e32e58f3b80f4d43ba10e9d355d2246fc1a028b25935e298dd501 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.1 resolution: "@types/normalize-package-data@npm:2.4.1" @@ -5339,6 +5348,23 @@ __metadata: languageName: node linkType: hard +"@types/redis-errors@npm:*": + version: 1.2.3 + resolution: "@types/redis-errors@npm:1.2.3" + checksum: fe6b9abbfdc58967360349c3d92aad1f29314c64ff3e136d7f19edcdbd3be7efc1a0363396b2821da81eaa5d0b7e88878eb839ba1c36275ebb34ff2b2e26cd3a + languageName: node + linkType: hard + +"@types/redis-parser@npm:^3.0.3": + version: 3.0.3 + resolution: "@types/redis-parser@npm:3.0.3" + dependencies: + "@types/node": "*" + "@types/redis-errors": "*" + checksum: 21bd046a32f593bc0bea106f76e91c4452860cb03db3bf6cf34089f03177fc86a34dc7bf57425ba3398ecd0ffae78f6c45cd5ed824f20403b9eb8a943207f0ac + languageName: node + linkType: hard + "@types/responselike@npm:^1.0.0": version: 1.0.0 resolution: "@types/responselike@npm:1.0.0" @@ -5383,6 +5409,15 @@ __metadata: languageName: node linkType: hard +"@types/ssh2@npm:^1.15.0": + version: 1.15.0 + resolution: "@types/ssh2@npm:1.15.0" + dependencies: + "@types/node": ^18.11.18 + checksum: d1c82b3fd1fee59d102fad44932c2f8bf6047506b9ca20856eed7484b1466a9901a9a3fbbfe41d7de71e8882b4cd5f634624773e69d63f0b8ab83a7a85731dce + languageName: node + linkType: hard + "@types/std-mocks@npm:^1.0.4": version: 1.0.4 resolution: "@types/std-mocks@npm:1.0.4" @@ -6254,7 +6289,7 @@ __metadata: languageName: node linkType: hard -"asn1@npm:^0.2.4, asn1@npm:~0.2.3": +"asn1@npm:^0.2.4, asn1@npm:^0.2.6, asn1@npm:~0.2.3": version: 0.2.6 resolution: "asn1@npm:0.2.6" dependencies: @@ -6318,6 +6353,15 @@ __metadata: languageName: node linkType: hard +"async@npm:^2.6.4": + version: 2.6.4 + resolution: "async@npm:2.6.4" + dependencies: + lodash: ^4.17.14 + checksum: a52083fb32e1ebe1d63e5c5624038bb30be68ff07a6c8d7dfe35e47c93fc144bd8652cbec869e0ac07d57dde387aa5f1386be3559cdee799cb1f789678d88e19 + languageName: node + linkType: hard + "async@npm:^3.2.3, async@npm:~3.2.3": version: 3.2.4 resolution: "async@npm:3.2.4" @@ -6618,6 +6662,13 @@ __metadata: languageName: node linkType: hard +"buildcheck@npm:~0.0.6": + version: 0.0.6 + resolution: "buildcheck@npm:0.0.6" + checksum: ad61759dc98d62e931df2c9f54ccac7b522e600c6e13bdcfdc2c9a872a818648c87765ee209c850f022174da4dd7c6a450c00357c5391705d26b9c5807c2a076 + languageName: node + linkType: hard + "builtin-modules@npm:^3.3.0": version: 3.3.0 resolution: "builtin-modules@npm:3.3.0" @@ -7787,6 +7838,17 @@ __metadata: languageName: node linkType: hard +"cpu-features@npm:~0.0.9": + version: 0.0.9 + resolution: "cpu-features@npm:0.0.9" + dependencies: + buildcheck: ~0.0.6 + nan: ^2.17.0 + node-gyp: latest + checksum: 1ff6045a16d32d9667d5dd69c7d485944494d3378ac9381c52bca772bd0c948812eaeda55a76ef09212b0c0e0c575e5d53221899ce51692b1196089452c5aef1 + languageName: node + linkType: hard + "create-require@npm:^1.1.0": version: 1.1.1 resolution: "create-require@npm:1.1.1" @@ -10917,8 +10979,10 @@ __metadata: "@types/phoenix": ^1.4.0 "@types/proxyquire": ^1.3.28 "@types/psl": ^1.1.3 + "@types/redis-parser": ^3.0.3 "@types/shell-escape": ^0.2.0 "@types/shell-quote": ^1.7.5 + "@types/ssh2": ^1.15.0 "@types/std-mocks": ^1.0.4 "@types/strftime": ^0.9.8 "@types/supports-color": ^5.3.0 @@ -10960,11 +11024,13 @@ __metadata: oclif: 3.11.3 open: ^8.4.2 phoenix: ^1.6.14 + portfinder: ^1.0.32 printf: 0.6.1 proxyquire: ^2.1.0 psl: ^1.9.0 qqjs: 0.3.11 read-pkg: ^4.0.1 + redis-parser: ^3.0.0 rimraf: 5.0.5 rollbar: ^2.26.2 semver: 5.6.0 @@ -10972,6 +11038,7 @@ __metadata: shell-quote: ^1.8.1 sinon: ^7.2.4 sparkline: ^0.2.0 + ssh2: ^1.15.0 std-mocks: ^2.0.0 stdout-stderr: ^0.1.13 strftime: ^0.10.0 @@ -13517,7 +13584,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^0.5.0, mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.2, mkdirp@npm:~0.5.1": +"mkdirp@npm:^0.5.0, mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.2, mkdirp@npm:^0.5.6, mkdirp@npm:~0.5.1": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" dependencies: @@ -13697,6 +13764,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.17.0, nan@npm:^2.18.0": + version: 2.19.0 + resolution: "nan@npm:2.19.0" + dependencies: + node-gyp: latest + checksum: 29a894a003c1954c250d690768c30e69cd91017e2e5eb21b294380f7cace425559508f5ffe3e329a751307140b0bd02f83af040740fa4def1a3869be6af39600 + languageName: node + linkType: hard + "nanoid@npm:3.3.1": version: 3.3.1 resolution: "nanoid@npm:3.3.1" @@ -15456,6 +15532,17 @@ __metadata: languageName: node linkType: hard +"portfinder@npm:^1.0.32": + version: 1.0.32 + resolution: "portfinder@npm:1.0.32" + dependencies: + async: ^2.6.4 + debug: ^3.2.7 + mkdirp: ^0.5.6 + checksum: 116b4aed1b9e16f6d5503823d966d9ffd41b1c2339e27f54c06cd2f3015a9d8ef53e2a53b57bc0a25af0885977b692007353aa28f9a0a98a44335cb50487240d + languageName: node + linkType: hard + "preferred-pm@npm:^3.0.3": version: 3.0.3 resolution: "preferred-pm@npm:3.0.3" @@ -17299,6 +17386,23 @@ __metadata: languageName: node linkType: hard +"ssh2@npm:^1.15.0": + version: 1.15.0 + resolution: "ssh2@npm:1.15.0" + dependencies: + asn1: ^0.2.6 + bcrypt-pbkdf: ^1.0.2 + cpu-features: ~0.0.9 + nan: ^2.18.0 + dependenciesMeta: + cpu-features: + optional: true + nan: + optional: true + checksum: 56baa07dc0dd8d97aefa05033b8a95d220a34b2f203aa9116173d7adc5e9fd46be22d7cfed99cdd9f5548862ae44abd1ec136e20ea856d5c470a0df0e5aea9d1 + languageName: node + linkType: hard + "sshpk@npm:^1.7.0": version: 1.16.1 resolution: "sshpk@npm:1.16.1"