Skip to content

Commit ca5064a

Browse files
committed
Add support for ACME-only revoked TLS endpoint
1 parent c124d8e commit ca5064a

6 files changed

Lines changed: 242 additions & 2 deletions

File tree

src/endpoints/http/echo.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,18 @@ async function handle(req: HttpRequest, res: HttpResponse) {
141141
}
142142

143143
// HTTP/1.x: return raw request data
144+
// Defer briefly to allow other pipelined requests to register
145+
await new Promise<void>(resolve => process.nextTick(resolve));
146+
147+
if (req.socket.pipelining) {
148+
res.writeHead(400);
149+
res.end('Echo endpoint does not support request pipelining. Send requests sequentially or use HTTP/2 multiplexing instead.');
150+
return;
151+
}
152+
144153
await streamConsumers.buffer(req);
145154
const rawData = Buffer.concat(req.socket.receivedData ?? []);
155+
146156
res.writeHead(200, {
147157
'Content-Length': Buffer.byteLength(rawData)
148158
});

src/http-handler.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ export function createHttp1Handler(options: {
143143
}) {
144144
const handleRequest = createHttpRequestHandler(options);
145145
const handler = new http.Server(async (req, res) => {
146+
// Track concurrent requests on this socket to detect pipelining
147+
const socket = req.socket;
148+
socket.requestsInBatch = (socket.requestsInBatch || 0) + 1;
149+
if (socket.requestsInBatch > 1) {
150+
socket.pipelining = true;
151+
}
152+
146153
try {
147154
console.log(`Handling H1 request to ${req.url}`);
148155
await handleRequest(req, res);
@@ -159,6 +166,10 @@ export function createHttp1Handler(options: {
159166
} finally {
160167
// Reset for next request on keep-alive connections
161168
req.socket.receivedData = [];
169+
req.socket.requestsInBatch!--;
170+
if (req.socket.requestsInBatch === 0) {
171+
req.socket.pipelining = false;
172+
}
162173
}
163174
});
164175

src/server.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { PersistentCertCache } from './tls-certificates/cert-cache.js';
1111
declare module 'stream' {
1212
interface Duplex {
1313
receivedData?: Buffer[];
14+
// Pipelining detection: tracks concurrent requests
15+
requestsInBatch?: number;
16+
pipelining?: boolean;
1417
}
1518
}
1619

@@ -48,6 +51,7 @@ async function generateTlsConfig(options: ServerOptions) {
4851
generateCertificate: (domain: string, mode?: CertMode) => {
4952
if (mode === 'self-signed') return ca.generateSelfSignedCertificate(domain);
5053
if (mode === 'expired') return ca.generateExpiredCertificate(domain);
54+
// 'revoked' mode requires ACME - falls through to normal cert without it
5155
return ca.generateCertificate(domain);
5256
},
5357
acmeChallenge: () => undefined // Not supported
@@ -82,6 +86,13 @@ async function generateTlsConfig(options: ServerOptions) {
8286
return ca.generateExpiredCertificate(domain);
8387
}
8488

89+
if (mode === 'revoked') {
90+
// Try to get a revoked ACME cert; fall back to normal cert if not yet available
91+
const revokedAcmeCert = acmeCA.tryGetRevokedCertificateSync(rootDomain);
92+
if (revokedAcmeCert) return revokedAcmeCert;
93+
// No LocalCA fallback for revoked - just use normal cert until ACME one is ready
94+
}
95+
8596
if (domain === rootDomain || domain.endsWith('.' + rootDomain)) {
8697
const cert = acmeCA.tryGetCertificateSync(domain);
8798
if (cert) return cert;

src/tls-certificates/acme.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,60 @@ export class AcmeCA {
7171
return cachedCert;
7272
}
7373

74+
// Returns an ACME cert that has been revoked. Issues once, revokes, never renews.
75+
// Returns undefined if no revoked ACME cert is available yet (caller should use LocalCA fallback).
76+
tryGetRevokedCertificateSync(domain: string) {
77+
const cacheKey = `${domain}:revoked`;
78+
const cachedCert = this.certCache.getCert(cacheKey);
79+
80+
if (cachedCert) {
81+
console.log(`Found cached revoked cert for ${domain}`);
82+
return cachedCert;
83+
}
84+
85+
// No cached cert - issue and revoke one in the background
86+
const attemptId = Math.random().toString(16).slice(2);
87+
console.log(`No revoked cert cached for ${domain}, issuing and revoking new one (${attemptId})`);
88+
this.issueRevokedCertificate(domain, cacheKey, attemptId);
89+
90+
return undefined;
91+
}
92+
93+
private async issueRevokedCertificate(domain: string, cacheKey: string, attemptId: string) {
94+
if (this.pendingCertRenewals[cacheKey]) {
95+
console.log(`Revoked cert already being issued for ${domain} (${attemptId})`);
96+
return;
97+
}
98+
99+
const refreshPromise = Object.assign(
100+
this.requestAndRevokeCertificate(domain, { attemptId }).then((certData) => {
101+
delete this.pendingCertRenewals[cacheKey];
102+
this.certCache.cacheCert({ ...certData, domain: cacheKey });
103+
console.log(`Revoked cert issued and cached for ${domain} (${attemptId})`);
104+
return certData;
105+
}).catch((e) => {
106+
delete this.pendingCertRenewals[cacheKey];
107+
console.log(`Revoked cert generation failed for ${domain} (${attemptId}):`, e.message);
108+
throw e;
109+
}),
110+
{ id: attemptId }
111+
);
112+
113+
this.pendingCertRenewals[cacheKey] = refreshPromise;
114+
}
115+
116+
private async requestAndRevokeCertificate(domain: string, options: { attemptId: string }): Promise<AcmeGeneratedCertificate> {
117+
console.log(`Requesting certificate to revoke for ${domain} (${options.attemptId})`);
118+
119+
const certData = await this.requestNewCertificate(domain, options);
120+
121+
console.log(`Revoking certificate for ${domain} (${options.attemptId})`);
122+
await (await this.acmeClient).revokeCertificate(certData.cert);
123+
console.log(`Certificate revoked for ${domain} (${options.attemptId})`);
124+
125+
return certData;
126+
}
127+
74128
// Returns an ACME cert only if it has actually expired. Issues once, never renews.
75129
// Returns undefined if no expired ACME cert is available (caller should use LocalCA fallback).
76130
tryGetExpiredCertificateSync(domain: string) {

src/tls-handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import * as tls from 'tls';
22

33
import { ConnectionProcessor } from './process-connection.js';
44

5-
export const CERT_MODES = ['wrong-host', 'self-signed', 'expired'] as const;
5+
export const CERT_MODES = ['wrong-host', 'self-signed', 'expired', 'revoked'] as const;
66
export type CertMode = typeof CERT_MODES[number];
77

88
// Modes that require special certificate generation (vs just domain remapping)
9-
const CERT_GENERATION_MODES = new Set<CertMode>(['self-signed', 'expired']);
9+
const CERT_GENERATION_MODES = new Set<CertMode>(['self-signed', 'expired', 'revoked']);
1010

1111
export type CertGenerator = (domain: string, mode?: CertMode) => {
1212
key: string,

test/echo.spec.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as net from 'net';
2+
import * as tls from 'tls';
23
import * as http2 from 'http2';
34
import { expect } from 'chai';
45
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
@@ -140,4 +141,157 @@ accept-encoding: gzip, deflate
140141

141142
});
142143

144+
describe("HTTP/1.1 pipelining", () => {
145+
146+
it("rejects pipelined echo request when it's the second request", async () => {
147+
const host = `localhost:${serverPort}`;
148+
const request1 = `GET /status/200 HTTP/1.1\r\nHost: ${host}\r\n\r\n`;
149+
const request2 = `GET /echo HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
150+
151+
const socket = tls.connect({
152+
host: 'localhost',
153+
port: serverPort,
154+
servername: 'http1.localhost',
155+
rejectUnauthorized: false
156+
});
157+
158+
await new Promise<void>((resolve) => socket.on('secureConnect', resolve));
159+
160+
// Send both requests at once (pipelined)
161+
socket.write(request1 + request2);
162+
163+
const response = await new Promise<string>((resolve) => {
164+
const chunks: Buffer[] = [];
165+
socket.on('data', (chunk) => chunks.push(chunk));
166+
socket.on('end', () => resolve(Buffer.concat(chunks).toString()));
167+
});
168+
169+
socket.destroy();
170+
171+
// Parse the two responses (filter empty strings from split)
172+
const responses = response.split(/(?=HTTP\/1\.1)/).filter(r => r.length > 0);
173+
expect(responses.length).to.equal(2);
174+
175+
// First response should be 200 from /status/200
176+
expect(responses[0]).to.include('HTTP/1.1 200');
177+
178+
// Second response (echo) should be 400 because pipelining is detected
179+
const echoResponse = responses[1];
180+
expect(echoResponse).to.include('HTTP/1.1 400');
181+
expect(echoResponse).to.include('pipelining');
182+
});
183+
184+
it("rejects pipelined echo request when it's the first request", async () => {
185+
const host = `localhost:${serverPort}`;
186+
const request1 = `GET /echo HTTP/1.1\r\nHost: ${host}\r\n\r\n`;
187+
const request2 = `GET /status/200 HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
188+
189+
const socket = tls.connect({
190+
host: 'localhost',
191+
port: serverPort,
192+
servername: 'http1.localhost',
193+
rejectUnauthorized: false
194+
});
195+
196+
await new Promise<void>((resolve) => socket.on('secureConnect', resolve));
197+
198+
// Send both requests at once (pipelined)
199+
socket.write(request1 + request2);
200+
201+
const response = await new Promise<string>((resolve) => {
202+
const chunks: Buffer[] = [];
203+
socket.on('data', (chunk) => chunks.push(chunk));
204+
socket.on('end', () => resolve(Buffer.concat(chunks).toString()));
205+
});
206+
207+
socket.destroy();
208+
209+
// First response (echo) should be 400 because pipelining is detected
210+
expect(response).to.include('HTTP/1.1 400');
211+
expect(response).to.include('pipelining');
212+
});
213+
214+
it("rejects both pipelined echo requests", async () => {
215+
const host = `localhost:${serverPort}`;
216+
const request1 = `GET /echo HTTP/1.1\r\nHost: ${host}\r\n\r\n`;
217+
const request2 = `GET /echo HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
218+
219+
const socket = tls.connect({
220+
host: 'localhost',
221+
port: serverPort,
222+
servername: 'http1.localhost',
223+
rejectUnauthorized: false
224+
});
225+
226+
await new Promise<void>((resolve) => socket.on('secureConnect', resolve));
227+
socket.write(request1 + request2);
228+
229+
const response = await new Promise<string>((resolve) => {
230+
const chunks: Buffer[] = [];
231+
socket.on('data', (chunk) => chunks.push(chunk));
232+
socket.on('end', () => resolve(Buffer.concat(chunks).toString()));
233+
});
234+
235+
socket.destroy();
236+
237+
// Both echo requests should return 400 due to pipelining
238+
const responses = response.split(/(?=HTTP\/1\.1)/).filter(r => r.length > 0);
239+
expect(responses.length).to.equal(2);
240+
expect(responses[0]).to.include('HTTP/1.1 400');
241+
expect(responses[0]).to.include('pipelining');
242+
expect(responses[1]).to.include('HTTP/1.1 400');
243+
expect(responses[1]).to.include('pipelining');
244+
});
245+
246+
it("works correctly with sequential requests on keep-alive connection", async () => {
247+
const host = `localhost:${serverPort}`;
248+
249+
const socket = tls.connect({
250+
host: 'localhost',
251+
port: serverPort,
252+
servername: 'http1.localhost',
253+
rejectUnauthorized: false
254+
});
255+
256+
await new Promise<void>((resolve) => socket.on('secureConnect', resolve));
257+
258+
// Send first request and wait for response
259+
const request1 = `GET /status/200 HTTP/1.1\r\nHost: ${host}\r\n\r\n`;
260+
socket.write(request1);
261+
262+
// Wait for first response
263+
const response1 = await new Promise<string>((resolve) => {
264+
let data = '';
265+
const onData = (chunk: Buffer) => {
266+
data += chunk.toString();
267+
// Check if we have a complete response (ends with \r\n\r\n for no body)
268+
if (data.includes('\r\n\r\n')) {
269+
socket.removeListener('data', onData);
270+
resolve(data);
271+
}
272+
};
273+
socket.on('data', onData);
274+
});
275+
276+
expect(response1).to.include('HTTP/1.1 200');
277+
278+
// Now send second request (echo)
279+
const request2 = `GET /echo HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
280+
socket.write(request2);
281+
282+
const response2 = await new Promise<string>((resolve) => {
283+
const chunks: Buffer[] = [];
284+
socket.on('data', (chunk) => chunks.push(chunk));
285+
socket.on('end', () => resolve(Buffer.concat(chunks).toString()));
286+
});
287+
288+
socket.destroy();
289+
290+
// Echo should work and return the raw request
291+
expect(response2).to.include('HTTP/1.1 200');
292+
expect(response2).to.include('GET /echo HTTP/1.1');
293+
});
294+
295+
});
296+
143297
});

0 commit comments

Comments
 (0)