fix(acme): Refactor ACME challenge route lifecycle to prevent port 80 EADDRINUSE errors
This commit is contained in:
346
test/test.challenge-route-lifecycle.node.ts
Normal file
346
test/test.challenge-route-lifecycle.node.ts
Normal file
@ -0,0 +1,346 @@
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
|
||||
let testProxy: SmartProxy;
|
||||
|
||||
// Helper to check if a port is being listened on
|
||||
async function isPortListening(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = plugins.net.createServer();
|
||||
|
||||
server.once('error', (err: any) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
// Port is already in use (being listened on)
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
// Port is available (not being listened on)
|
||||
server.close();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to create test route
|
||||
const createRoute = (id: number, port: number = 8443) => ({
|
||||
name: `test-route-${id}`,
|
||||
match: {
|
||||
ports: [port],
|
||||
domains: [`test${id}.example.com`]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 3000 + id
|
||||
},
|
||||
tls: {
|
||||
mode: 'terminate' as const,
|
||||
certificate: 'auto' as const
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should add challenge route once during initialization', async () => {
|
||||
testProxy = new SmartProxy({
|
||||
routes: [createRoute(1, 8443)],
|
||||
acme: {
|
||||
email: 'test@example.com',
|
||||
useProduction: false,
|
||||
port: 8080 // Use high port for testing
|
||||
}
|
||||
});
|
||||
|
||||
// Mock certificate manager initialization
|
||||
let challengeRouteAddCount = 0;
|
||||
const originalInitCertManager = (testProxy as any).initializeCertificateManager;
|
||||
|
||||
(testProxy as any).initializeCertificateManager = async function() {
|
||||
// Track challenge route additions
|
||||
const mockCertManager = {
|
||||
addChallengeRoute: async function() {
|
||||
challengeRouteAddCount++;
|
||||
},
|
||||
removeChallengeRoute: async function() {
|
||||
challengeRouteAddCount--;
|
||||
},
|
||||
setUpdateRoutesCallback: function() {},
|
||||
setNetworkProxy: function() {},
|
||||
setGlobalAcmeDefaults: function() {},
|
||||
initialize: async function() {
|
||||
// Simulate adding challenge route during init
|
||||
await this.addChallengeRoute();
|
||||
},
|
||||
stop: async function() {
|
||||
// Simulate removing challenge route during stop
|
||||
await this.removeChallengeRoute();
|
||||
},
|
||||
getAcmeOptions: function() {
|
||||
return { email: 'test@example.com' };
|
||||
}
|
||||
};
|
||||
|
||||
(this as any).certManager = mockCertManager;
|
||||
};
|
||||
|
||||
await testProxy.start();
|
||||
|
||||
// Challenge route should be added exactly once
|
||||
expect(challengeRouteAddCount).toEqual(1);
|
||||
|
||||
await testProxy.stop();
|
||||
|
||||
// Challenge route should be removed on stop
|
||||
expect(challengeRouteAddCount).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should persist challenge route during multiple certificate provisioning', async () => {
|
||||
testProxy = new SmartProxy({
|
||||
routes: [
|
||||
createRoute(1, 8443),
|
||||
createRoute(2, 8444),
|
||||
createRoute(3, 8445)
|
||||
],
|
||||
acme: {
|
||||
email: 'test@example.com',
|
||||
useProduction: false,
|
||||
port: 8080
|
||||
}
|
||||
});
|
||||
|
||||
// Mock to track route operations
|
||||
let challengeRouteActive = false;
|
||||
let addAttempts = 0;
|
||||
let removeAttempts = 0;
|
||||
|
||||
(testProxy as any).initializeCertificateManager = async function() {
|
||||
const mockCertManager = {
|
||||
challengeRouteActive: false,
|
||||
isProvisioning: false,
|
||||
|
||||
addChallengeRoute: async function() {
|
||||
addAttempts++;
|
||||
if (this.challengeRouteActive) {
|
||||
console.log('Challenge route already active, skipping');
|
||||
return;
|
||||
}
|
||||
this.challengeRouteActive = true;
|
||||
challengeRouteActive = true;
|
||||
},
|
||||
|
||||
removeChallengeRoute: async function() {
|
||||
removeAttempts++;
|
||||
if (!this.challengeRouteActive) {
|
||||
console.log('Challenge route not active, skipping removal');
|
||||
return;
|
||||
}
|
||||
this.challengeRouteActive = false;
|
||||
challengeRouteActive = false;
|
||||
},
|
||||
|
||||
provisionAllCertificates: async function() {
|
||||
this.isProvisioning = true;
|
||||
// Simulate provisioning multiple certificates
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Would normally call provisionCertificate for each route
|
||||
// Challenge route should remain active throughout
|
||||
expect(this.challengeRouteActive).toEqual(true);
|
||||
}
|
||||
this.isProvisioning = false;
|
||||
},
|
||||
|
||||
setUpdateRoutesCallback: function() {},
|
||||
setNetworkProxy: function() {},
|
||||
setGlobalAcmeDefaults: function() {},
|
||||
|
||||
initialize: async function() {
|
||||
await this.addChallengeRoute();
|
||||
await this.provisionAllCertificates();
|
||||
},
|
||||
|
||||
stop: async function() {
|
||||
await this.removeChallengeRoute();
|
||||
},
|
||||
|
||||
getAcmeOptions: function() {
|
||||
return { email: 'test@example.com' };
|
||||
}
|
||||
};
|
||||
|
||||
(this as any).certManager = mockCertManager;
|
||||
};
|
||||
|
||||
await testProxy.start();
|
||||
|
||||
// Challenge route should be added once and remain active
|
||||
expect(addAttempts).toEqual(1);
|
||||
expect(challengeRouteActive).toEqual(true);
|
||||
|
||||
await testProxy.stop();
|
||||
|
||||
// Challenge route should be removed once
|
||||
expect(removeAttempts).toEqual(1);
|
||||
expect(challengeRouteActive).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('should handle port conflicts gracefully', async () => {
|
||||
// Create a server that listens on port 8080 to create a conflict
|
||||
const conflictServer = plugins.net.createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
conflictServer.listen(8080, () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
testProxy = new SmartProxy({
|
||||
routes: [createRoute(1, 8443)],
|
||||
acme: {
|
||||
email: 'test@example.com',
|
||||
useProduction: false,
|
||||
port: 8080 // This port is already in use
|
||||
}
|
||||
});
|
||||
|
||||
let error: Error | null = null;
|
||||
|
||||
(testProxy as any).initializeCertificateManager = async function() {
|
||||
const mockCertManager = {
|
||||
challengeRouteActive: false,
|
||||
|
||||
addChallengeRoute: async function() {
|
||||
if (this.challengeRouteActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate EADDRINUSE error
|
||||
const err = new Error('listen EADDRINUSE: address already in use :::8080');
|
||||
(err as any).code = 'EADDRINUSE';
|
||||
throw err;
|
||||
},
|
||||
|
||||
setUpdateRoutesCallback: function() {},
|
||||
setNetworkProxy: function() {},
|
||||
setGlobalAcmeDefaults: function() {},
|
||||
|
||||
initialize: async function() {
|
||||
try {
|
||||
await this.addChallengeRoute();
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
stop: async function() {},
|
||||
getAcmeOptions: function() {
|
||||
return { email: 'test@example.com' };
|
||||
}
|
||||
};
|
||||
|
||||
(this as any).certManager = mockCertManager;
|
||||
};
|
||||
|
||||
try {
|
||||
await testProxy.start();
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
// Should have caught the port conflict
|
||||
expect(error).toBeTruthy();
|
||||
expect(error?.message).toContain('Port 8080 is already in use');
|
||||
|
||||
} finally {
|
||||
// Clean up conflict server
|
||||
conflictServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should prevent concurrent provisioning', async () => {
|
||||
// Mock the certificate manager with tracking
|
||||
let concurrentAttempts = 0;
|
||||
let maxConcurrent = 0;
|
||||
let currentlyProvisioning = 0;
|
||||
|
||||
const mockProxy = {
|
||||
provisionCertificate: async function(route: any, allowConcurrent = false) {
|
||||
if (!allowConcurrent && currentlyProvisioning > 0) {
|
||||
console.log('Provisioning already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
concurrentAttempts++;
|
||||
currentlyProvisioning++;
|
||||
maxConcurrent = Math.max(maxConcurrent, currentlyProvisioning);
|
||||
|
||||
// Simulate provisioning delay
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
currentlyProvisioning--;
|
||||
}
|
||||
};
|
||||
|
||||
// Try to provision multiple certificates concurrently
|
||||
const promises = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
promises.push(mockProxy.provisionCertificate({ name: `route-${i}` }));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Should have rejected concurrent attempts
|
||||
expect(concurrentAttempts).toEqual(1);
|
||||
expect(maxConcurrent).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('should clean up properly even on errors', async () => {
|
||||
let challengeRouteActive = false;
|
||||
|
||||
const mockCertManager = {
|
||||
challengeRouteActive: false,
|
||||
|
||||
addChallengeRoute: async function() {
|
||||
this.challengeRouteActive = true;
|
||||
challengeRouteActive = true;
|
||||
throw new Error('Test error during add');
|
||||
},
|
||||
|
||||
removeChallengeRoute: async function() {
|
||||
if (!this.challengeRouteActive) {
|
||||
return;
|
||||
}
|
||||
this.challengeRouteActive = false;
|
||||
challengeRouteActive = false;
|
||||
},
|
||||
|
||||
initialize: async function() {
|
||||
try {
|
||||
await this.addChallengeRoute();
|
||||
} catch (error) {
|
||||
// Should still clean up
|
||||
await this.removeChallengeRoute();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await mockCertManager.initialize();
|
||||
} catch (error) {
|
||||
// Expected error
|
||||
}
|
||||
|
||||
// State should be cleaned up
|
||||
expect(challengeRouteActive).toEqual(false);
|
||||
expect(mockCertManager.challengeRouteActive).toEqual(false);
|
||||
});
|
||||
|
||||
tap.start();
|
Reference in New Issue
Block a user