diff --git a/packages/cli/src/lib/run/dyno.ts b/packages/cli/src/lib/run/dyno.ts index b369fa5e55..c4751ee199 100644 --- a/packages/cli/src/lib/run/dyno.ts +++ b/packages/cli/src/lib/run/dyno.ts @@ -249,6 +249,13 @@ export default class Dyno extends Duplex { r.end() r.on('error', this.reject) + + r.on('response', response => { + const statusCode = response.statusCode + if (statusCode === 403) { + this.reject?.(new Error('You can\'t access this space from your IP address. Contact your team admin.')) + } + }) r.on('upgrade', (_, remote) => { const s = net.createServer(client => { client.on('end', () => { diff --git a/packages/cli/src/lib/run/log-displayer.ts b/packages/cli/src/lib/run/log-displayer.ts index 3704171058..6074d7e167 100644 --- a/packages/cli/src/lib/run/log-displayer.ts +++ b/packages/cli/src/lib/run/log-displayer.ts @@ -1,6 +1,8 @@ import {APIClient} from '@heroku-cli/command' import {ux} from '@oclif/core' import color from '@heroku-cli/color' +import * as https from 'https' +import {URL} from 'url' import colorize from './colorize' import {LogSession} from '../types/fir' import {getGenerationByAppId} from '../apps/generation' @@ -16,6 +18,84 @@ interface LogDisplayerOptions { type?: string } +/** + * Fetches the response body from an HTTP request when the response body isn't available + * from the error object (e.g., EventSource doesn't expose response bodies). + * Uses Node's https module directly (like dyno.ts) to handle staging SSL certificates. + * + * @param url - The URL to fetch the response body from + * @param expectedStatusCode - Only return body if status code matches (default: 403) + * @returns The response body as a string, or empty string if unavailable or status doesn't match + */ +async function fetchHttpResponseBody(url: string, expectedStatusCode: number = 403): Promise { + return new Promise(resolve => { + let req: ReturnType | null = null + let timeout: NodeJS.Timeout | null = null + const TIMEOUT_MS = 5000 // 5 second timeout + + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + + if (req) { + req.destroy() + req = null + } + } + + try { + const parsedUrl = new URL(url) + const userAgent = process.env.HEROKU_DEBUG_USER_AGENT || 'heroku-run' + + const options: https.RequestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: parsedUrl.pathname + parsedUrl.search, + method: 'GET', + headers: { + 'User-Agent': userAgent, + Accept: 'text/plain', + }, + rejectUnauthorized: false, // Allow staging self-signed certificates + } + + req = https.request(options, res => { + let body = '' + res.setEncoding('utf8') + res.on('data', chunk => { + body += chunk + }) + res.on('end', () => { + cleanup() + if (res.statusCode === expectedStatusCode) { + resolve(body) + } else { + resolve('') + } + }) + }) + + req.on('error', (): void => { + cleanup() + resolve('') + }) + + // Set timeout to prevent hanging requests + timeout = setTimeout(() => { + cleanup() + resolve('') + }, TIMEOUT_MS) + + req.end() + } catch { + cleanup() + resolve('') + } + }) +} + function readLogs(logplexURL: string, isTail: boolean, recreateSessionTimeout?: number) { return new Promise(function (resolve, reject) { const userAgent = process.env.HEROKU_DEBUG_USER_AGENT || 'heroku-run' @@ -27,18 +107,52 @@ function readLogs(logplexURL: string, isTail: boolean, recreateSessionTimeout?: }, }) - es.addEventListener('error', function (err: { status?: number; message?: string | null }) { - if (err && (err.status || err.message)) { - const msg = (isTail && (err.status === 404 || err.status === 403)) ? - 'Log stream timed out. Please try again.' : - `Logs eventsource failed with: ${err.status}${err.message ? ` ${err.message}` : ''}` - reject(new Error(msg)) + let isResolved = false + + const safeReject = (error: Error) => { + if (!isResolved) { + isResolved = true es.close() + reject(error) } + } - if (!isTail) { - resolve() + const safeResolve = () => { + if (!isResolved) { + isResolved = true es.close() + resolve() + } + } + + es.addEventListener('error', async function (err: { status?: number; message?: string | null }) { + if (err && (err.status || err.message)) { + let msg: string + if (err.status === 404) { + msg = 'Your access to the log stream expired. Try again.' + safeReject(new Error(msg)) + } else if (err.status === 403) { + // EventSource doesn't expose response bodies, so fetch it via HTTP request + const responseBody = await fetchHttpResponseBody(logplexURL, 403) + + // Check if response contains IP restriction message + // Match both "space" (for spaces) and "app" (for apps) IP restriction messages + if (responseBody && responseBody.includes("can't access") && responseBody.includes('IP address')) { + // Extract and use the server's error message + msg = responseBody.trim() + } else { + // For other 403 errors (like stream expiration), use default message + msg = 'Your access to the log stream expired. Try again.' + } + + safeReject(new Error(msg)) + } else { + msg = `Logs eventsource failed with: ${err.status}${err.message ? ` ${err.message}` : ''}` + + safeReject(new Error(msg)) + } + } else if (!isTail) { + safeResolve() } // should only land here if --tail and no error status or message diff --git a/packages/cli/test/unit/lib/run/log-displayer.unit.test.ts b/packages/cli/test/unit/lib/run/log-displayer.unit.test.ts index f6e80315f3..8ac43548cf 100644 --- a/packages/cli/test/unit/lib/run/log-displayer.unit.test.ts +++ b/packages/cli/test/unit/lib/run/log-displayer.unit.test.ts @@ -53,7 +53,12 @@ describe('logDisplayer', function () { reqheaders: {Accept: 'text/event-stream'}, }).get('/stream') .query(true) - .reply(401) + .reply(403) + + nock('https://logs.heroku.com') + .get('/stream') + .query(true) + .reply(403, 'You can\'t access this space from your IP address. Contact your team admin.') try { await logDisplayer(heroku, { @@ -65,7 +70,7 @@ describe('logDisplayer', function () { }) } catch (error: unknown) { const {message} = error as CLIError - expect(message).to.equal('Logs eventsource failed with: 401') + expect(message).to.equal('You can\'t access this space from your IP address. Contact your team admin.') } logServer.done() @@ -87,7 +92,12 @@ describe('logDisplayer', function () { reqheaders: {Accept: 'text/event-stream'}, }).get('/stream') .query(true) - .reply(401) + .reply(403) + + nock('https://logs.heroku.com') + .get('/stream') + .query(true) + .reply(403, 'You can\'t access this space from your IP address. Contact your team admin.') try { await logDisplayer(heroku, { @@ -99,7 +109,7 @@ describe('logDisplayer', function () { }) } catch (error: unknown) { const {message} = error as CLIError - expect(message).to.equal('Logs eventsource failed with: 401') + expect(message).to.equal('You can\'t access this space from your IP address. Contact your team admin.') } logServer.done() @@ -121,7 +131,12 @@ describe('logDisplayer', function () { reqheaders: {Accept: 'text/event-stream'}, }).get('/stream') .query(true) - .reply(401) + .reply(403) + + nock('https://logs.heroku.com') + .get('/stream') + .query(true) + .reply(403, 'You can\'t access this space from your IP address. Contact your team admin.') try { await logDisplayer(heroku, { @@ -134,7 +149,7 @@ describe('logDisplayer', function () { }) } catch (error: unknown) { const {message} = error as CLIError - expect(message).to.equal('Logs eventsource failed with: 401') + expect(message).to.equal('You can\'t access this space from your IP address. Contact your team admin.') } logServer.done() @@ -216,7 +231,12 @@ describe('logDisplayer', function () { reqheaders: {Accept: 'text/event-stream'}, }).get('/stream') .query(true) - .reply(401) + .reply(403) + + nock('https://logs.heroku.com') + .get('/stream') + .query(true) + .reply(403, 'You can\'t access this space from your IP address. Contact your team admin.') try { await logDisplayer(heroku, { @@ -225,7 +245,7 @@ describe('logDisplayer', function () { }) } catch (error: unknown) { const {message, oclif} = error as CLIError - expect(message).to.equal('Logs eventsource failed with: 401') + expect(message).to.equal('You can\'t access this space from your IP address. Contact your team admin.') expect(oclif.exit).to.eq(1) } @@ -282,7 +302,12 @@ describe('logDisplayer', function () { reqheaders: {Accept: 'text/event-stream'}, }).get('/stream') .query(true) - .reply(401) + .reply(403) + + nock('https://logs.heroku.com') + .get('/stream') + .query(true) + .reply(403, 'You can\'t access this space from your IP address. Contact your team admin.') try { await logDisplayer(heroku, { @@ -291,7 +316,7 @@ describe('logDisplayer', function () { }) } catch (error: unknown) { const {message, oclif} = error as CLIError - expect(message).to.equal('Logs eventsource failed with: 401') + expect(message).to.equal('You can\'t access this space from your IP address. Contact your team admin.') expect(oclif.exit).to.eq(1) } @@ -299,8 +324,8 @@ describe('logDisplayer', function () { }) }) - context('when the log server responds with a stream of log lines and then timeouts', function () { - it('displays log lines and exits showing a timeout error', async function () { + context('when the log server responds with a stream of log lines and the token expires ending the stream', function () { + it('displays log lines and exits showing a stream access expired error', async function () { const logServer = nock('https://logs.heroku.com', { reqheaders: {Accept: 'text/event-stream'}, }).get('/stream') @@ -326,7 +351,7 @@ describe('logDisplayer', function () { } catch (error: unknown) { stdout.stop() const {message, oclif} = error as CLIError - expect(message).to.equal('Log stream timed out. Please try again.') + expect(message).to.equal('Your access to the log stream expired. Try again.') expect(oclif.exit).to.eq(1) }