1 | 'use strict'
|
2 |
|
3 | const cli = require('heroku-cli-util')
|
4 | const path = require('path');
|
5 | const child = require('child_process');
|
6 | const url = require('url');
|
7 | const co = require('co');
|
8 | const keypair = require('keypair');
|
9 | const forge = require('node-forge');
|
10 | const socks = require('@heroku/socksv5');
|
11 | const ssh = require('./ssh')
|
12 | const wait = require('co-wait')
|
13 | const Client = require('ssh2').Client;
|
14 |
|
15 | function * checkStatus(context, heroku, configVars) {
|
16 | let dynos = yield heroku.request({path: `/apps/${context.app}/dynos`})
|
17 |
|
18 | var execUrl = _execUrl(context, configVars)
|
19 |
|
20 | return cli.got(`https://${execUrl.host}`, {
|
21 | auth: execUrl.auth,
|
22 | path: _execApiPath(configVars),
|
23 | headers: _execHeaders(),
|
24 | method: 'GET'
|
25 | }).then(response => {
|
26 |
|
27 | var reservations = JSON.parse(response.body);
|
28 |
|
29 | cli.styledHeader(`Heroku Exec ${cli.color.app(context.app)}`)
|
30 |
|
31 | if (reservations.length == 0) {
|
32 | cli.error("Heroku Exec is not running!")
|
33 | cli.error("Check dyno status with `heroku ps'")
|
34 | } else {
|
35 |
|
36 | var statuses = []
|
37 |
|
38 | for (var i in reservations) {
|
39 | var name = reservations[i]['dyno_name']
|
40 | var dyno = dynos.find(d => d.name === name)
|
41 |
|
42 | statuses.push({
|
43 | dyno_name: cli.color.white.bold(name),
|
44 | proxy_status: 'running',
|
45 | dyno_status: !dyno ? cli.color.red('missing!') : (dyno.state === 'up' ? cli.color.green(dyno.state) : cli.color.yellow(dyno.state))
|
46 | })
|
47 | }
|
48 | cli.table(statuses, {
|
49 | columns: [
|
50 | {key: 'dyno_name', label: 'Dyno'},
|
51 | {key: 'proxy_status', label: 'Proxy Status'},
|
52 | {key: 'dyno_status', label: 'Dyno Status'},
|
53 | ]
|
54 | });
|
55 | }
|
56 | }).catch(error => {
|
57 | cli.error(error);
|
58 | });;
|
59 | }
|
60 |
|
61 | function * initFeature(context, heroku, callback) {
|
62 | var buildpackUrls = ["https://github.com/heroku/exec-buildpack", "urn:buildpack:heroku/exec"]
|
63 | let promises = {
|
64 | app: heroku.get(`/apps/${context.app}`),
|
65 | feature: heroku.get(`/apps/${context.app}/features/runtime-heroku-exec`),
|
66 | config: heroku.get(`/apps/${context.app}/config-vars`),
|
67 | buildpacks: heroku.request({
|
68 | path: `/apps/${context.app}/buildpack-installations`,
|
69 | headers: {Range: ''}
|
70 | })
|
71 | }
|
72 |
|
73 | let data = yield promises
|
74 | let feature = data.feature
|
75 | let configVars = data.config
|
76 | let buildpacks = data.buildpacks
|
77 |
|
78 | if (data.app['space'] != null) {
|
79 | if (data.app['space']['shield'] === true) {
|
80 | cli.error(`This feature is restricted for Shield Private Spaces`)
|
81 | cli.exit(1);
|
82 | } else if (buildpacks.length === 0) {
|
83 | cli.error(`${context.app} has no Buildpack URL set. You must deploy your application first!`)
|
84 | cli.exit(1);
|
85 | } else if (!(_hasExecBuildpack(buildpacks, buildpackUrls))) {
|
86 | yield _enableFeature(context, heroku)
|
87 | cli.log(`Adding the Heroku Exec buildpack to ${context.app}`)
|
88 | child.execSync(`heroku buildpacks:add -i 1 heroku/exec -a ${context.app}`)
|
89 | cli.log('')
|
90 | cli.log('Run the following commands to redeploy your app, then Heroku Exec will be ready to use:')
|
91 | cli.log(cli.color.magenta(' git commit -m "Heroku Exec initialization" --allow-empty'))
|
92 | cli.log(cli.color.magenta(' git push heroku master'))
|
93 | cli.exit(0);
|
94 | }
|
95 | } else if (_hasExecBuildpack(buildpacks, buildpackUrls)) {
|
96 | cli.warn(`The Heroku Exec buildpack is no longer required for this app,\n` +
|
97 | `and may interfer with the 'heroku run' command. Please run the\n` +
|
98 | `following command to remove it:\n ` +
|
99 | cli.color.magenta('heroku buildpacks:remove https://github.com/heroku/exec-buildpack'))
|
100 | }
|
101 |
|
102 | var addonUrl = configVars['HEROKU_EXEC_URL']
|
103 | if (addonUrl) {
|
104 | cli.error("It looks like you're using the Heroku Exec addon, which is no longer required\n" +
|
105 | "to use this feature. Please run the following command to remove the addon\n" +
|
106 | "and then try using Heroku Exec again:\n" +
|
107 | cli.color.magenta(' heroku addons:destroy heroku-exec'));
|
108 | cli.exit();
|
109 | } else if (!feature.enabled) {
|
110 | cli.log(`Running this command for the first time requires a dyno restart.`)
|
111 | let answer = yield cli.prompt('Do you want to continue? [y/n]', {});
|
112 |
|
113 | if (answer.trim().toLowerCase() !== 'y') {
|
114 | cli.exit();
|
115 | }
|
116 |
|
117 | yield _enableFeature(context, heroku)
|
118 |
|
119 | yield cli.action(`Restarting dynos`, co(function * () {
|
120 | yield wait(2000)
|
121 | yield heroku.request({method: 'DELETE', path: `/apps/${context.app}/dynos`});
|
122 | }))
|
123 |
|
124 | let dynoName = _dyno(context)
|
125 | let state = 'down'
|
126 | yield cli.action(`Waiting for ${cli.color.cyan(dynoName)} to start`, co(function * () {
|
127 | while (state != 'up') {
|
128 | yield wait(3000)
|
129 | let d = yield heroku.request({path: `/apps/${context.app}/dynos/${dynoName}`})
|
130 | state = d.state
|
131 | if (state === 'crashed') {
|
132 | throw new Error(`The dyno crashed`)
|
133 | }
|
134 | }
|
135 | }))
|
136 | }
|
137 |
|
138 | yield callback(configVars);
|
139 | }
|
140 |
|
141 | function updateClientKey(context, heroku, configVars, callback) {
|
142 | return cli.action("Establishing credentials", {success: false}, co(function* () {
|
143 | var key = keypair();
|
144 | var privkeypem = key.private;
|
145 | var publicKey = forge.pki.publicKeyFromPem(key.public);
|
146 | var pubkeypem = forge.ssh.publicKeyToOpenSSH(publicKey, '');
|
147 | cli.hush(pubkeypem)
|
148 |
|
149 | var execUrl = _execUrl(context, configVars)
|
150 | var dyno = _dyno(context)
|
151 |
|
152 | return cli.got(`https://${execUrl.host}`, {
|
153 | auth: execUrl.auth,
|
154 | path: `${_execApiPath(configVars)}/${dyno}`,
|
155 | method: 'PUT',
|
156 | headers: _execHeaders(),
|
157 | body: {client_key: pubkeypem}
|
158 | }).then(function (response) {
|
159 | cli.action.done('done')
|
160 | callback(privkeypem, dyno, response);
|
161 | }).catch(error => {
|
162 | cli.action.done('error');
|
163 | cli.hush(error);
|
164 | cli.error('Could not connect to dyno!\nCheck if the dyno is running with `heroku ps\'')
|
165 | });;
|
166 | }))
|
167 | }
|
168 |
|
169 |
|
170 | function createSocksProxy(context, heroku, configVars, callback) {
|
171 | return updateClientKey(context, heroku, configVars, function(key, dyno, response) {
|
172 | cli.hush(response.body);
|
173 | var json = JSON.parse(response.body);
|
174 | var user = json['client_user']
|
175 | var host = json['tunnel_host']
|
176 | var port = 80
|
177 | var dyno_ip = json['dyno_ip']
|
178 |
|
179 | ssh.socksv5({ host: host, port: port, username: user, privateKey: key }, function(socks_port) {
|
180 | if (callback) callback(dyno_ip, dyno, socks_port)
|
181 | else cli.log(`Use ${cli.color.magenta('CTRL+C')} to stop the proxy`)
|
182 | });
|
183 | })
|
184 | }
|
185 |
|
186 | function _execApiPath(configVars) {
|
187 | if (configVars['HEROKU_EXEC_URL']) {
|
188 | return '/api/v1';
|
189 | } else {
|
190 | return '/api/v2'
|
191 | }
|
192 | }
|
193 |
|
194 | function _execUrl(context, configVars) {
|
195 | var urlString = configVars['HEROKU_EXEC_URL']
|
196 | if (urlString) {
|
197 | return url.parse(urlString);
|
198 | } else {
|
199 | if (process.env.HEROKU_EXEC_URL === undefined) {
|
200 | urlString = "https://exec-manager.heroku.com/"
|
201 | } else {
|
202 | urlString = process.env.HEROKU_EXEC_URL
|
203 | }
|
204 | var execUrl = url.parse(urlString)
|
205 | execUrl.auth = `${context.app}:${process.env.HEROKU_API_KEY || context.auth.password}`
|
206 | return execUrl
|
207 | }
|
208 | }
|
209 |
|
210 | function _dyno(context) {
|
211 | return context.flags.dyno || 'web.1'
|
212 | }
|
213 |
|
214 | function _hasExecBuildpack(buildpacks, urls) {
|
215 | for (let b of buildpacks) {
|
216 | for (let u of urls) {
|
217 | if (b['buildpack']['url'].indexOf(u) === 0) return true
|
218 | }
|
219 | }
|
220 | return false
|
221 | }
|
222 |
|
223 | function _enableFeature(context, heroku) {
|
224 | return cli.action('Initializing feature', co(function* () {
|
225 | yield heroku.request({
|
226 | method: 'PATCH',
|
227 | path: `/apps/${context.app}/features/runtime-heroku-exec`,
|
228 | body: {'enabled' : true}
|
229 | });
|
230 | }));
|
231 | }
|
232 |
|
233 | function _execHeaders() {
|
234 | if (process.env.HEROKU_HEADERS) {
|
235 | cli.hush(`using headers: ${process.env.HEROKU_HEADERS}`)
|
236 | return JSON.parse(process.env.HEROKU_HEADERS)
|
237 | } else {
|
238 | return {}
|
239 | }
|
240 | }
|
241 |
|
242 | module.exports = {
|
243 | createSocksProxy,
|
244 | checkStatus,
|
245 | initFeature,
|
246 | updateClientKey
|
247 | }
|