heroku 11.10.0 → 11.10.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/commands/config/set.js +12 -5
- package/dist/commands/config/unset.js +12 -6
- package/dist/commands/ps/forward.js +60 -13
- package/dist/lib/ci/source.js +1 -1
- package/dist/lib/local/load-foreman-procfile.js +1 -1
- package/dist/lib/ps-exec/socks5-server.d.ts +46 -0
- package/dist/lib/ps-exec/socks5-server.js +172 -0
- package/dist/lib/ps-exec/ssh.d.ts +2 -0
- package/dist/lib/ps-exec/ssh.js +100 -17
- package/oclif.manifest.json +1 -1
- package/package.json +12 -13
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
## [11.10.1-beta.0](https://github.com/heroku/cli/compare/v11.10.0...v11.10.1-beta.0) (2026-09-23)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
* **deps:** bump fast-uri to 3.1.7 (closes 4 advisories) ([#3908](https://github.com/heroku/cli/issues/3908)) ([ffe207d](https://github.com/heroku/cli/commit/ffe207dc6e50fd1d6fcfddf345dde94deb1db5b3)), closes [#391](https://github.com/heroku/cli/issues/391) [#392](https://github.com/heroku/cli/issues/392) [#399](https://github.com/heroku/cli/issues/399) [#400](https://github.com/heroku/cli/issues/400)
|
|
13
|
+
* **deps:** resolve GHSA-73wf-gq98-2v4g in browserslist ([#3909](https://github.com/heroku/cli/issues/3909)) ([fe3cd8b](https://github.com/heroku/cli/commit/fe3cd8b5f88b3b22491319879bdb9568db42486a)), closes [#396](https://github.com/heroku/cli/issues/396)
|
|
14
|
+
* fix misleading config output when 2FA fails ([#3887](https://github.com/heroku/cli/issues/3887)) ([e0fd81f](https://github.com/heroku/cli/commit/e0fd81f0b4bafc5d2919c5e3cf83a39279299b20))
|
|
15
|
+
* parse Procfile process types containing dashes in heroku local ([#3883](https://github.com/heroku/cli/issues/3883)) ([5118f40](https://github.com/heroku/cli/commit/5118f40a868e7c07604e2986efc68ea45e9feaee))
|
|
16
|
+
* updates opentelemetry deps to match sentry's peer deps ([#3900](https://github.com/heroku/cli/issues/3900)) ([f66d998](https://github.com/heroku/cli/commit/f66d9984b2dd1b8c0cc6c1e4d0f1819340fc4b63))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Code Refactoring
|
|
20
|
+
|
|
21
|
+
* drop @heroku/socksv5 fork to clear ip-address HIGH advisory ([#3939](https://github.com/heroku/cli/issues/3939)) ([af20ead](https://github.com/heroku/cli/commit/af20eadf8bfb65278e8b26bcffdea3cf1a743833))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Continuous Integration
|
|
25
|
+
|
|
26
|
+
* automatically label each PR with a code-size bucket ([#3941](https://github.com/heroku/cli/issues/3941)) ([2d13484](https://github.com/heroku/cli/commit/2d1348438ec8ab1361a773bce30b3c5c93d8a1e6))
|
|
27
|
+
|
|
7
28
|
## [11.10.0](https://github.com/heroku/cli/compare/v11.9.0...v11.10.0) (2026-08-18)
|
|
8
29
|
|
|
9
30
|
|
|
@@ -43,11 +43,18 @@ RACK_ENV: staging`)];
|
|
|
43
43
|
});
|
|
44
44
|
const varsCopy = argv.map((v) => color.name(v.split('=')[0])).join(', ');
|
|
45
45
|
ux.action.start(`Setting ${varsCopy} and restarting ${color.app(flags.app)}`);
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
let config;
|
|
47
|
+
try {
|
|
48
|
+
({ body: config } = await this.heroku.patch(`/apps/${flags.app}/config-vars`, {
|
|
49
|
+
body: vars,
|
|
50
|
+
}));
|
|
51
|
+
const release = await lastRelease(this.heroku, flags.app);
|
|
52
|
+
ux.action.stop(`done, ${color.name('v' + release.version)}`);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
ux.action.stop(color.red('!'));
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
51
58
|
config = Object.fromEntries(Object.entries(config)
|
|
52
59
|
.filter(([k]) => vars[k])
|
|
53
60
|
.map(([k, v]) => [color.name(k), v]));
|
|
@@ -34,11 +34,17 @@ Unsetting RAILS_ENV, RACK_ENV and restarting example... done, v10`)];
|
|
|
34
34
|
}
|
|
35
35
|
const vars = argv.map(v => color.name(v)).join(', ');
|
|
36
36
|
ux.action.start(`Unsetting ${vars} and restarting ${color.app(flags.app)}`);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
try {
|
|
38
|
+
await this.heroku.patch(`/apps/${flags.app}/config-vars`, {
|
|
39
|
+
// body will be like {FOO: null, BAR: null}
|
|
40
|
+
body: Object.fromEntries(argv.map(v => [v, null])),
|
|
41
|
+
});
|
|
42
|
+
const release = await lastRelease();
|
|
43
|
+
ux.action.stop('done, ' + color.name(`v${release.version}`));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
ux.action.stop(color.red('!'));
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
43
49
|
}
|
|
44
50
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Command, flags } from '@heroku-cli/command';
|
|
2
2
|
import * as color from '@heroku/heroku-cli-util/color';
|
|
3
|
-
import socks from '@heroku/socksv5';
|
|
4
3
|
import { Args, ux } from '@oclif/core';
|
|
4
|
+
import debug from 'debug';
|
|
5
5
|
import net from 'node:net';
|
|
6
|
+
import { SocksClient } from 'socks';
|
|
6
7
|
import tsheredoc from 'tsheredoc';
|
|
7
8
|
import { HerokuExec } from '../../lib/ps-exec/exec.js';
|
|
8
9
|
const heredoc = tsheredoc.default;
|
|
10
|
+
const forwardDebug = debug('cli:ps:forward');
|
|
9
11
|
export default class Forward extends Command {
|
|
10
12
|
static args = {
|
|
11
13
|
port: Args.string({ description: 'port or list of ports to forward', required: true }),
|
|
@@ -48,27 +50,72 @@ export default class Forward extends Command {
|
|
|
48
50
|
// that's ok because localPort is only here for backwards compat
|
|
49
51
|
return [ports[0], ports[1] || localPort || ports[0]];
|
|
50
52
|
});
|
|
53
|
+
// Each local listener binds asynchronously, so we don't know until its
|
|
54
|
+
// 'listening'/'error' event whether the port was actually claimed. Resolve
|
|
55
|
+
// this once every listener has settled with the count that bound, so run()
|
|
56
|
+
// can fail loudly if forwarding nothing (every requested port in use)
|
|
57
|
+
// instead of silently exiting 0.
|
|
58
|
+
let resolveBoundCount;
|
|
59
|
+
const boundCountPromise = new Promise(resolve => {
|
|
60
|
+
resolveBoundCount = resolve;
|
|
61
|
+
});
|
|
51
62
|
await exec.initFeature(context, this.heroku, async (configVars) => {
|
|
52
63
|
await exec.createSocksProxy(context, this.heroku, configVars, (dynoIp, dynoName, socksPort) => {
|
|
64
|
+
let bound = 0;
|
|
65
|
+
let settled = 0;
|
|
66
|
+
const onSettled = () => {
|
|
67
|
+
settled++;
|
|
68
|
+
if (settled === portMappings.length)
|
|
69
|
+
resolveBoundCount(bound);
|
|
70
|
+
};
|
|
53
71
|
for (const portMapping of portMappings) {
|
|
54
72
|
const [localPortNum, remotePort] = portMapping;
|
|
55
|
-
ux.stdout(`Listening on ${color.bold(localPortNum)} and forwarding to ${color.bold(`${dynoName}:${remotePort}`)}`);
|
|
56
73
|
net.createServer(connIn => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
// Without a handler a socket reset (e.g. the peer hangs up) surfaces
|
|
75
|
+
// as an unhandled 'error' and crashes the CLI.
|
|
76
|
+
connIn.on('error', err => {
|
|
77
|
+
forwardDebug('local connection error: %o', err);
|
|
78
|
+
connIn.destroy();
|
|
79
|
+
});
|
|
80
|
+
SocksClient.createConnection({
|
|
81
|
+
command: 'connect',
|
|
82
|
+
destination: { host: '0.0.0.0', port: Number.parseInt(remotePort, 10) },
|
|
83
|
+
proxy: { host: 'localhost', port: socksPort, type: 5 },
|
|
84
|
+
}, (err, info) => {
|
|
85
|
+
if (err || !info) {
|
|
86
|
+
forwardDebug('SOCKS connection to %s:%s failed: %o', dynoName, remotePort, err);
|
|
87
|
+
connIn.destroy();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
info.socket.on('error', socketErr => {
|
|
91
|
+
forwardDebug('proxied socket error: %o', socketErr);
|
|
92
|
+
connIn.destroy();
|
|
93
|
+
});
|
|
94
|
+
connIn.pipe(info.socket);
|
|
95
|
+
info.socket.pipe(connIn);
|
|
66
96
|
});
|
|
67
|
-
|
|
97
|
+
// Bind loopback-only so the forwarded port is reachable only from
|
|
98
|
+
// this machine — it fronts a no-auth tunnel into the dyno, so it must
|
|
99
|
+
// not be exposed to other hosts on the LAN.
|
|
100
|
+
}).listen(Number.parseInt(localPortNum, 10), '127.0.0.1')
|
|
101
|
+
.on('listening', () => {
|
|
102
|
+
bound++;
|
|
103
|
+
ux.stdout(`Listening on ${color.bold(localPortNum)} and forwarding to ${color.bold(`${dynoName}:${remotePort}`)}`);
|
|
104
|
+
onSettled();
|
|
105
|
+
})
|
|
106
|
+
.on('error', (err) => {
|
|
107
|
+
const detail = err.code === 'EADDRINUSE' ? 'port is already in use' : err.message;
|
|
108
|
+
ux.warn(`Cannot forward to ${dynoName}:${remotePort} on local port ${localPortNum}: ${detail}`);
|
|
109
|
+
onSettled();
|
|
110
|
+
});
|
|
68
111
|
}
|
|
69
|
-
ux.stdout(`Use ${color.magenta('CTRL+C')} to stop port forwarding`);
|
|
70
112
|
});
|
|
71
113
|
}, 'forward');
|
|
114
|
+
const boundCount = await boundCountPromise;
|
|
115
|
+
if (boundCount === 0) {
|
|
116
|
+
this.error('Could not forward any of the requested ports; every requested local port is already in use.');
|
|
117
|
+
}
|
|
118
|
+
ux.stdout(`Use ${color.magenta('CTRL+C')} to stop port forwarding`);
|
|
72
119
|
// Keep the process running until interrupted
|
|
73
120
|
await new Promise(resolve => {
|
|
74
121
|
process.once('SIGINT', resolve);
|
package/dist/lib/ci/source.js
CHANGED
|
@@ -4,7 +4,7 @@ function parseProcfile(content) {
|
|
|
4
4
|
if (/^\s*#/.test(line)) {
|
|
5
5
|
return false;
|
|
6
6
|
}
|
|
7
|
-
if (/\w/.test(line) && !/^\s
|
|
7
|
+
if (/\w/.test(line) && !/^\s*[\w-]+:/.test(line)) {
|
|
8
8
|
throw new Error('line ' + (i + 1) + ' parse error: ' + line);
|
|
9
9
|
}
|
|
10
10
|
return line.match(/\w/);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
/**
|
|
3
|
+
* A minimal SOCKS5 server supporting only the CONNECT command with no
|
|
4
|
+
* authentication — the exact subset the `heroku ps:socks`/`ps:forward`
|
|
5
|
+
* commands require.
|
|
6
|
+
*
|
|
7
|
+
* Unlike a general SOCKS proxy, this server never dials the destination
|
|
8
|
+
* itself: the destination lives inside the dyno's private network and is only
|
|
9
|
+
* reachable through an SSH tunnel. Instead, the connection handler receives the
|
|
10
|
+
* parsed request and an `accept()` callback that hands back the raw client
|
|
11
|
+
* socket, so the caller can pipe it to an SSH `forwardOut` stream.
|
|
12
|
+
*
|
|
13
|
+
* @see https://datatracker.ietf.org/doc/html/rfc1928
|
|
14
|
+
*/
|
|
15
|
+
export interface SocksRequestInfo {
|
|
16
|
+
cmd: number;
|
|
17
|
+
dstAddr: string;
|
|
18
|
+
dstPort: number;
|
|
19
|
+
srcAddr: string;
|
|
20
|
+
srcPort: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Approves the connection: sends the SOCKS success reply and returns the raw
|
|
24
|
+
* client socket for the caller to bridge to its own upstream. This server only
|
|
25
|
+
* supports this intercept mode — it never dials the destination itself. Returns
|
|
26
|
+
* `null` if the connection was already settled by a prior `accept`/`deny` or the
|
|
27
|
+
* socket is no longer writable.
|
|
28
|
+
*/
|
|
29
|
+
export type AcceptFn = () => net.Socket | null;
|
|
30
|
+
export type DenyFn = () => void;
|
|
31
|
+
export type ConnectionHandler = (info: SocksRequestInfo, accept: AcceptFn, deny: DenyFn) => void;
|
|
32
|
+
export declare class Socks5Server {
|
|
33
|
+
private readonly handler;
|
|
34
|
+
readonly server: net.Server;
|
|
35
|
+
constructor(handler: ConnectionHandler);
|
|
36
|
+
close(callback?: () => void): this;
|
|
37
|
+
listen(port: number, host: string, callback?: () => void): this;
|
|
38
|
+
on(event: 'error', listener: (err: NodeJS.ErrnoException) => void): this;
|
|
39
|
+
private _handleConnection;
|
|
40
|
+
private _readAddress;
|
|
41
|
+
}
|
|
42
|
+
declare function createServer(handler: ConnectionHandler): Socks5Server;
|
|
43
|
+
declare const _default: {
|
|
44
|
+
createServer: typeof createServer;
|
|
45
|
+
};
|
|
46
|
+
export default _default;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// cspell:ignore atyp nmethods
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
const SOCKS_VERSION = 0x05;
|
|
4
|
+
const AUTH_NONE = 0x00;
|
|
5
|
+
const AUTH_NO_ACCEPTABLE = 0xFF;
|
|
6
|
+
const CMD_CONNECT = 0x01;
|
|
7
|
+
const ATYP_IPV4 = 0x01;
|
|
8
|
+
const ATYP_DOMAIN = 0x03;
|
|
9
|
+
const ATYP_IPV6 = 0x04;
|
|
10
|
+
const REP_SUCCESS = 0x00;
|
|
11
|
+
const REP_GENERAL_FAILURE = 0x01;
|
|
12
|
+
const REP_CMD_NOT_SUPPORTED = 0x07;
|
|
13
|
+
// Guardrails against a client that connects but stalls or floods before the
|
|
14
|
+
// handshake completes. A well-behaved client sends only a few hundred bytes and
|
|
15
|
+
// finishes promptly; anything larger or slower is dropped.
|
|
16
|
+
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
17
|
+
const MAX_HANDSHAKE_BYTES = 1024;
|
|
18
|
+
// Replies carry a null bind address (0.0.0.0:0); clients doing CONNECT ignore it.
|
|
19
|
+
const reply = (rep) => Buffer.from([SOCKS_VERSION, rep, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0]);
|
|
20
|
+
export class Socks5Server {
|
|
21
|
+
handler;
|
|
22
|
+
server;
|
|
23
|
+
constructor(handler) {
|
|
24
|
+
this.handler = handler;
|
|
25
|
+
this.server = net.createServer(socket => this._handleConnection(socket));
|
|
26
|
+
}
|
|
27
|
+
close(callback) {
|
|
28
|
+
this.server.close(callback);
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
listen(port, host, callback) {
|
|
32
|
+
this.server.listen(port, host, callback);
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
// Passthrough for the underlying server's 'error' event (e.g. an EADDRINUSE
|
|
36
|
+
// bind failure), so callers can report it cleanly instead of letting it
|
|
37
|
+
// surface as an unhandled 'error' that crashes the process.
|
|
38
|
+
on(event, listener) {
|
|
39
|
+
this.server.on(event, listener);
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
_handleConnection(socket) {
|
|
43
|
+
socket.on('error', () => socket.destroy());
|
|
44
|
+
// Drop a client that connects but never finishes the handshake, so a slow or
|
|
45
|
+
// idle peer can't pin the socket open. Cleared once the request is parsed.
|
|
46
|
+
socket.setTimeout(HANDSHAKE_TIMEOUT_MS);
|
|
47
|
+
socket.on('timeout', () => socket.destroy());
|
|
48
|
+
let settled = false;
|
|
49
|
+
let buffer = Buffer.alloc(0);
|
|
50
|
+
// Sequential reader over the accumulating buffer. Each step consumes a fixed
|
|
51
|
+
// number of bytes once they are available and advances to the next.
|
|
52
|
+
const steps = [];
|
|
53
|
+
const readBytes = (need, run) => steps.push({ need, run });
|
|
54
|
+
const pump = () => {
|
|
55
|
+
while (steps.length > 0 && buffer.length >= steps[0].need) {
|
|
56
|
+
const { need, run } = steps.shift();
|
|
57
|
+
const bytes = buffer.subarray(0, need);
|
|
58
|
+
buffer = buffer.subarray(need);
|
|
59
|
+
run(bytes);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const onData = (chunk) => {
|
|
63
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
64
|
+
// While still parsing the handshake, a well-behaved client sends only a
|
|
65
|
+
// few hundred bytes; anything larger is abnormal, so refuse it rather than
|
|
66
|
+
// buffering without bound.
|
|
67
|
+
if (steps.length > 0 && buffer.length > MAX_HANDSHAKE_BYTES) {
|
|
68
|
+
socket.destroy();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
pump();
|
|
72
|
+
};
|
|
73
|
+
// Greeting: VER, NMETHODS, METHODS...
|
|
74
|
+
readBytes(2, header => {
|
|
75
|
+
if (header[0] !== SOCKS_VERSION)
|
|
76
|
+
return socket.destroy();
|
|
77
|
+
readBytes(header[1], methods => {
|
|
78
|
+
if (!methods.includes(AUTH_NONE)) {
|
|
79
|
+
socket.end(Buffer.from([SOCKS_VERSION, AUTH_NO_ACCEPTABLE]));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
socket.write(Buffer.from([SOCKS_VERSION, AUTH_NONE]));
|
|
83
|
+
// Request: VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT
|
|
84
|
+
readBytes(4, head => {
|
|
85
|
+
const [ver, cmd, , atyp] = head;
|
|
86
|
+
if (ver !== SOCKS_VERSION)
|
|
87
|
+
return socket.destroy();
|
|
88
|
+
this._readAddress(atyp, readBytes, addr => {
|
|
89
|
+
if (addr === null) {
|
|
90
|
+
socket.end(reply(REP_GENERAL_FAILURE));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
readBytes(2, portBuf => {
|
|
94
|
+
const dstPort = portBuf.readUInt16BE(0);
|
|
95
|
+
if (cmd !== CMD_CONNECT) {
|
|
96
|
+
socket.end(reply(REP_CMD_NOT_SUPPORTED));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const info = {
|
|
100
|
+
cmd,
|
|
101
|
+
dstAddr: addr,
|
|
102
|
+
dstPort,
|
|
103
|
+
srcAddr: socket.remoteAddress ?? '',
|
|
104
|
+
srcPort: socket.remotePort ?? 0,
|
|
105
|
+
};
|
|
106
|
+
const accept = () => {
|
|
107
|
+
if (settled || !socket.writable)
|
|
108
|
+
return null;
|
|
109
|
+
settled = true;
|
|
110
|
+
socket.write(reply(REP_SUCCESS));
|
|
111
|
+
// Stop intercepting: detach our reader and hand back any bytes
|
|
112
|
+
// that arrived after the request so piping sees a clean stream.
|
|
113
|
+
socket.removeListener('data', onData);
|
|
114
|
+
if (buffer.length > 0) {
|
|
115
|
+
socket.unshift(buffer);
|
|
116
|
+
buffer = Buffer.alloc(0);
|
|
117
|
+
}
|
|
118
|
+
return socket;
|
|
119
|
+
};
|
|
120
|
+
const deny = () => {
|
|
121
|
+
// Ignore a deny once the connection is accepted (or already
|
|
122
|
+
// denied): writing a SOCKS reply into a live tunnel corrupts it.
|
|
123
|
+
if (settled)
|
|
124
|
+
return;
|
|
125
|
+
settled = true;
|
|
126
|
+
if (socket.writable)
|
|
127
|
+
socket.end(reply(REP_GENERAL_FAILURE));
|
|
128
|
+
};
|
|
129
|
+
// The handshake is parsed: stop the timeout and pause reads so a
|
|
130
|
+
// client can't flood us while the handler establishes the upstream
|
|
131
|
+
// tunnel. accept() detaches the reader and piping resumes flow.
|
|
132
|
+
socket.setTimeout(0);
|
|
133
|
+
socket.pause();
|
|
134
|
+
this.handler(info, accept, deny);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
socket.on('data', onData);
|
|
141
|
+
}
|
|
142
|
+
// DST.ADDR is length-prefixed for domains and fixed-width for IPs. Calls back
|
|
143
|
+
// with the decoded address, or null on an unsupported address type.
|
|
144
|
+
_readAddress(atyp, readBytes, done) {
|
|
145
|
+
switch (atyp) {
|
|
146
|
+
case ATYP_DOMAIN: {
|
|
147
|
+
readBytes(1, lenBuf => readBytes(lenBuf[0], nameBuf => done(nameBuf.toString('utf8'))));
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case ATYP_IPV4: {
|
|
151
|
+
readBytes(4, b => done(`${b[0]}.${b[1]}.${b[2]}.${b[3]}`));
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case ATYP_IPV6: {
|
|
155
|
+
readBytes(16, b => {
|
|
156
|
+
const groups = [];
|
|
157
|
+
for (let i = 0; i < 16; i += 2)
|
|
158
|
+
groups.push(b.readUInt16BE(i).toString(16));
|
|
159
|
+
done(groups.join(':'));
|
|
160
|
+
});
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
default: {
|
|
164
|
+
done(null);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function createServer(handler) {
|
|
170
|
+
return new Socks5Server(handler);
|
|
171
|
+
}
|
|
172
|
+
export default { createServer };
|
package/dist/lib/ps-exec/ssh.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as color from '@heroku/heroku-cli-util/color';
|
|
2
|
-
import
|
|
2
|
+
import { Errors } from '@oclif/core';
|
|
3
3
|
import { ux } from '@oclif/core/ux';
|
|
4
4
|
import cliProgress from 'cli-progress';
|
|
5
5
|
import debug from 'debug';
|
|
@@ -11,7 +11,12 @@ import path from 'node:path';
|
|
|
11
11
|
import stream from 'node:stream';
|
|
12
12
|
import tty from 'node:tty';
|
|
13
13
|
import { Client } from 'ssh2';
|
|
14
|
+
import socks5 from './socks5-server.js';
|
|
14
15
|
const sshDebug = debug('cli:ps-exec:ssh');
|
|
16
|
+
// Upper bound on simultaneously-live SOCKS connections (each is its own SSH
|
|
17
|
+
// session). Generous enough for a browser fanning out, low enough to keep a
|
|
18
|
+
// runaway local client from exhausting fds/SSH sessions.
|
|
19
|
+
const MAX_SOCKS_CONNECTIONS = 128;
|
|
15
20
|
export class HerokuSsh {
|
|
16
21
|
connect(context, addonHost, dynoUser, privateKey, proxyKey, callback) {
|
|
17
22
|
return new Promise((resolve, reject) => {
|
|
@@ -136,36 +141,76 @@ export class HerokuSsh {
|
|
|
136
141
|
}
|
|
137
142
|
socksv5(addonHost, dynoUser, privateKey, proxyKey, callback) {
|
|
138
143
|
const socksPort = 1080;
|
|
139
|
-
|
|
144
|
+
// Each accepted SOCKS request opens its own SSH connection, so cap how many
|
|
145
|
+
// can be live at once. Without this a client that fans out many parallel
|
|
146
|
+
// connections (a browser, or a hostile local process) can drive up fd/memory
|
|
147
|
+
// and SSH-session load on both the CLI and the dyno's sshd.
|
|
148
|
+
let activeConnections = 0;
|
|
149
|
+
socks5.createServer((info, accept, deny) => {
|
|
150
|
+
if (activeConnections >= MAX_SOCKS_CONNECTIONS) {
|
|
151
|
+
sshDebug(`refusing SOCKS request: ${activeConnections} connections already active (max ${MAX_SOCKS_CONNECTIONS})`);
|
|
152
|
+
return deny();
|
|
153
|
+
}
|
|
154
|
+
activeConnections++;
|
|
140
155
|
const conn = new Client();
|
|
156
|
+
let released = false;
|
|
157
|
+
// Free the slot exactly once, whenever this SSH connection closes.
|
|
158
|
+
const release = () => {
|
|
159
|
+
if (released)
|
|
160
|
+
return;
|
|
161
|
+
released = true;
|
|
162
|
+
activeConnections--;
|
|
163
|
+
};
|
|
164
|
+
conn.on('close', release);
|
|
165
|
+
let clientSocket = null;
|
|
166
|
+
const teardown = () => {
|
|
167
|
+
if (clientSocket)
|
|
168
|
+
clientSocket.destroy();
|
|
169
|
+
conn.end();
|
|
170
|
+
};
|
|
141
171
|
conn.on('ready', () => {
|
|
142
172
|
conn.forwardOut(info.srcAddr, info.srcPort, info.dstAddr, info.dstPort, (err, stream) => {
|
|
143
173
|
if (err) {
|
|
144
174
|
conn.end();
|
|
145
175
|
return deny();
|
|
146
176
|
}
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
177
|
+
// Guard the forwardOut stream against an unhandled 'error' before the
|
|
178
|
+
// accept() null-check below: if accept() returns null we abandon this
|
|
179
|
+
// stream (conn.end() emits 'close', not 'error'), but a late error on
|
|
180
|
+
// the orphaned stream would otherwise go unhandled and crash the CLI.
|
|
181
|
+
stream.on('error', teardown);
|
|
182
|
+
clientSocket = accept();
|
|
183
|
+
if (!clientSocket) {
|
|
184
|
+
conn.end();
|
|
185
|
+
return;
|
|
152
186
|
}
|
|
153
|
-
|
|
187
|
+
// Tear the tunnel down if either side errors, rather than letting an
|
|
188
|
+
// unhandled 'error' event crash the CLI.
|
|
189
|
+
clientSocket.on('error', teardown);
|
|
190
|
+
stream.pipe(clientSocket).pipe(stream).on('close', () => {
|
|
154
191
|
conn.end();
|
|
192
|
+
});
|
|
155
193
|
});
|
|
156
194
|
}).on('error', () => {
|
|
157
|
-
|
|
195
|
+
// Before accept, reject the SOCKS request. After, the tunnel is live, so
|
|
196
|
+
// tear it down instead of injecting a late SOCKS reply into the stream.
|
|
197
|
+
if (clientSocket)
|
|
198
|
+
teardown();
|
|
199
|
+
else
|
|
200
|
+
deny();
|
|
158
201
|
}).connect({
|
|
159
202
|
...this._connectionDefaults(proxyKey),
|
|
160
203
|
host: addonHost,
|
|
161
204
|
privateKey,
|
|
162
205
|
username: dynoUser,
|
|
163
206
|
});
|
|
207
|
+
}).on('error', (err) => {
|
|
208
|
+
this._reportProxyBindError(err, socksPort);
|
|
164
209
|
}).listen(socksPort, '127.0.0.1', () => {
|
|
165
|
-
|
|
210
|
+
ux.stdout(`SOCKSv5 proxy server started on port ${color.info(socksPort.toString())}`);
|
|
166
211
|
if (callback)
|
|
167
212
|
callback(socksPort);
|
|
168
|
-
})
|
|
213
|
+
});
|
|
169
214
|
}
|
|
170
215
|
async ssh(context, addonHost, dynoUser, privateKey, proxyKey) {
|
|
171
216
|
sshDebug('[cli-ssh] native');
|
|
@@ -218,11 +263,22 @@ export class HerokuSsh {
|
|
|
218
263
|
return {
|
|
219
264
|
hostHash: 'sha256',
|
|
220
265
|
hostVerifier(hashedKey) {
|
|
266
|
+
// proxyKey is the API-provided "ssh-rsa <base64>" host key. If it's
|
|
267
|
+
// empty or missing the base64 blob, fail closed rather than throwing
|
|
268
|
+
// out of the verifier on `Buffer.from(undefined, ...)`.
|
|
269
|
+
const keyBlob = proxyKey?.split(' ')[1];
|
|
270
|
+
if (!keyBlob) {
|
|
271
|
+
sshDebug('proxy public key is missing or malformed; refusing host key');
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
221
274
|
const hasher = crypto.createHash('sha256');
|
|
222
|
-
hasher.update(Buffer.from(
|
|
275
|
+
hasher.update(Buffer.from(keyBlob, 'base64'));
|
|
223
276
|
return hasher.digest('hex') === hashedKey;
|
|
224
277
|
},
|
|
225
278
|
port: 80,
|
|
279
|
+
// Bound how long a connecting client is held before we give up, so a
|
|
280
|
+
// stalled SSH endpoint can't pin a paused SOCKS client socket forever.
|
|
281
|
+
readyTimeout: 20_000,
|
|
226
282
|
};
|
|
227
283
|
}
|
|
228
284
|
_logConnectionError() {
|
|
@@ -260,11 +316,38 @@ export class HerokuSsh {
|
|
|
260
316
|
});
|
|
261
317
|
}
|
|
262
318
|
else {
|
|
263
|
-
stdin.pipe(
|
|
264
|
-
flush: done => c.write('\u0004', done),
|
|
265
|
-
objectMode: true,
|
|
266
|
-
transform: (chunk, _, next) => c.write(chunk, next),
|
|
267
|
-
}));
|
|
319
|
+
stdin.pipe(this._stdinToRemote(c));
|
|
268
320
|
}
|
|
269
321
|
}
|
|
322
|
+
// A SOCKS-server bind failure fires asynchronously, outside run()'s
|
|
323
|
+
// error-handling chain, so throwing here (ux.error) would surface as an
|
|
324
|
+
// unhandled crash with a raw stack trace. Route it through oclif's handler so
|
|
325
|
+
// it prints cleanly and exits non-zero. (Extracted as a seam so tests can stub
|
|
326
|
+
// it — Errors.handle exits the process and can't be stubbed as an ESM export.)
|
|
327
|
+
_reportProxyBindError(err, socksPort) {
|
|
328
|
+
const detail = err.code === 'EADDRINUSE'
|
|
329
|
+
? `port ${socksPort} is already in use — stop the process using it and try again`
|
|
330
|
+
: err.message;
|
|
331
|
+
Errors.handle(new Errors.CLIError(`Could not start the SOCKS proxy server: ${detail}`, { exit: 1 }));
|
|
332
|
+
}
|
|
333
|
+
// Bridges piped stdin to the remote exec stream, appending an EOF (^D) when
|
|
334
|
+
// stdin ends. For a non-interactive command the remote stream (`c`) closes as
|
|
335
|
+
// soon as the command exits — which can happen before stdin reaches EOF — so
|
|
336
|
+
// every write is guarded on `c.writable` (don't write the trailing EOF to an
|
|
337
|
+
// already-ended stream) and a late write-after-end is swallowed as the
|
|
338
|
+
// teardown race it is, rather than surfacing as an unhandled error that
|
|
339
|
+
// crashes the CLI.
|
|
340
|
+
_stdinToRemote(c) {
|
|
341
|
+
const transform = new stream.Transform({
|
|
342
|
+
// Pass the stream callbacks straight through so a genuine write error
|
|
343
|
+
// reaches the 'error' handler below instead of being silently dropped.
|
|
344
|
+
flush: done => (c.writable ? c.write('\u0004', done) : done()),
|
|
345
|
+
transform: (chunk, _, next) => (c.writable ? c.write(chunk, next) : next()),
|
|
346
|
+
});
|
|
347
|
+
transform.on('error', (error) => {
|
|
348
|
+
if (error.code !== 'ERR_STREAM_WRITE_AFTER_END')
|
|
349
|
+
sshDebug(error);
|
|
350
|
+
});
|
|
351
|
+
return transform;
|
|
352
|
+
}
|
|
270
353
|
}
|
package/oclif.manifest.json
CHANGED
|
@@ -21161,5 +21161,5 @@
|
|
|
21161
21161
|
]
|
|
21162
21162
|
}
|
|
21163
21163
|
},
|
|
21164
|
-
"version": "11.10.0"
|
|
21164
|
+
"version": "11.10.1-beta.0"
|
|
21165
21165
|
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heroku",
|
|
3
3
|
"description": "CLI to interact with Heroku",
|
|
4
|
-
"version": "11.10.0",
|
|
4
|
+
"version": "11.10.1-beta.0",
|
|
5
5
|
"author": "Heroku",
|
|
6
6
|
"bin": "./bin/run.js",
|
|
7
7
|
"bugs": "https://github.com/heroku/cli/issues",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@heroku-cli/command": "^13.
|
|
10
|
-
"@heroku-cli/notifications": "^1.2.
|
|
9
|
+
"@heroku-cli/command": "^13.2.0",
|
|
10
|
+
"@heroku-cli/notifications": "^1.2.9",
|
|
11
11
|
"@heroku-cli/schema": "^1.0.25",
|
|
12
12
|
"@heroku/buildpack-registry": "^1.0.1",
|
|
13
13
|
"@heroku/heroku-cli-util": "^10.10.0",
|
|
14
14
|
"@heroku/http-call": "^5.5.1",
|
|
15
15
|
"@heroku/mcp-server": "^1.2.5",
|
|
16
|
-
"@
|
|
17
|
-
"@inquirer/prompts": "^7.0",
|
|
16
|
+
"@inquirer/prompts": "^8.7",
|
|
18
17
|
"@oclif/core": "^4.8.4",
|
|
19
18
|
"@oclif/plugin-commands": "^4.1.40",
|
|
20
19
|
"@oclif/plugin-help": "^6.2.37",
|
|
@@ -29,10 +28,10 @@
|
|
|
29
28
|
"@opentelemetry/api": "^1.9.0",
|
|
30
29
|
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
|
|
31
30
|
"@opentelemetry/instrumentation": "^0.52.1",
|
|
32
|
-
"@opentelemetry/resources": "^1.
|
|
33
|
-
"@opentelemetry/sdk-trace-base": "^1.
|
|
34
|
-
"@opentelemetry/sdk-trace-node": "^1.
|
|
35
|
-
"@opentelemetry/semantic-conventions": "^1.
|
|
31
|
+
"@opentelemetry/resources": "^1.30.1",
|
|
32
|
+
"@opentelemetry/sdk-trace-base": "^1.30.1",
|
|
33
|
+
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
|
34
|
+
"@opentelemetry/semantic-conventions": "^1.30.1",
|
|
36
35
|
"@sentry/node": "^10.27",
|
|
37
36
|
"@sentry/opentelemetry": "^10.27.0",
|
|
38
37
|
"@types/js-yaml": "^3.12.5",
|
|
@@ -42,14 +41,14 @@
|
|
|
42
41
|
"chrono-node": "^2.7.6",
|
|
43
42
|
"cli-progress": "^3.12.0",
|
|
44
43
|
"commander": "^2.15.1",
|
|
45
|
-
"date-fns": "^
|
|
44
|
+
"date-fns": "^4.4.0",
|
|
46
45
|
"debug": "^4.4.3",
|
|
47
46
|
"eventsource": "^4",
|
|
48
47
|
"execa": "^9.6.1",
|
|
49
48
|
"filesize": "^10.1",
|
|
50
49
|
"fs-extra": "^11.3.0",
|
|
51
50
|
"glob": "^13.0.2",
|
|
52
|
-
"got": "^
|
|
51
|
+
"got": "^14.0.0",
|
|
53
52
|
"https-proxy-agent": "^7.0.6",
|
|
54
53
|
"inquirer": "^8.2.6",
|
|
55
54
|
"keypair": "^1.0.4",
|
|
@@ -66,6 +65,7 @@
|
|
|
66
65
|
"printf": "0.6.1",
|
|
67
66
|
"redis-parser": "^3.0.0",
|
|
68
67
|
"shell-quote": "^1.8.1",
|
|
68
|
+
"socks": "^2.8.8",
|
|
69
69
|
"ssh2": "^1.16.0",
|
|
70
70
|
"strftime": "^0.10.0",
|
|
71
71
|
"term-img": "^7.1.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"word-wrap": "^1.2.5",
|
|
79
79
|
"ws": "^6.2.2",
|
|
80
80
|
"yaml": "^2.0.1",
|
|
81
|
-
"yargs-parser": "
|
|
81
|
+
"yargs-parser": "22.0.0"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@actions/core": "^3.0.0",
|
|
@@ -374,7 +374,6 @@
|
|
|
374
374
|
"node-notifier": {
|
|
375
375
|
"uuid": "^11.1.1"
|
|
376
376
|
},
|
|
377
|
-
"socks": "^2.8.8",
|
|
378
377
|
"express-rate-limit": "^8.5.1"
|
|
379
378
|
},
|
|
380
379
|
"repository": "heroku/cli",
|