Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/sandbox/tls-terminate-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,10 @@ function duplexOverSocket(
* reads in that socket's own buffer too, which grows by the whole upload.
* `outbound` is the stream whose write queue holds what is on its way to
* the client.
*
* @internal Exported for its test.
*/
function serverTlsOn(
export function serverTlsOn(
socket: Duplex,
head: Buffer,
options: TLSSocketOptions,
Expand All @@ -242,6 +244,12 @@ function serverTlsOn(
isServer: true,
})
setConsumer(tls)
// destroySoon() is to destroy a socket once all it was given is out. This
// one has finished as soon as it has written to the stream under it, long
// before the client has the bytes, and destroyed then it drops them. (A
// runtime's http server may call it after an HTTP/1.0 response.) Ended, it
// closes the tunnel too, after the flush. destroy() still cuts at once.
tls.destroySoon = () => void tls.end()
return { tls, outbound: duplex }
}

Expand Down
108 changes: 108 additions & 0 deletions test/sandbox/tls-terminate-destroy-soon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import { createServer, type AddressInfo } from 'node:net'
import { join } from 'node:path'
import { connect, type TLSSocket } from 'node:tls'
import { createMitmCA } from '../../src/sandbox/mitm-ca.js'
import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js'
import { serverTlsOn } from '../../src/sandbox/tls-terminate-proxy.js'

// More than the kernel takes for a client that is not reading.
const SIZE = 32 << 20

const FIXTURES = join(import.meta.dir, '..', 'fixtures', 'tls-terminate')
const LEAF = (): { cert: string; key: string } => {
const leaf = mintLeafCert(
createMitmCA({
caCertPath: join(FIXTURES, 'ca.crt'),
caKeyPath: join(FIXTURES, 'ca.key'),
}),
'localhost',
)
return { cert: leaf.certPem, key: leaf.keyPem }
}

const cleanup: Array<() => void> = []
afterEach(() => {
while (cleanup.length) cleanup.pop()!()
})

/**
* What a TLS client reads, and whether it saw the end, from a server that
* writes SIZE bytes to its TLS socket, all at once, and hands it to `then`.
* The client reads nothing for a while, so `then` is done long before the
* bytes are taken, and then slowly.
*/
async function lateClientOf(
then: (tls: TLSSocket) => void,
): Promise<{ read: number; ended: boolean }> {
// As an http server's sockets are, a CONNECT's among them.
const server = createServer({ allowHalfOpen: true }, socket => {
socket.on('error', () => {})
const { tls } = serverTlsOn(socket, Buffer.alloc(0), LEAF(), () => {})
tls.on('error', () => {})
tls.once('secure', () => {
const chunk = Buffer.alloc(64 << 10, 0x61)
for (let sent = 0; sent < SIZE; sent += chunk.length) tls.write(chunk)
then(tls)
})
})
cleanup.push(() => server.close())
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
return new Promise(resolve => {
const to = {
port: (server.address() as AddressInfo).port,
host: '127.0.0.1',
allowHalfOpen: true,
servername: 'localhost',
ca: readFileSync(join(FIXTURES, 'ca.crt')),
}
const c = connect(to)
let read = 0
let ended = false
c.pause()
setTimeout(() => c.resume(), 300)
c.on('data', d => {
read += d.length
c.pause()
setTimeout(() => c.resume(), 5)
})
c.on('end', () => {
ended = true
c.end()
})
c.on('error', () => {})
c.on('close', () => resolve({ read, ended }))
})
}

describe('serverTlsOn', () => {
// What a runtime's http server does, depending on the runtime, once it has
// answered an HTTP/1.0 request.
test.each([
['end(), then destroySoon()', (t: TLSSocket) => (t.end(), t.destroySoon())],
['destroySoon()', (t: TLSSocket) => t.destroySoon()],
])(
'%s: the client gets every byte, then the end',
async (_how, close) => {
expect(await lateClientOf(close)).toEqual({ read: SIZE, ended: true })
},
60_000,
)

// As the back-stops do, a while after an end that a client does not answer.
test.each([
[
'end(), and destroy() once it has finished',
(t: TLSSocket) => t.end(() => setImmediate(() => t.destroy())),
],
['destroy()', (t: TLSSocket) => t.destroy()],
])(
'%s: cut at once',
async (_how, cut) => {
// Long before the client reads its first byte.
expect((await lateClientOf(cut)).read).toBeLessThan(SIZE)
},
60_000,
)
})
Loading