Skip to content
7 changes: 7 additions & 0 deletions packages/cli/src/lib/run/dyno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
130 changes: 122 additions & 8 deletions packages/cli/src/lib/run/log-displayer.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<string> {
return new Promise(resolve => {
let req: ReturnType<typeof https.request> | 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<void>(function (resolve, reject) {
const userAgent = process.env.HEROKU_DEBUG_USER_AGENT || 'heroku-run'
Expand All @@ -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
Expand Down
51 changes: 38 additions & 13 deletions packages/cli/test/unit/lib/run/log-displayer.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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()
Expand All @@ -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, {
Expand All @@ -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()
Expand All @@ -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, {
Expand All @@ -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()
Expand Down Expand Up @@ -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, {
Expand All @@ -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)
}

Expand Down Expand Up @@ -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, {
Expand All @@ -291,16 +316,16 @@ 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)
}

logServer.done()
})
})

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')
Expand All @@ -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)
}

Expand Down
Loading