fix(acme): Fix port 80 ACME management and challenge route concurrency issues by deduplicating port listeners, preserving challenge route state across certificate manager recreations, and adding mutex locks to route updates.
This commit is contained in:
345
test/test.port80-management.node.ts
Normal file
345
test/test.port80-management.node.ts
Normal file
@ -0,0 +1,345 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
tap.test('should not double-register port 80 when user route and ACME use same port', async (tools) => {
|
||||
tools.timeout(5000);
|
||||
|
||||
let port80AddCount = 0;
|
||||
const activePorts = new Set<number>();
|
||||
|
||||
const settings = {
|
||||
port: 9901,
|
||||
routes: [
|
||||
{
|
||||
name: 'user-route',
|
||||
match: {
|
||||
ports: [80]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: 'http://localhost:3000'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'acme-route',
|
||||
match: {
|
||||
ports: [443]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: 'https://localhost:3001',
|
||||
tls: {
|
||||
mode: 'terminate' as const,
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
acme: {
|
||||
email: 'test@test.com',
|
||||
port: 80 // ACME on same port as user route
|
||||
}
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Mock the port manager to track port additions
|
||||
(proxy as any).portManager = {
|
||||
addPort: async (port: number) => {
|
||||
if (activePorts.has(port)) {
|
||||
// This is the deduplication behavior we're testing
|
||||
return;
|
||||
}
|
||||
|
||||
activePorts.add(port);
|
||||
if (port === 80) {
|
||||
port80AddCount++;
|
||||
}
|
||||
},
|
||||
|
||||
addPorts: async (ports: number[]) => {
|
||||
for (const port of ports) {
|
||||
await (proxy as any).portManager.addPort(port);
|
||||
}
|
||||
},
|
||||
|
||||
removePort: async (port: number) => {
|
||||
activePorts.delete(port);
|
||||
},
|
||||
|
||||
updatePorts: async (requiredPorts: Set<number>) => {
|
||||
const portsToRemove = [];
|
||||
for (const port of activePorts) {
|
||||
if (!requiredPorts.has(port)) {
|
||||
portsToRemove.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
const portsToAdd = [];
|
||||
for (const port of requiredPorts) {
|
||||
if (!activePorts.has(port)) {
|
||||
portsToAdd.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
for (const port of portsToRemove) {
|
||||
await (proxy as any).portManager.removePort(port);
|
||||
}
|
||||
|
||||
for (const port of portsToAdd) {
|
||||
await (proxy as any).portManager.addPort(port);
|
||||
}
|
||||
},
|
||||
|
||||
setShuttingDown: () => {},
|
||||
getPortForRoutes: () => new Map(),
|
||||
closeAll: async () => { activePorts.clear(); },
|
||||
stop: async () => { await (proxy as any).portManager.closeAll(); }
|
||||
};
|
||||
|
||||
// Mock NFTables
|
||||
(proxy as any).nftablesManager = {
|
||||
ensureNFTablesSetup: async () => {},
|
||||
stop: async () => {}
|
||||
};
|
||||
|
||||
// Mock certificate manager to prevent ACME
|
||||
(proxy as any).createCertificateManager = async function(routes: any[], certDir: string, acmeOptions: any) {
|
||||
const certManager = {
|
||||
routes: routes,
|
||||
globalAcmeDefaults: acmeOptions,
|
||||
updateRoutesCallback: null as any,
|
||||
challengeRouteActive: false,
|
||||
|
||||
setUpdateRoutesCallback: function(callback: any) {
|
||||
this.updateRoutesCallback = callback;
|
||||
},
|
||||
|
||||
setNetworkProxy: function() {},
|
||||
setGlobalAcmeDefaults: function(defaults: any) {
|
||||
this.globalAcmeDefaults = defaults;
|
||||
},
|
||||
|
||||
initialize: async function() {
|
||||
const hasAcmeRoutes = routes.some((r: any) =>
|
||||
r.action.tls?.certificate === 'auto'
|
||||
);
|
||||
|
||||
if (hasAcmeRoutes && acmeOptions?.email) {
|
||||
const challengeRoute = {
|
||||
name: 'acme-challenge',
|
||||
priority: 1000,
|
||||
match: {
|
||||
ports: acmeOptions.port || 80,
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static',
|
||||
handler: async () => ({ status: 200, body: 'challenge' })
|
||||
}
|
||||
};
|
||||
|
||||
const updatedRoutes = [...routes, challengeRoute];
|
||||
if (this.updateRoutesCallback) {
|
||||
await this.updateRoutesCallback(updatedRoutes);
|
||||
}
|
||||
|
||||
this.challengeRouteActive = true;
|
||||
}
|
||||
},
|
||||
|
||||
getAcmeOptions: function() {
|
||||
return acmeOptions;
|
||||
},
|
||||
|
||||
stop: async function() {}
|
||||
};
|
||||
|
||||
certManager.setUpdateRoutesCallback(async (routes: any[]) => {
|
||||
await this.updateRoutes(routes);
|
||||
});
|
||||
|
||||
await certManager.initialize();
|
||||
return certManager;
|
||||
};
|
||||
|
||||
// Mock admin server to prevent binding
|
||||
(proxy as any).startAdminServer = async function() {
|
||||
this.servers.set(this.settings.port, {
|
||||
port: this.settings.port,
|
||||
close: async () => {}
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
|
||||
// Verify that port 80 was added only once
|
||||
tools.expect(port80AddCount).toEqual(1);
|
||||
|
||||
} finally {
|
||||
await proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should handle ACME on different port than user routes', async (tools) => {
|
||||
tools.timeout(5000);
|
||||
|
||||
const portAddHistory: number[] = [];
|
||||
const activePorts = new Set<number>();
|
||||
|
||||
const settings = {
|
||||
port: 9902,
|
||||
routes: [
|
||||
{
|
||||
name: 'user-route',
|
||||
match: {
|
||||
ports: [80]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: 'http://localhost:3000'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'acme-route',
|
||||
match: {
|
||||
ports: [443]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: 'https://localhost:3001',
|
||||
tls: {
|
||||
mode: 'terminate' as const,
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
acme: {
|
||||
email: 'test@test.com',
|
||||
port: 8080 // ACME on different port than user routes
|
||||
}
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Mock the port manager
|
||||
(proxy as any).portManager = {
|
||||
addPort: async (port: number) => {
|
||||
if (!activePorts.has(port)) {
|
||||
activePorts.add(port);
|
||||
portAddHistory.push(port);
|
||||
}
|
||||
},
|
||||
|
||||
addPorts: async (ports: number[]) => {
|
||||
for (const port of ports) {
|
||||
await (proxy as any).portManager.addPort(port);
|
||||
}
|
||||
},
|
||||
|
||||
removePort: async (port: number) => {
|
||||
activePorts.delete(port);
|
||||
},
|
||||
|
||||
updatePorts: async (requiredPorts: Set<number>) => {
|
||||
for (const port of requiredPorts) {
|
||||
await (proxy as any).portManager.addPort(port);
|
||||
}
|
||||
},
|
||||
|
||||
setShuttingDown: () => {},
|
||||
getPortForRoutes: () => new Map(),
|
||||
closeAll: async () => { activePorts.clear(); },
|
||||
stop: async () => { await (proxy as any).portManager.closeAll(); }
|
||||
};
|
||||
|
||||
// Mock NFTables
|
||||
(proxy as any).nftablesManager = {
|
||||
ensureNFTablesSetup: async () => {},
|
||||
stop: async () => {}
|
||||
};
|
||||
|
||||
// Mock certificate manager
|
||||
(proxy as any).createCertificateManager = async function(routes: any[], certDir: string, acmeOptions: any) {
|
||||
const certManager = {
|
||||
routes: routes,
|
||||
globalAcmeDefaults: acmeOptions,
|
||||
updateRoutesCallback: null as any,
|
||||
challengeRouteActive: false,
|
||||
|
||||
setUpdateRoutesCallback: function(callback: any) {
|
||||
this.updateRoutesCallback = callback;
|
||||
},
|
||||
|
||||
setNetworkProxy: function() {},
|
||||
setGlobalAcmeDefaults: function(defaults: any) {
|
||||
this.globalAcmeDefaults = defaults;
|
||||
},
|
||||
|
||||
initialize: async function() {
|
||||
const hasAcmeRoutes = routes.some((r: any) =>
|
||||
r.action.tls?.certificate === 'auto'
|
||||
);
|
||||
|
||||
if (hasAcmeRoutes && acmeOptions?.email) {
|
||||
const challengeRoute = {
|
||||
name: 'acme-challenge',
|
||||
priority: 1000,
|
||||
match: {
|
||||
ports: acmeOptions.port || 80,
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static',
|
||||
handler: async () => ({ status: 200, body: 'challenge' })
|
||||
}
|
||||
};
|
||||
|
||||
const updatedRoutes = [...routes, challengeRoute];
|
||||
if (this.updateRoutesCallback) {
|
||||
await this.updateRoutesCallback(updatedRoutes);
|
||||
}
|
||||
|
||||
this.challengeRouteActive = true;
|
||||
}
|
||||
},
|
||||
|
||||
getAcmeOptions: function() {
|
||||
return acmeOptions;
|
||||
},
|
||||
|
||||
stop: async function() {}
|
||||
};
|
||||
|
||||
certManager.setUpdateRoutesCallback(async (routes: any[]) => {
|
||||
await this.updateRoutes(routes);
|
||||
});
|
||||
|
||||
await certManager.initialize();
|
||||
return certManager;
|
||||
};
|
||||
|
||||
// Mock admin server
|
||||
(proxy as any).startAdminServer = async function() {
|
||||
this.servers.set(this.settings.port, {
|
||||
port: this.settings.port,
|
||||
close: async () => {}
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
|
||||
// Verify that all expected ports were added
|
||||
tools.expect(portAddHistory).toInclude(80); // User route
|
||||
tools.expect(portAddHistory).toInclude(443); // TLS route
|
||||
tools.expect(portAddHistory).toInclude(8080); // ACME challenge
|
||||
|
||||
} finally {
|
||||
await proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap;
|
Reference in New Issue
Block a user