Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
e317fd9d7e | |||
4134d2842c | |||
02e77655ad | |||
f9bcbf4bfc | |||
ec81678651 | |||
9646dba601 | |||
0faca5e256 |
341
Connection-Cleanup-Patterns.md
Normal file
341
Connection-Cleanup-Patterns.md
Normal file
@ -0,0 +1,341 @@
|
||||
# Connection Cleanup Code Patterns
|
||||
|
||||
## Pattern 1: Safe Connection Cleanup
|
||||
|
||||
```typescript
|
||||
public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
|
||||
// Prevent duplicate cleanup
|
||||
if (record.incomingTerminationReason === null ||
|
||||
record.incomingTerminationReason === undefined) {
|
||||
record.incomingTerminationReason = reason;
|
||||
this.incrementTerminationStat('incoming', reason);
|
||||
}
|
||||
|
||||
this.cleanupConnection(record, reason);
|
||||
}
|
||||
|
||||
public cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
|
||||
if (!record.connectionClosed) {
|
||||
record.connectionClosed = true;
|
||||
|
||||
// Remove from tracking immediately
|
||||
this.connectionRecords.delete(record.id);
|
||||
this.securityManager.removeConnectionByIP(record.remoteIP, record.id);
|
||||
|
||||
// Clear timers
|
||||
if (record.cleanupTimer) {
|
||||
clearTimeout(record.cleanupTimer);
|
||||
record.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
// Clean up sockets
|
||||
this.cleanupSocket(record, 'incoming', record.incoming);
|
||||
if (record.outgoing) {
|
||||
this.cleanupSocket(record, 'outgoing', record.outgoing);
|
||||
}
|
||||
|
||||
// Clear memory
|
||||
record.pendingData = [];
|
||||
record.pendingDataSize = 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 2: Socket Cleanup with Retry
|
||||
|
||||
```typescript
|
||||
private cleanupSocket(
|
||||
record: IConnectionRecord,
|
||||
side: 'incoming' | 'outgoing',
|
||||
socket: plugins.net.Socket
|
||||
): void {
|
||||
try {
|
||||
if (!socket.destroyed) {
|
||||
// Graceful shutdown first
|
||||
socket.end();
|
||||
|
||||
// Force destroy after timeout
|
||||
const socketTimeout = setTimeout(() => {
|
||||
try {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[${record.id}] Error destroying ${side} socket: ${err}`);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Don't block process exit
|
||||
if (socketTimeout.unref) {
|
||||
socketTimeout.unref();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[${record.id}] Error closing ${side} socket: ${err}`);
|
||||
// Fallback to destroy
|
||||
try {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
} catch (destroyErr) {
|
||||
console.log(`[${record.id}] Error destroying ${side} socket: ${destroyErr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 3: NetworkProxy Bridge Cleanup
|
||||
|
||||
```typescript
|
||||
public async forwardToNetworkProxy(
|
||||
connectionId: string,
|
||||
socket: plugins.net.Socket,
|
||||
record: IConnectionRecord,
|
||||
initialChunk: Buffer,
|
||||
networkProxyPort: number,
|
||||
cleanupCallback: (reason: string) => void
|
||||
): Promise<void> {
|
||||
const proxySocket = new plugins.net.Socket();
|
||||
|
||||
// Connect to NetworkProxy
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
proxySocket.connect(networkProxyPort, 'localhost', () => {
|
||||
resolve();
|
||||
});
|
||||
proxySocket.on('error', reject);
|
||||
});
|
||||
|
||||
// Send initial data
|
||||
if (initialChunk) {
|
||||
proxySocket.write(initialChunk);
|
||||
}
|
||||
|
||||
// Setup bidirectional piping
|
||||
socket.pipe(proxySocket);
|
||||
proxySocket.pipe(socket);
|
||||
|
||||
// Comprehensive cleanup handler
|
||||
const cleanup = (reason: string) => {
|
||||
// Unpipe to prevent data loss
|
||||
socket.unpipe(proxySocket);
|
||||
proxySocket.unpipe(socket);
|
||||
|
||||
// Destroy proxy socket
|
||||
proxySocket.destroy();
|
||||
|
||||
// Notify SmartProxy
|
||||
cleanupCallback(reason);
|
||||
};
|
||||
|
||||
// Setup all cleanup triggers
|
||||
socket.on('end', () => cleanup('socket_end'));
|
||||
socket.on('error', () => cleanup('socket_error'));
|
||||
proxySocket.on('end', () => cleanup('proxy_end'));
|
||||
proxySocket.on('error', () => cleanup('proxy_error'));
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 4: Error Handler with Cleanup
|
||||
|
||||
```typescript
|
||||
public handleError(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
|
||||
return (err: Error) => {
|
||||
const code = (err as any).code;
|
||||
let reason = 'error';
|
||||
|
||||
// Map error codes to reasons
|
||||
switch (code) {
|
||||
case 'ECONNRESET':
|
||||
reason = 'econnreset';
|
||||
break;
|
||||
case 'ETIMEDOUT':
|
||||
reason = 'etimedout';
|
||||
break;
|
||||
case 'ECONNREFUSED':
|
||||
reason = 'connection_refused';
|
||||
break;
|
||||
case 'EHOSTUNREACH':
|
||||
reason = 'host_unreachable';
|
||||
break;
|
||||
}
|
||||
|
||||
// Log with context
|
||||
const duration = Date.now() - record.incomingStartTime;
|
||||
console.log(
|
||||
`[${record.id}] ${code} on ${side} side from ${record.remoteIP}. ` +
|
||||
`Duration: ${plugins.prettyMs(duration)}`
|
||||
);
|
||||
|
||||
// Track termination reason
|
||||
if (side === 'incoming' && record.incomingTerminationReason === null) {
|
||||
record.incomingTerminationReason = reason;
|
||||
this.incrementTerminationStat('incoming', reason);
|
||||
}
|
||||
|
||||
// Initiate cleanup
|
||||
this.initiateCleanupOnce(record, reason);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 5: Inactivity Check with Cleanup
|
||||
|
||||
```typescript
|
||||
public performInactivityCheck(): void {
|
||||
const now = Date.now();
|
||||
const connectionIds = [...this.connectionRecords.keys()];
|
||||
|
||||
for (const id of connectionIds) {
|
||||
const record = this.connectionRecords.get(id);
|
||||
if (!record) continue;
|
||||
|
||||
// Skip if disabled or immortal
|
||||
if (this.settings.disableInactivityCheck ||
|
||||
(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const inactivityTime = now - record.lastActivity;
|
||||
let effectiveTimeout = this.settings.inactivityTimeout!;
|
||||
|
||||
// Extended timeout for keep-alive
|
||||
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
|
||||
effectiveTimeout *= (this.settings.keepAliveInactivityMultiplier || 6);
|
||||
}
|
||||
|
||||
if (inactivityTime > effectiveTimeout && !record.connectionClosed) {
|
||||
// Warn before closing keep-alive connections
|
||||
if (record.hasKeepAlive && !record.inactivityWarningIssued) {
|
||||
console.log(`[${id}] Warning: Keep-alive connection inactive`);
|
||||
record.inactivityWarningIssued = true;
|
||||
// Grace period
|
||||
record.lastActivity = now - (effectiveTimeout - 600000);
|
||||
} else {
|
||||
// Close the connection
|
||||
console.log(`[${id}] Closing due to inactivity`);
|
||||
this.cleanupConnection(record, 'inactivity');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 6: Complete Shutdown
|
||||
|
||||
```typescript
|
||||
public clearConnections(): void {
|
||||
const connectionIds = [...this.connectionRecords.keys()];
|
||||
|
||||
// Phase 1: Graceful end
|
||||
for (const id of connectionIds) {
|
||||
const record = this.connectionRecords.get(id);
|
||||
if (record) {
|
||||
try {
|
||||
// Clear timers
|
||||
if (record.cleanupTimer) {
|
||||
clearTimeout(record.cleanupTimer);
|
||||
record.cleanupTimer = undefined;
|
||||
}
|
||||
|
||||
// Graceful socket end
|
||||
if (record.incoming && !record.incoming.destroyed) {
|
||||
record.incoming.end();
|
||||
}
|
||||
if (record.outgoing && !record.outgoing.destroyed) {
|
||||
record.outgoing.end();
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`Error during graceful end: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Force destroy after delay
|
||||
setTimeout(() => {
|
||||
for (const id of connectionIds) {
|
||||
const record = this.connectionRecords.get(id);
|
||||
if (record) {
|
||||
try {
|
||||
// Remove all listeners
|
||||
if (record.incoming) {
|
||||
record.incoming.removeAllListeners();
|
||||
if (!record.incoming.destroyed) {
|
||||
record.incoming.destroy();
|
||||
}
|
||||
}
|
||||
if (record.outgoing) {
|
||||
record.outgoing.removeAllListeners();
|
||||
if (!record.outgoing.destroyed) {
|
||||
record.outgoing.destroy();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`Error during forced destruction: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all tracking
|
||||
this.connectionRecords.clear();
|
||||
this.terminationStats = { incoming: {}, outgoing: {} };
|
||||
}, 100);
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 7: Safe Event Handler Removal
|
||||
|
||||
```typescript
|
||||
// Store handlers for later removal
|
||||
record.renegotiationHandler = this.tlsManager.createRenegotiationHandler(
|
||||
connectionId,
|
||||
serverName,
|
||||
connInfo,
|
||||
(connectionId, reason) => this.connectionManager.initiateCleanupOnce(record, reason)
|
||||
);
|
||||
|
||||
// Add the handler
|
||||
socket.on('data', record.renegotiationHandler);
|
||||
|
||||
// Remove during cleanup
|
||||
if (record.incoming) {
|
||||
try {
|
||||
record.incoming.removeAllListeners('data');
|
||||
record.renegotiationHandler = undefined;
|
||||
} catch (err) {
|
||||
console.log(`[${record.id}] Error removing data handlers: ${err}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 8: Connection State Tracking
|
||||
|
||||
```typescript
|
||||
interface IConnectionRecord {
|
||||
id: string;
|
||||
connectionClosed: boolean;
|
||||
incomingTerminationReason: string | null;
|
||||
outgoingTerminationReason: string | null;
|
||||
cleanupTimer?: NodeJS.Timeout;
|
||||
renegotiationHandler?: Function;
|
||||
// ... other fields
|
||||
}
|
||||
|
||||
// Check state before operations
|
||||
if (!record.connectionClosed) {
|
||||
// Safe to perform operations
|
||||
}
|
||||
|
||||
// Track cleanup state
|
||||
record.connectionClosed = true;
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Idempotency**: Cleanup operations should be safe to call multiple times
|
||||
2. **State Tracking**: Always track connection and cleanup state
|
||||
3. **Error Resilience**: Handle errors during cleanup gracefully
|
||||
4. **Resource Release**: Clear all references (timers, handlers, buffers)
|
||||
5. **Graceful First**: Try graceful shutdown before forced destroy
|
||||
6. **Comprehensive Coverage**: Handle all possible termination scenarios
|
||||
7. **Logging**: Track termination reasons for debugging
|
||||
8. **Memory Safety**: Clear data structures to prevent leaks
|
248
Connection-Termination-Issues.md
Normal file
248
Connection-Termination-Issues.md
Normal file
@ -0,0 +1,248 @@
|
||||
# Connection Termination Issues and Solutions in SmartProxy/NetworkProxy
|
||||
|
||||
## Common Connection Termination Scenarios
|
||||
|
||||
### 1. Normal Connection Closure
|
||||
|
||||
**Flow**:
|
||||
- Client or server initiates graceful close
|
||||
- 'close' event triggers cleanup
|
||||
- Connection removed from tracking
|
||||
- Resources freed
|
||||
|
||||
**Code Path**:
|
||||
```typescript
|
||||
// In ConnectionManager
|
||||
handleClose(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
|
||||
record.incomingTerminationReason = 'normal';
|
||||
this.initiateCleanupOnce(record, 'closed_' + side);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Error-Based Termination
|
||||
|
||||
**Common Errors**:
|
||||
- ECONNRESET: Connection reset by peer
|
||||
- ETIMEDOUT: Connection timed out
|
||||
- ECONNREFUSED: Connection refused
|
||||
- EHOSTUNREACH: Host unreachable
|
||||
|
||||
**Handling**:
|
||||
```typescript
|
||||
handleError(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
|
||||
return (err: Error) => {
|
||||
const code = (err as any).code;
|
||||
let reason = 'error';
|
||||
|
||||
if (code === 'ECONNRESET') {
|
||||
reason = 'econnreset';
|
||||
} else if (code === 'ETIMEDOUT') {
|
||||
reason = 'etimedout';
|
||||
}
|
||||
|
||||
this.initiateCleanupOnce(record, reason);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Inactivity Timeout
|
||||
|
||||
**Detection**:
|
||||
```typescript
|
||||
performInactivityCheck(): void {
|
||||
const now = Date.now();
|
||||
for (const record of this.connectionRecords.values()) {
|
||||
const inactivityTime = now - record.lastActivity;
|
||||
if (inactivityTime > effectiveTimeout) {
|
||||
this.cleanupConnection(record, 'inactivity');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Special Cases**:
|
||||
- Keep-alive connections get extended timeouts
|
||||
- "Immortal" connections bypass inactivity checks
|
||||
- Warning issued before closure for keep-alive connections
|
||||
|
||||
### 4. NFTables-Handled Connections
|
||||
|
||||
**Special Handling**:
|
||||
```typescript
|
||||
if (route.action.forwardingEngine === 'nftables') {
|
||||
socket.end();
|
||||
record.nftablesHandled = true;
|
||||
this.connectionManager.initiateCleanupOnce(record, 'nftables_handled');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
These connections are:
|
||||
- Handled at kernel level
|
||||
- Closed immediately at application level
|
||||
- Tracked for metrics only
|
||||
|
||||
### 5. NetworkProxy Bridge Termination
|
||||
|
||||
**Bridge Cleanup**:
|
||||
```typescript
|
||||
const cleanup = (reason: string) => {
|
||||
socket.unpipe(proxySocket);
|
||||
proxySocket.unpipe(socket);
|
||||
proxySocket.destroy();
|
||||
cleanupCallback(reason);
|
||||
};
|
||||
|
||||
socket.on('end', () => cleanup('socket_end'));
|
||||
socket.on('error', () => cleanup('socket_error'));
|
||||
proxySocket.on('end', () => cleanup('proxy_end'));
|
||||
proxySocket.on('error', () => cleanup('proxy_error'));
|
||||
```
|
||||
|
||||
## Preventing Connection Leaks
|
||||
|
||||
### 1. Always Remove Event Listeners
|
||||
|
||||
```typescript
|
||||
cleanupConnection(record: IConnectionRecord, reason: string): void {
|
||||
if (record.incoming) {
|
||||
record.incoming.removeAllListeners('data');
|
||||
record.renegotiationHandler = undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Clear Timers
|
||||
|
||||
```typescript
|
||||
if (record.cleanupTimer) {
|
||||
clearTimeout(record.cleanupTimer);
|
||||
record.cleanupTimer = undefined;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Proper Socket Cleanup
|
||||
|
||||
```typescript
|
||||
private cleanupSocket(record: IConnectionRecord, side: string, socket: net.Socket): void {
|
||||
try {
|
||||
if (!socket.destroyed) {
|
||||
socket.end(); // Graceful
|
||||
setTimeout(() => {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy(); // Forced
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`Error closing ${side} socket: ${err}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Connection Record Cleanup
|
||||
|
||||
```typescript
|
||||
// Clear pending data to prevent memory leaks
|
||||
record.pendingData = [];
|
||||
record.pendingDataSize = 0;
|
||||
|
||||
// Remove from tracking map
|
||||
this.connectionRecords.delete(record.id);
|
||||
```
|
||||
|
||||
## Monitoring and Debugging
|
||||
|
||||
### 1. Termination Statistics
|
||||
|
||||
```typescript
|
||||
private terminationStats: {
|
||||
incoming: Record<string, number>;
|
||||
outgoing: Record<string, number>;
|
||||
} = { incoming: {}, outgoing: {} };
|
||||
|
||||
incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
|
||||
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Connection Logging
|
||||
|
||||
**Detailed Logging**:
|
||||
```typescript
|
||||
console.log(
|
||||
`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}).` +
|
||||
` Duration: ${prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}`
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Active Connection Tracking
|
||||
|
||||
```typescript
|
||||
getConnectionCount(): number {
|
||||
return this.connectionRecords.size;
|
||||
}
|
||||
|
||||
// In NetworkProxy
|
||||
metrics = {
|
||||
activeConnections: this.connectedClients,
|
||||
portProxyConnections: this.portProxyConnections,
|
||||
tlsTerminatedConnections: this.tlsTerminatedConnections
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices for Connection Termination
|
||||
|
||||
1. **Always Use initiateCleanupOnce()**:
|
||||
- Prevents duplicate cleanup operations
|
||||
- Ensures proper termination reason tracking
|
||||
|
||||
2. **Handle All Socket Events**:
|
||||
- 'error', 'close', 'end' events
|
||||
- Both incoming and outgoing sockets
|
||||
|
||||
3. **Implement Proper Timeouts**:
|
||||
- Initial data timeout
|
||||
- Inactivity timeout
|
||||
- Maximum connection lifetime
|
||||
|
||||
4. **Track Resources**:
|
||||
- Connection records
|
||||
- Socket maps
|
||||
- Timer references
|
||||
|
||||
5. **Log Termination Reasons**:
|
||||
- Helps debug connection issues
|
||||
- Provides metrics for monitoring
|
||||
|
||||
6. **Graceful Shutdown**:
|
||||
- Try socket.end() before socket.destroy()
|
||||
- Allow time for graceful closure
|
||||
|
||||
7. **Memory Management**:
|
||||
- Clear pending data buffers
|
||||
- Remove event listeners
|
||||
- Delete connection records
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Memory Leaks from Event Listeners
|
||||
**Solution**: Always call removeAllListeners() during cleanup
|
||||
|
||||
### Issue: Orphaned Connections
|
||||
**Solution**: Implement multiple cleanup triggers (timeout, error, close)
|
||||
|
||||
### Issue: Duplicate Cleanup Operations
|
||||
**Solution**: Use connectionClosed flag and initiateCleanupOnce()
|
||||
|
||||
### Issue: Hanging Connections
|
||||
**Solution**: Implement inactivity checks and maximum lifetime limits
|
||||
|
||||
### Issue: Resource Exhaustion
|
||||
**Solution**: Track connection counts and implement limits
|
||||
|
||||
### Issue: Lost Data During Cleanup
|
||||
**Solution**: Use proper unpipe operations and graceful shutdown
|
||||
|
||||
### Issue: Debugging Connection Issues
|
||||
**Solution**: Track termination reasons and maintain detailed logs
|
153
NetworkProxy-SmartProxy-Connection-Management.md
Normal file
153
NetworkProxy-SmartProxy-Connection-Management.md
Normal file
@ -0,0 +1,153 @@
|
||||
# NetworkProxy Connection Termination and SmartProxy Connection Handling
|
||||
|
||||
## Overview
|
||||
|
||||
The connection management between NetworkProxy and SmartProxy involves complex coordination to handle TLS termination, connection forwarding, and proper cleanup. This document outlines how these systems work together.
|
||||
|
||||
## SmartProxy Connection Management
|
||||
|
||||
### Connection Tracking (ConnectionManager)
|
||||
|
||||
1. **Connection Lifecycle**:
|
||||
- New connections are registered in `ConnectionManager.createConnection()`
|
||||
- Each connection gets a unique ID and tracking record
|
||||
- Connection records track both incoming (client) and outgoing (target) sockets
|
||||
- Connections are removed from tracking upon cleanup
|
||||
|
||||
2. **Connection Cleanup Flow**:
|
||||
```
|
||||
initiateCleanupOnce() -> cleanupConnection() -> cleanupSocket()
|
||||
```
|
||||
- `initiateCleanupOnce()`: Prevents duplicate cleanup operations
|
||||
- `cleanupConnection()`: Main cleanup logic, removes connections from tracking
|
||||
- `cleanupSocket()`: Handles socket termination (graceful end, then forced destroy)
|
||||
|
||||
3. **Cleanup Triggers**:
|
||||
- Socket errors (ECONNRESET, ETIMEDOUT, etc.)
|
||||
- Socket close events
|
||||
- Inactivity timeouts
|
||||
- Connection lifetime limits
|
||||
- Manual cleanup (e.g., NFTables-handled connections)
|
||||
|
||||
## NetworkProxy Integration
|
||||
|
||||
### NetworkProxyBridge
|
||||
|
||||
The `NetworkProxyBridge` class manages the connection between SmartProxy and NetworkProxy:
|
||||
|
||||
1. **Connection Forwarding**:
|
||||
```typescript
|
||||
forwardToNetworkProxy(
|
||||
connectionId: string,
|
||||
socket: net.Socket,
|
||||
record: IConnectionRecord,
|
||||
initialChunk: Buffer,
|
||||
networkProxyPort: number,
|
||||
cleanupCallback: (reason: string) => void
|
||||
)
|
||||
```
|
||||
- Creates a new socket connection to NetworkProxy
|
||||
- Pipes data between client and NetworkProxy sockets
|
||||
- Sets up cleanup handlers for both sockets
|
||||
|
||||
2. **Cleanup Coordination**:
|
||||
- When either socket ends or errors, both are cleaned up
|
||||
- Cleanup callback notifies SmartProxy's ConnectionManager
|
||||
- Proper unpipe operations prevent memory leaks
|
||||
|
||||
## NetworkProxy Connection Tracking
|
||||
|
||||
### Connection Tracking in NetworkProxy
|
||||
|
||||
1. **Raw TCP Connection Tracking**:
|
||||
```typescript
|
||||
setupConnectionTracking(): void {
|
||||
this.httpsServer.on('connection', (connection: net.Socket) => {
|
||||
// Track connections in socketMap
|
||||
this.socketMap.add(connection);
|
||||
|
||||
// Setup cleanup handlers
|
||||
connection.on('close', cleanupConnection);
|
||||
connection.on('error', cleanupConnection);
|
||||
connection.on('end', cleanupConnection);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
2. **SmartProxy Connection Detection**:
|
||||
- Connections from localhost (127.0.0.1) are identified as SmartProxy connections
|
||||
- Special counter tracks `portProxyConnections`
|
||||
- Connection counts are updated when connections close
|
||||
|
||||
3. **Metrics and Monitoring**:
|
||||
- Active connections tracked in `connectedClients`
|
||||
- TLS handshake completions tracked in `tlsTerminatedConnections`
|
||||
- Connection pool status monitored periodically
|
||||
|
||||
## Connection Termination Flow
|
||||
|
||||
### Typical TLS Termination Flow:
|
||||
|
||||
1. Client connects to SmartProxy
|
||||
2. SmartProxy creates connection record and tracks socket
|
||||
3. SmartProxy determines route requires TLS termination
|
||||
4. NetworkProxyBridge forwards connection to NetworkProxy
|
||||
5. NetworkProxy performs TLS termination
|
||||
6. Data flows through piped sockets
|
||||
7. When connection ends:
|
||||
- NetworkProxy cleans up its socket tracking
|
||||
- NetworkProxyBridge handles cleanup coordination
|
||||
- SmartProxy's ConnectionManager removes connection record
|
||||
- All resources are properly released
|
||||
|
||||
### Cleanup Coordination Points:
|
||||
|
||||
1. **SmartProxy Cleanup**:
|
||||
- ConnectionManager tracks all cleanup reasons
|
||||
- Socket handlers removed to prevent memory leaks
|
||||
- Timeout timers cleared
|
||||
- Connection records removed from maps
|
||||
- Security manager notified of connection removal
|
||||
|
||||
2. **NetworkProxy Cleanup**:
|
||||
- Sockets removed from tracking map
|
||||
- Connection counters updated
|
||||
- Metrics updated for monitoring
|
||||
- Connection pool resources freed
|
||||
|
||||
3. **Bridge Cleanup**:
|
||||
- Unpipe operations prevent data loss
|
||||
- Both sockets properly destroyed
|
||||
- Cleanup callback ensures SmartProxy is notified
|
||||
|
||||
## Important Considerations
|
||||
|
||||
1. **Memory Management**:
|
||||
- All event listeners must be removed during cleanup
|
||||
- Proper unpipe operations prevent memory leaks
|
||||
- Connection records cleared from all tracking maps
|
||||
|
||||
2. **Error Handling**:
|
||||
- Multiple cleanup mechanisms prevent orphaned connections
|
||||
- Graceful shutdown attempted before forced destruction
|
||||
- Timeout mechanisms ensure cleanup even in edge cases
|
||||
|
||||
3. **State Consistency**:
|
||||
- Connection closed flags prevent duplicate cleanup
|
||||
- Termination reasons tracked for debugging
|
||||
- Activity timestamps updated for accurate timeout handling
|
||||
|
||||
4. **Performance**:
|
||||
- Connection pools minimize TCP handshake overhead
|
||||
- Efficient socket tracking using Maps
|
||||
- Periodic cleanup prevents resource accumulation
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Always use `initiateCleanupOnce()` to prevent duplicate cleanup operations
|
||||
2. Track termination reasons for debugging and monitoring
|
||||
3. Ensure all event listeners are removed during cleanup
|
||||
4. Use proper unpipe operations when breaking socket connections
|
||||
5. Monitor connection counts and cleanup statistics
|
||||
6. Implement proper timeout handling for all connection types
|
||||
7. Keep socket tracking maps synchronized with actual socket state
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiryDate": "2025-08-16T18:25:31.732Z",
|
||||
"issueDate": "2025-05-18T18:25:31.732Z",
|
||||
"savedAt": "2025-05-18T18:25:31.734Z"
|
||||
"expiryDate": "2025-08-17T12:04:34.427Z",
|
||||
"issueDate": "2025-05-19T12:04:34.427Z",
|
||||
"savedAt": "2025-05-19T12:04:34.429Z"
|
||||
}
|
23
changelog.md
23
changelog.md
@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-05-19 - 19.3.1 - fix(certificates)
|
||||
Update static-route certificate metadata for ACME challenges
|
||||
|
||||
- Updated expiryDate and issueDate in certs/static-route/meta.json to reflect new certificate issuance information
|
||||
|
||||
## 2025-05-19 - 19.3.0 - feat(smartproxy)
|
||||
Update dependencies and enhance ACME certificate provisioning with wildcard support
|
||||
|
||||
- Bump @types/node from ^22.15.18 to ^22.15.19
|
||||
- Bump @push.rocks/smartacme from ^7.3.4 to ^8.0.0
|
||||
- Bump @push.rocks/smartnetwork from ^4.0.1 to ^4.0.2
|
||||
- Add new test (test.certificate-acme-update.ts) to verify wildcard certificate logic
|
||||
- Update SmartCertManager to request wildcard certificates if DNS-01 challenge is available
|
||||
|
||||
## 2025-05-19 - 19.2.6 - fix(tests)
|
||||
Adjust test cases for ACME challenge route handling, mutex locking in route updates, and port management. Remove obsolete challenge-route lifecycle tests and update expected outcomes in port80 management and race condition tests.
|
||||
|
||||
- Remove test file 'test.challenge-route-lifecycle.node.ts'
|
||||
- Rename 'acme-route' to 'secure-route' in port80 management tests to avoid confusion
|
||||
- Ensure port 80 is added only once when both user routes and ACME challenge use the same port
|
||||
- Improve mutex locking tests to guarantee serialized route updates with no concurrent execution
|
||||
- Adjust expected certificate manager recreation counts in race conditions tests
|
||||
|
||||
## 2025-05-19 - 19.2.5 - 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.
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "19.2.5",
|
||||
"version": "19.3.1",
|
||||
"private": false,
|
||||
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
|
||||
"main": "dist_ts/index.js",
|
||||
@ -18,16 +18,16 @@
|
||||
"@git.zone/tsbuild": "^2.5.1",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^1.9.0",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/node": "^22.15.19",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smartacme": "^7.3.4",
|
||||
"@push.rocks/smartacme": "^8.0.0",
|
||||
"@push.rocks/smartcrypto": "^2.0.4",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartfile": "^11.2.0",
|
||||
"@push.rocks/smartnetwork": "^4.0.1",
|
||||
"@push.rocks/smartnetwork": "^4.0.2",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrequest": "^2.1.0",
|
||||
"@push.rocks/smartstring": "^4.0.15",
|
||||
|
1267
pnpm-lock.yaml
generated
1267
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,86 +0,0 @@
|
||||
# ACME/Certificate Simplification Summary
|
||||
|
||||
## What Was Done
|
||||
|
||||
We successfully implemented the ACME/Certificate simplification plan for SmartProxy:
|
||||
|
||||
### 1. Created New Certificate Management System
|
||||
|
||||
- **SmartCertManager** (`ts/proxies/smart-proxy/certificate-manager.ts`): A unified certificate manager that handles both ACME and static certificates
|
||||
- **CertStore** (`ts/proxies/smart-proxy/cert-store.ts`): File-based certificate storage system
|
||||
|
||||
### 2. Updated Route Types
|
||||
|
||||
- Added `IRouteAcme` interface for ACME configuration
|
||||
- Added `IStaticResponse` interface for static route responses
|
||||
- Extended `IRouteTls` with comprehensive certificate options
|
||||
- Added `handler` property to `IRouteAction` for static routes
|
||||
|
||||
### 3. Implemented Static Route Handler
|
||||
|
||||
- Added `handleStaticAction` method to route-connection-handler.ts
|
||||
- Added support for 'static' route type in the action switch statement
|
||||
- Implemented proper HTTP response formatting
|
||||
|
||||
### 4. Updated SmartProxy Integration
|
||||
|
||||
- Removed old CertProvisioner and Port80Handler dependencies
|
||||
- Added `initializeCertificateManager` method
|
||||
- Updated `start` and `stop` methods to use new certificate manager
|
||||
- Added `provisionCertificate`, `renewCertificate`, and `getCertificateStatus` methods
|
||||
|
||||
### 5. Simplified NetworkProxyBridge
|
||||
|
||||
- Removed all certificate-related logic
|
||||
- Simplified to only handle network proxy forwarding
|
||||
- Updated to use port-based matching for network proxy routes
|
||||
|
||||
### 6. Cleaned Up HTTP Module
|
||||
|
||||
- Removed exports for port80 subdirectory
|
||||
- Kept only router and redirect functionality
|
||||
|
||||
### 7. Created Tests
|
||||
|
||||
- Created simplified test for certificate functionality
|
||||
- Test demonstrates static route handling and basic certificate configuration
|
||||
|
||||
## Key Improvements
|
||||
|
||||
1. **No Backward Compatibility**: Clean break from legacy implementations
|
||||
2. **Direct SmartAcme Integration**: Uses @push.rocks/smartacme directly without custom wrappers
|
||||
3. **Route-Based ACME Challenges**: No separate HTTP server needed
|
||||
4. **Simplified Architecture**: Removed unnecessary abstraction layers
|
||||
5. **Unified Configuration**: Certificate configuration is part of route definitions
|
||||
|
||||
## Configuration Example
|
||||
|
||||
```typescript
|
||||
const proxy = new SmartProxy({
|
||||
routes: [{
|
||||
name: 'secure-site',
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'backend', port: 8080 },
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
acme: {
|
||||
email: 'admin@example.com',
|
||||
useProduction: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Remove old certificate module and port80 directory
|
||||
2. Update documentation with new configuration format
|
||||
3. Test with real ACME certificates in staging environment
|
||||
4. Add more comprehensive tests for renewal and edge cases
|
||||
|
||||
The implementation is complete and builds successfully!
|
@ -1,34 +0,0 @@
|
||||
# NFTables Naming Consolidation Summary
|
||||
|
||||
This document summarizes the changes made to consolidate the naming convention for IP allow/block lists in the NFTables integration.
|
||||
|
||||
## Changes Made
|
||||
|
||||
1. **Updated NFTablesProxy interface** (`ts/proxies/nftables-proxy/models/interfaces.ts`):
|
||||
- Changed `allowedSourceIPs` to `ipAllowList`
|
||||
- Changed `bannedSourceIPs` to `ipBlockList`
|
||||
|
||||
2. **Updated NFTablesProxy implementation** (`ts/proxies/nftables-proxy/nftables-proxy.ts`):
|
||||
- Updated all references from `allowedSourceIPs` to `ipAllowList`
|
||||
- Updated all references from `bannedSourceIPs` to `ipBlockList`
|
||||
|
||||
3. **Updated NFTablesManager** (`ts/proxies/smart-proxy/nftables-manager.ts`):
|
||||
- Changed mapping from `allowedSourceIPs` to `ipAllowList`
|
||||
- Changed mapping from `bannedSourceIPs` to `ipBlockList`
|
||||
|
||||
## Files Already Using Consistent Naming
|
||||
|
||||
The following files already used the consistent naming convention `ipAllowList` and `ipBlockList`:
|
||||
|
||||
1. **Route helpers** (`ts/proxies/smart-proxy/utils/route-helpers.ts`)
|
||||
2. **Integration test** (`test/test.nftables-integration.ts`)
|
||||
3. **NFTables example** (`examples/nftables-integration.ts`)
|
||||
4. **Route types** (`ts/proxies/smart-proxy/models/route-types.ts`)
|
||||
|
||||
## Result
|
||||
|
||||
The naming is now consistent throughout the codebase:
|
||||
- `ipAllowList` is used for lists of allowed IP addresses
|
||||
- `ipBlockList` is used for lists of blocked IP addresses
|
||||
|
||||
This matches the naming convention already established in SmartProxy's core routing system.
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
EventSystem,
|
||||
ProxyEvents,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { IpUtils } from '../../../ts/core/utils/ip-utils.js';
|
||||
|
||||
tap.test('ip-utils - normalizeIP', async () => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as routeUtils from '../../../ts/core/utils/route-utils.js';
|
||||
|
||||
// Test domain matching
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SharedSecurityManager } from '../../../ts/core/utils/shared-security-manager.js';
|
||||
import type { IRouteConfig, IRouteContext } from '../../../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { ValidationUtils } from '../../../ts/core/utils/validation-utils.js';
|
||||
import type { IDomainOptions, IAcmeOptions } from '../../../ts/core/models/common-types.js';
|
||||
|
||||
|
129
test/test.acme-http-challenge.ts
Normal file
129
test/test.acme-http-challenge.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
tap.test('should handle HTTP requests on port 80 for ACME challenges', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
// Track HTTP requests that are handled
|
||||
const handledRequests: any[] = [];
|
||||
|
||||
const settings = {
|
||||
routes: [
|
||||
{
|
||||
name: 'acme-test-route',
|
||||
match: {
|
||||
ports: [18080], // Use high port to avoid permission issues
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static' as const,
|
||||
handler: async (context) => {
|
||||
handledRequests.push({
|
||||
path: context.path,
|
||||
method: context.method,
|
||||
headers: context.headers
|
||||
});
|
||||
|
||||
// Simulate ACME challenge response
|
||||
const token = context.path?.split('/').pop() || '';
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: `challenge-response-for-${token}`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Mock NFTables manager
|
||||
(proxy as any).nftablesManager = {
|
||||
ensureNFTablesSetup: async () => {},
|
||||
stop: async () => {}
|
||||
};
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Make an HTTP request to the challenge endpoint
|
||||
const response = await fetch('http://localhost:18080/.well-known/acme-challenge/test-token', {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
// Verify response
|
||||
expect(response.status).toEqual(200);
|
||||
const body = await response.text();
|
||||
expect(body).toEqual('challenge-response-for-test-token');
|
||||
|
||||
// Verify request was handled
|
||||
expect(handledRequests.length).toEqual(1);
|
||||
expect(handledRequests[0].path).toEqual('/.well-known/acme-challenge/test-token');
|
||||
expect(handledRequests[0].method).toEqual('GET');
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.test('should parse HTTP headers correctly', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
const capturedContext: any = {};
|
||||
|
||||
const settings = {
|
||||
routes: [
|
||||
{
|
||||
name: 'header-test-route',
|
||||
match: {
|
||||
ports: [18081]
|
||||
},
|
||||
action: {
|
||||
type: 'static' as const,
|
||||
handler: async (context) => {
|
||||
Object.assign(capturedContext, context);
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
received: context.headers
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Mock NFTables manager
|
||||
(proxy as any).nftablesManager = {
|
||||
ensureNFTablesSetup: async () => {},
|
||||
stop: async () => {}
|
||||
};
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Make request with custom headers
|
||||
const response = await fetch('http://localhost:18081/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Custom-Header': 'test-value',
|
||||
'User-Agent': 'test-agent'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
const body = await response.json();
|
||||
|
||||
// Verify headers were parsed correctly
|
||||
expect(capturedContext.headers['x-custom-header']).toEqual('test-value');
|
||||
expect(capturedContext.headers['user-agent']).toEqual('test-agent');
|
||||
expect(capturedContext.method).toEqual('POST');
|
||||
expect(capturedContext.path).toEqual('/test');
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
139
test/test.acme-route-creation.ts
Normal file
139
test/test.acme-route-creation.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
/**
|
||||
* Test that verifies ACME challenge routes are properly created
|
||||
*/
|
||||
tap.test('should create ACME challenge route with high ports', async (tools) => {
|
||||
tools.timeout(5000);
|
||||
|
||||
const capturedRoutes: any[] = [];
|
||||
|
||||
const settings = {
|
||||
routes: [
|
||||
{
|
||||
name: 'secure-route',
|
||||
match: {
|
||||
ports: [18443], // High port to avoid permission issues
|
||||
domains: 'test.local'
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
target: { host: 'localhost', port: 8080 },
|
||||
tls: {
|
||||
mode: 'terminate' as const,
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
acme: {
|
||||
email: 'test@test.local',
|
||||
port: 18080 // High port for ACME challenges
|
||||
}
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Capture route updates
|
||||
const originalUpdateRoutes = (proxy as any).updateRoutesInternal.bind(proxy);
|
||||
(proxy as any).updateRoutesInternal = async function(routes: any[]) {
|
||||
capturedRoutes.push([...routes]);
|
||||
return originalUpdateRoutes(routes);
|
||||
};
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Check that ACME challenge route was added
|
||||
const finalRoutes = capturedRoutes[capturedRoutes.length - 1];
|
||||
const challengeRoute = finalRoutes.find((r: any) => r.name === 'acme-challenge');
|
||||
|
||||
expect(challengeRoute).toBeDefined();
|
||||
expect(challengeRoute.match.path).toEqual('/.well-known/acme-challenge/*');
|
||||
expect(challengeRoute.match.ports).toEqual(18080);
|
||||
expect(challengeRoute.action.type).toEqual('static');
|
||||
expect(challengeRoute.priority).toEqual(1000);
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.test('should handle HTTP request parsing correctly', async (tools) => {
|
||||
tools.timeout(5000);
|
||||
|
||||
let handlerCalled = false;
|
||||
let receivedContext: any;
|
||||
|
||||
const settings = {
|
||||
routes: [
|
||||
{
|
||||
name: 'test-static',
|
||||
match: {
|
||||
ports: [18090],
|
||||
path: '/test/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static' as const,
|
||||
handler: async (context) => {
|
||||
handlerCalled = true;
|
||||
receivedContext = context;
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'OK'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Mock NFTables manager
|
||||
(proxy as any).nftablesManager = {
|
||||
ensureNFTablesSetup: async () => {},
|
||||
stop: async () => {}
|
||||
};
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Create a simple HTTP request
|
||||
const client = new plugins.net.Socket();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.connect(18090, 'localhost', () => {
|
||||
// Send HTTP request
|
||||
const request = [
|
||||
'GET /test/example HTTP/1.1',
|
||||
'Host: localhost:18090',
|
||||
'User-Agent: test-client',
|
||||
'',
|
||||
''
|
||||
].join('\r\n');
|
||||
|
||||
client.write(request);
|
||||
|
||||
// Wait for response
|
||||
client.on('data', (data) => {
|
||||
const response = data.toString();
|
||||
expect(response).toContain('HTTP/1.1 200');
|
||||
expect(response).toContain('OK');
|
||||
client.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
client.on('error', reject);
|
||||
});
|
||||
|
||||
// Verify handler was called
|
||||
expect(handlerCalled).toBeTrue();
|
||||
expect(receivedContext).toBeDefined();
|
||||
expect(receivedContext.path).toEqual('/test/example');
|
||||
expect(receivedContext.method).toEqual('GET');
|
||||
expect(receivedContext.headers.host).toEqual('localhost:18090');
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
116
test/test.acme-simple.ts
Normal file
116
test/test.acme-simple.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
|
||||
/**
|
||||
* Simple test to verify HTTP parsing works for ACME challenges
|
||||
*/
|
||||
tap.test('should parse HTTP requests correctly', async (tools) => {
|
||||
tools.timeout(15000);
|
||||
|
||||
let receivedRequest = '';
|
||||
|
||||
// Create a simple HTTP server to test the parsing
|
||||
const server = net.createServer((socket) => {
|
||||
socket.on('data', (data) => {
|
||||
receivedRequest = data.toString();
|
||||
|
||||
// Send response
|
||||
const response = [
|
||||
'HTTP/1.1 200 OK',
|
||||
'Content-Type: text/plain',
|
||||
'Content-Length: 2',
|
||||
'',
|
||||
'OK'
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(response);
|
||||
socket.end();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(18091, () => {
|
||||
console.log('Test server listening on port 18091');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Connect and send request
|
||||
const client = net.connect(18091, 'localhost');
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on('connect', () => {
|
||||
const request = [
|
||||
'GET /.well-known/acme-challenge/test-token HTTP/1.1',
|
||||
'Host: localhost:18091',
|
||||
'User-Agent: test-client',
|
||||
'',
|
||||
''
|
||||
].join('\r\n');
|
||||
|
||||
client.write(request);
|
||||
});
|
||||
|
||||
client.on('data', (data) => {
|
||||
const response = data.toString();
|
||||
expect(response).toContain('200 OK');
|
||||
client.end();
|
||||
});
|
||||
|
||||
client.on('end', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on('error', reject);
|
||||
});
|
||||
|
||||
// Verify we received the request
|
||||
expect(receivedRequest).toContain('GET /.well-known/acme-challenge/test-token');
|
||||
expect(receivedRequest).toContain('Host: localhost:18091');
|
||||
|
||||
server.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test to verify ACME route configuration
|
||||
*/
|
||||
tap.test('should configure ACME challenge route', async () => {
|
||||
// Simple test to verify the route configuration structure
|
||||
const challengeRoute = {
|
||||
name: 'acme-challenge',
|
||||
priority: 1000,
|
||||
match: {
|
||||
ports: 80,
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static',
|
||||
handler: async (context: any) => {
|
||||
const token = context.path?.split('/').pop() || '';
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: `challenge-response-${token}`
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(challengeRoute.name).toEqual('acme-challenge');
|
||||
expect(challengeRoute.match.path).toEqual('/.well-known/acme-challenge/*');
|
||||
expect(challengeRoute.match.ports).toEqual(80);
|
||||
expect(challengeRoute.priority).toEqual(1000);
|
||||
|
||||
// Test the handler
|
||||
const context = {
|
||||
path: '/.well-known/acme-challenge/test-token',
|
||||
method: 'GET',
|
||||
headers: {}
|
||||
};
|
||||
|
||||
const response = await challengeRoute.action.handler(context);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual('challenge-response-test-token');
|
||||
});
|
||||
|
||||
tap.start();
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { AcmeStateManager } from '../ts/proxies/smart-proxy/acme-state-manager.js';
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
|
77
test/test.certificate-acme-update.ts
Normal file
77
test/test.certificate-acme-update.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as smartproxy from '../ts/index.js';
|
||||
|
||||
// This test verifies that SmartProxy correctly uses the updated SmartAcme v8.0.0 API
|
||||
// with the optional wildcard parameter
|
||||
|
||||
tap.test('SmartCertManager should call getCertificateForDomain with wildcard option', async () => {
|
||||
console.log('Testing SmartCertManager with SmartAcme v8.0.0 API...');
|
||||
|
||||
// Create a mock route with ACME certificate configuration
|
||||
const mockRoute: smartproxy.IRouteConfig = {
|
||||
match: {
|
||||
domains: ['test.example.com'],
|
||||
ports: 443
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 8080
|
||||
},
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
acme: {
|
||||
email: 'test@example.com',
|
||||
useProduction: false
|
||||
}
|
||||
}
|
||||
},
|
||||
name: 'test-route'
|
||||
};
|
||||
|
||||
// Create a certificate manager
|
||||
const certManager = new smartproxy.SmartCertManager(
|
||||
[mockRoute],
|
||||
'./test-certs',
|
||||
{
|
||||
email: 'test@example.com',
|
||||
useProduction: false
|
||||
}
|
||||
);
|
||||
|
||||
// Since we can't actually test ACME in a unit test, we'll just verify the logic
|
||||
// The actual test would be that it builds and runs without errors
|
||||
|
||||
// Test the wildcard logic for different domain types and challenge handlers
|
||||
const testCases = [
|
||||
{ domain: 'example.com', hasDnsChallenge: true, shouldIncludeWildcard: true },
|
||||
{ domain: 'example.com', hasDnsChallenge: false, shouldIncludeWildcard: false },
|
||||
{ domain: 'sub.example.com', hasDnsChallenge: true, shouldIncludeWildcard: true },
|
||||
{ domain: 'sub.example.com', hasDnsChallenge: false, shouldIncludeWildcard: false },
|
||||
{ domain: '*.example.com', hasDnsChallenge: true, shouldIncludeWildcard: false },
|
||||
{ domain: '*.example.com', hasDnsChallenge: false, shouldIncludeWildcard: false },
|
||||
{ domain: 'test', hasDnsChallenge: true, shouldIncludeWildcard: false }, // single label domain
|
||||
{ domain: 'test', hasDnsChallenge: false, shouldIncludeWildcard: false },
|
||||
{ domain: 'my.sub.example.com', hasDnsChallenge: true, shouldIncludeWildcard: true },
|
||||
{ domain: 'my.sub.example.com', hasDnsChallenge: false, shouldIncludeWildcard: false }
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const shouldIncludeWildcard = !testCase.domain.startsWith('*.') &&
|
||||
testCase.domain.includes('.') &&
|
||||
testCase.domain.split('.').length >= 2 &&
|
||||
testCase.hasDnsChallenge;
|
||||
|
||||
console.log(`Domain: ${testCase.domain}, DNS-01: ${testCase.hasDnsChallenge}, Should include wildcard: ${shouldIncludeWildcard}`);
|
||||
expect(shouldIncludeWildcard).toEqual(testCase.shouldIncludeWildcard);
|
||||
}
|
||||
|
||||
console.log('All wildcard logic tests passed!');
|
||||
});
|
||||
|
||||
tap.start({
|
||||
throwOnError: true
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
const testProxy = new SmartProxy({
|
||||
routes: [{
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
tap.test('should create SmartProxy with certificate routes', async () => {
|
||||
const proxy = new SmartProxy({
|
||||
|
@ -1,346 +0,0 @@
|
||||
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();
|
@ -1,5 +1,5 @@
|
||||
import * as path from 'path';
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import type { IForwardConfig, TForwardingType } from '../ts/forwarding/config/forwarding-types.js';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// First, import the components directly to avoid issues with compiled modules
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { NetworkProxy } from '../ts/proxies/network-proxy/index.js';
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartproxy from '../ts/index.js';
|
||||
import { loadTestCertificates } from './helpers/certificates.js';
|
||||
import * as https from 'https';
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { NFTablesManager } from '../ts/proxies/smart-proxy/nftables-manager.js';
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
import type { ISmartProxyOptions } from '../ts/proxies/smart-proxy/models/interfaces.js';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import { NFTablesManager } from '../ts/proxies/smart-proxy/nftables-manager.js';
|
||||
import { createNfTablesRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
import {
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
/**
|
||||
* Test that verifies port 80 is not double-registered when both
|
||||
* user routes and ACME challenges use the same port
|
||||
*/
|
||||
tap.test('should not double-register port 80 when user route and ACME use same port', async (tools) => {
|
||||
tools.timeout(5000);
|
||||
|
||||
@ -21,7 +25,7 @@ tap.test('should not double-register port 80 when user route and ACME use same p
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'acme-route',
|
||||
name: 'secure-route',
|
||||
match: {
|
||||
ports: [443]
|
||||
},
|
||||
@ -44,57 +48,61 @@ tap.test('should not double-register port 80 when user route and ACME use same p
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Mock the port manager to track port additions
|
||||
(proxy as any).portManager = {
|
||||
const mockPortManager = {
|
||||
addPort: async (port: number) => {
|
||||
if (activePorts.has(port)) {
|
||||
// This is the deduplication behavior we're testing
|
||||
return;
|
||||
return; // Simulate deduplication
|
||||
}
|
||||
|
||||
activePorts.add(port);
|
||||
if (port === 80) {
|
||||
port80AddCount++;
|
||||
}
|
||||
},
|
||||
|
||||
addPorts: async (ports: number[]) => {
|
||||
for (const port of ports) {
|
||||
await (proxy as any).portManager.addPort(port);
|
||||
await mockPortManager.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);
|
||||
await mockPortManager.addPort(port);
|
||||
}
|
||||
},
|
||||
|
||||
setShuttingDown: () => {},
|
||||
getPortForRoutes: () => new Map(),
|
||||
closeAll: async () => { activePorts.clear(); },
|
||||
stop: async () => { await (proxy as any).portManager.closeAll(); }
|
||||
stop: async () => { await mockPortManager.closeAll(); }
|
||||
};
|
||||
|
||||
// Inject mock
|
||||
(proxy as any).portManager = mockPortManager;
|
||||
|
||||
// Mock certificate manager to prevent ACME calls
|
||||
(proxy as any).createCertificateManager = async function(routes: any[], certDir: string, acmeOptions: any, initialState?: any) {
|
||||
const mockCertManager = {
|
||||
setUpdateRoutesCallback: function(callback: any) { /* noop */ },
|
||||
setNetworkProxy: function() {},
|
||||
setGlobalAcmeDefaults: function() {},
|
||||
setAcmeStateManager: function() {},
|
||||
initialize: async function() {
|
||||
// Simulate ACME route addition
|
||||
const challengeRoute = {
|
||||
name: 'acme-challenge',
|
||||
priority: 1000,
|
||||
match: {
|
||||
ports: acmeOptions?.port || 80,
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
}
|
||||
};
|
||||
// This would trigger route update in real implementation
|
||||
},
|
||||
getAcmeOptions: () => acmeOptions,
|
||||
getState: () => ({ challengeRouteActive: false }),
|
||||
stop: async () => {}
|
||||
};
|
||||
return mockCertManager;
|
||||
};
|
||||
|
||||
// Mock NFTables
|
||||
@ -103,85 +111,25 @@ tap.test('should not double-register port 80 when user route and ACME use same p
|
||||
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
|
||||
// Mock admin server
|
||||
(proxy as any).startAdminServer = async function() {
|
||||
this.servers.set(this.settings.port, {
|
||||
(this as any).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();
|
||||
}
|
||||
await proxy.start();
|
||||
|
||||
// Verify that port 80 was added only once
|
||||
tools.expect(port80AddCount).toEqual(1);
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test that verifies ACME can use a different port than user routes
|
||||
*/
|
||||
tap.test('should handle ACME on different port than user routes', async (tools) => {
|
||||
tools.timeout(5000);
|
||||
|
||||
@ -202,7 +150,7 @@ tap.test('should handle ACME on different port than user routes', async (tools)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'acme-route',
|
||||
name: 'secure-route',
|
||||
match: {
|
||||
ports: [443]
|
||||
},
|
||||
@ -225,34 +173,57 @@ tap.test('should handle ACME on different port than user routes', async (tools)
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Mock the port manager
|
||||
(proxy as any).portManager = {
|
||||
const mockPortManager = {
|
||||
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);
|
||||
await mockPortManager.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);
|
||||
await mockPortManager.addPort(port);
|
||||
}
|
||||
},
|
||||
|
||||
setShuttingDown: () => {},
|
||||
getPortForRoutes: () => new Map(),
|
||||
closeAll: async () => { activePorts.clear(); },
|
||||
stop: async () => { await (proxy as any).portManager.closeAll(); }
|
||||
stop: async () => { await mockPortManager.closeAll(); }
|
||||
};
|
||||
|
||||
// Inject mocks
|
||||
(proxy as any).portManager = mockPortManager;
|
||||
|
||||
// Mock certificate manager
|
||||
(proxy as any).createCertificateManager = async function(routes: any[], certDir: string, acmeOptions: any, initialState?: any) {
|
||||
const mockCertManager = {
|
||||
setUpdateRoutesCallback: function(callback: any) { /* noop */ },
|
||||
setNetworkProxy: function() {},
|
||||
setGlobalAcmeDefaults: function() {},
|
||||
setAcmeStateManager: function() {},
|
||||
initialize: async function() {
|
||||
// Simulate ACME route addition on different port
|
||||
const challengeRoute = {
|
||||
name: 'acme-challenge',
|
||||
priority: 1000,
|
||||
match: {
|
||||
ports: acmeOptions?.port || 80,
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static'
|
||||
}
|
||||
};
|
||||
},
|
||||
getAcmeOptions: () => acmeOptions,
|
||||
getState: () => ({ challengeRouteActive: false }),
|
||||
stop: async () => {}
|
||||
};
|
||||
return mockCertManager;
|
||||
};
|
||||
|
||||
// Mock NFTables
|
||||
@ -261,85 +232,22 @@ tap.test('should handle ACME on different port than user routes', async (tools)
|
||||
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, {
|
||||
(this as any).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();
|
||||
}
|
||||
await proxy.start();
|
||||
|
||||
// Verify that all expected ports were added
|
||||
tools.expect(portAddHistory).toContain(80); // User route
|
||||
tools.expect(portAddHistory).toContain(443); // TLS route
|
||||
tools.expect(portAddHistory).toContain(8080); // ACME challenge on different port
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
export default tap;
|
@ -1,6 +1,9 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
/**
|
||||
* Test that verifies mutex prevents race conditions during concurrent route updates
|
||||
*/
|
||||
tap.test('should handle concurrent route updates without race conditions', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
@ -54,227 +57,27 @@ tap.test('should handle concurrent route updates without race conditions', async
|
||||
|
||||
// Verify final state
|
||||
const currentRoutes = proxy['settings'].routes;
|
||||
tools.expect(currentRoutes.length).toBeGreaterThan(1);
|
||||
tools.expect(currentRoutes.length).toEqual(2); // Initial route + last update
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.test('should preserve certificate manager state during rapid updates', async (tools) => {
|
||||
/**
|
||||
* Test that verifies mutex serializes route updates
|
||||
*/
|
||||
tap.test('should serialize route updates with mutex', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
const settings = {
|
||||
port: 6002,
|
||||
routes: [
|
||||
{
|
||||
name: 'test-route',
|
||||
match: {
|
||||
ports: [443]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: 'https://localhost:3001',
|
||||
tls: {
|
||||
mode: 'terminate' as const,
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
routes: [{
|
||||
name: 'test-route',
|
||||
match: { ports: [80] },
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: 'http://localhost:3000'
|
||||
}
|
||||
],
|
||||
acme: {
|
||||
email: 'test@test.com',
|
||||
port: 80
|
||||
}
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
await proxy.start();
|
||||
|
||||
// Get initial certificate manager reference
|
||||
const initialCertManager = proxy['certManager'];
|
||||
tools.expect(initialCertManager).not.toBeNull();
|
||||
|
||||
// Perform rapid route updates
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await proxy.updateRoutes([
|
||||
...settings.routes,
|
||||
{
|
||||
name: `extra-route-${i}`,
|
||||
match: {
|
||||
ports: [8000 + i]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: `http://localhost:${4000 + i}`
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
// Certificate manager should be recreated but state preserved
|
||||
const finalCertManager = proxy['certManager'];
|
||||
tools.expect(finalCertManager).not.toBeNull();
|
||||
tools.expect(finalCertManager).not.toEqual(initialCertManager);
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.test('should handle challenge route state correctly across recreations', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
let challengeRouteAddCount = 0;
|
||||
|
||||
const settings = {
|
||||
port: 6003,
|
||||
routes: [
|
||||
{
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Mock the route update to count challenge route additions
|
||||
const originalUpdateRoutes = proxy['updateRoutes'].bind(proxy);
|
||||
proxy['updateRoutes'] = async (routes: any[]) => {
|
||||
if (routes.some(r => r.name === 'acme-challenge')) {
|
||||
challengeRouteAddCount++;
|
||||
}
|
||||
return originalUpdateRoutes(routes);
|
||||
};
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Multiple route updates
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await proxy.updateRoutes([
|
||||
...settings.routes,
|
||||
{
|
||||
name: `dynamic-route-${i}`,
|
||||
match: {
|
||||
ports: [9000 + i]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: `http://localhost:${5000 + i}`
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
// Challenge route should only be added once during initial start
|
||||
tools.expect(challengeRouteAddCount).toEqual(1);
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.test('should prevent port conflicts during certificate manager recreation', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
const settings = {
|
||||
port: 6004,
|
||||
routes: [
|
||||
{
|
||||
name: 'http-route',
|
||||
match: {
|
||||
ports: [80]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: 'http://localhost:3000'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'https-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 // Same as user route
|
||||
}
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Track port operations
|
||||
let port80AddCount = 0;
|
||||
const originalPortManager = proxy['portManager'];
|
||||
const originalAddPort = originalPortManager.addPort.bind(originalPortManager);
|
||||
originalPortManager.addPort = async (port: number) => {
|
||||
if (port === 80) {
|
||||
port80AddCount++;
|
||||
}
|
||||
return originalAddPort(port);
|
||||
};
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Update routes multiple times
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await proxy.updateRoutes([
|
||||
...settings.routes,
|
||||
{
|
||||
name: `temp-route-${i}`,
|
||||
match: {
|
||||
ports: [7000 + i]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: `http://localhost:${6000 + i}`
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
// Port 80 should be maintained properly without conflicts
|
||||
tools.expect(port80AddCount).toBeGreaterThan(0);
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.test('should handle mutex locking correctly', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
const settings = {
|
||||
port: 6005,
|
||||
routes: [
|
||||
{
|
||||
name: 'test-route',
|
||||
match: {
|
||||
ports: [80]
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: 'http://localhost:3000'
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
@ -282,16 +85,17 @@ tap.test('should handle mutex locking correctly', async (tools) => {
|
||||
|
||||
let updateStartCount = 0;
|
||||
let updateEndCount = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
// Wrap updateRoutes to track concurrent execution
|
||||
const originalUpdateRoutes = proxy['updateRoutes'].bind(proxy);
|
||||
proxy['updateRoutes'] = async (routes: any[]) => {
|
||||
updateStartCount++;
|
||||
const startCount = updateStartCount;
|
||||
const endCount = updateEndCount;
|
||||
const concurrent = updateStartCount - updateEndCount;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||
|
||||
// If mutex is working, start count should never be more than end count + 1
|
||||
tools.expect(startCount).toBeLessThanOrEqual(endCount + 1);
|
||||
// If mutex is working, only one update should run at a time
|
||||
tools.expect(concurrent).toEqual(1);
|
||||
|
||||
const result = await originalUpdateRoutes(routes);
|
||||
updateEndCount++;
|
||||
@ -305,9 +109,7 @@ tap.test('should handle mutex locking correctly', async (tools) => {
|
||||
...settings.routes,
|
||||
{
|
||||
name: `concurrent-route-${i}`,
|
||||
match: {
|
||||
ports: [2000 + i]
|
||||
},
|
||||
match: { ports: [2000 + i] },
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: `http://localhost:${3000 + i}`
|
||||
@ -321,6 +123,73 @@ tap.test('should handle mutex locking correctly', async (tools) => {
|
||||
// All updates should have completed
|
||||
tools.expect(updateStartCount).toEqual(5);
|
||||
tools.expect(updateEndCount).toEqual(5);
|
||||
tools.expect(maxConcurrent).toEqual(1); // Mutex ensures only one at a time
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test that challenge route state is preserved across certificate manager recreations
|
||||
*/
|
||||
tap.test('should preserve challenge route state during cert manager recreation', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
const settings = {
|
||||
port: 6003,
|
||||
routes: [{
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Track certificate manager recreations
|
||||
let certManagerCreationCount = 0;
|
||||
const originalCreateCertManager = proxy['createCertificateManager'].bind(proxy);
|
||||
proxy['createCertificateManager'] = async (...args: any[]) => {
|
||||
certManagerCreationCount++;
|
||||
return originalCreateCertManager(...args);
|
||||
};
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Initial creation
|
||||
tools.expect(certManagerCreationCount).toEqual(1);
|
||||
|
||||
// Multiple route updates
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await proxy.updateRoutes([
|
||||
...settings.routes,
|
||||
{
|
||||
name: `dynamic-route-${i}`,
|
||||
match: { ports: [9000 + i] },
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targetUrl: `http://localhost:${5000 + i}`
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
// Certificate manager should be recreated for each update
|
||||
tools.expect(certManagerCreationCount).toEqual(4); // 1 initial + 3 updates
|
||||
|
||||
// State should be preserved (challenge route active)
|
||||
const globalState = proxy['globalChallengeRouteActive'];
|
||||
tools.expect(globalState).toBeDefined();
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tests for the unified route-based configuration system
|
||||
*/
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
// Import from core modules
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
let testProxy: SmartProxy;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Import from individual modules to avoid naming conflicts
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
import * as http from 'http';
|
||||
import { ProxyRouter, type RouterResult } from '../ts/http/router/proxy-router.js';
|
||||
|
104
test/test.simple-acme-mock.ts
Normal file
104
test/test.simple-acme-mock.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
|
||||
/**
|
||||
* Simple test to check that ACME challenge routes are created
|
||||
*/
|
||||
tap.test('should create ACME challenge route', async (tools) => {
|
||||
tools.timeout(5000);
|
||||
|
||||
const mockRouteUpdates: any[] = [];
|
||||
|
||||
const settings = {
|
||||
routes: [
|
||||
{
|
||||
name: 'secure-route',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: 'test.example.com'
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
target: { host: 'localhost', port: 8080 },
|
||||
tls: {
|
||||
mode: 'terminate' as const,
|
||||
certificate: 'auto',
|
||||
acme: {
|
||||
email: 'test@test.local' // Use non-example.com domain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const proxy = new SmartProxy(settings);
|
||||
|
||||
// Mock certificate manager
|
||||
let updateRoutesCallback: any;
|
||||
|
||||
(proxy as any).createCertificateManager = async function(routes: any[], certDir: string, acmeOptions: any) {
|
||||
const mockCertManager = {
|
||||
setUpdateRoutesCallback: function(callback: any) {
|
||||
updateRoutesCallback = callback;
|
||||
},
|
||||
setNetworkProxy: function() {},
|
||||
setGlobalAcmeDefaults: function() {},
|
||||
setAcmeStateManager: function() {},
|
||||
initialize: async function() {
|
||||
// Simulate adding ACME challenge route
|
||||
if (updateRoutesCallback) {
|
||||
const challengeRoute = {
|
||||
name: 'acme-challenge',
|
||||
priority: 1000,
|
||||
match: {
|
||||
ports: 80,
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static',
|
||||
handler: async (context: any) => {
|
||||
const token = context.path?.split('/').pop() || '';
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: `mock-challenge-response-${token}`
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updatedRoutes = [...routes, challengeRoute];
|
||||
mockRouteUpdates.push(updatedRoutes);
|
||||
await updateRoutesCallback(updatedRoutes);
|
||||
}
|
||||
},
|
||||
getAcmeOptions: () => acmeOptions,
|
||||
getState: () => ({ challengeRouteActive: false }),
|
||||
stop: async () => {}
|
||||
};
|
||||
return mockCertManager;
|
||||
};
|
||||
|
||||
// Mock NFTables
|
||||
(proxy as any).nftablesManager = {
|
||||
ensureNFTablesSetup: async () => {},
|
||||
stop: async () => {}
|
||||
};
|
||||
|
||||
await proxy.start();
|
||||
|
||||
// Verify that routes were updated with challenge route
|
||||
expect(mockRouteUpdates.length).toBeGreaterThan(0);
|
||||
|
||||
const lastUpdate = mockRouteUpdates[mockRouteUpdates.length - 1];
|
||||
const challengeRoute = lastUpdate.find((r: any) => r.name === 'acme-challenge');
|
||||
|
||||
expect(challengeRoute).toBeDefined();
|
||||
expect(challengeRoute.match.path).toEqual('/.well-known/acme-challenge/*');
|
||||
expect(challengeRoute.match.ports).toEqual(80);
|
||||
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
@ -1,5 +1,5 @@
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartCertManager } from '../ts/proxies/smart-proxy/certificate-manager.js';
|
||||
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
|
||||
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '19.2.5',
|
||||
version: '19.3.1',
|
||||
description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.'
|
||||
}
|
||||
|
@ -219,21 +219,12 @@ export class NetworkProxy implements IMetricsTracker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use SmartCertManager instead
|
||||
*/
|
||||
public setExternalPort80Handler(handler: any): void {
|
||||
this.logger.warn('Port80Handler is deprecated - use SmartCertManager instead');
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the proxy server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
this.startTime = Date.now();
|
||||
|
||||
// Certificate management is now handled by SmartCertManager
|
||||
|
||||
// Create HTTP/2 server with HTTP/1 fallback
|
||||
this.httpsServer = plugins.http2.createSecureServer(
|
||||
{
|
||||
|
@ -244,8 +244,29 @@ export class SmartCertManager {
|
||||
// Challenge route should already be active from initialization
|
||||
// No need to add it for each certificate
|
||||
|
||||
// Use smartacme to get certificate
|
||||
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain);
|
||||
// Determine if we should request a wildcard certificate
|
||||
// Only request wildcards if:
|
||||
// 1. The primary domain is not already a wildcard
|
||||
// 2. The domain has multiple parts (can have subdomains)
|
||||
// 3. We have DNS-01 challenge support (required for wildcards)
|
||||
const hasDnsChallenge = (this.smartAcme as any).challengeHandlers?.some((handler: any) =>
|
||||
handler.getSupportedTypes && handler.getSupportedTypes().includes('dns-01')
|
||||
);
|
||||
|
||||
const shouldIncludeWildcard = !primaryDomain.startsWith('*.') &&
|
||||
primaryDomain.includes('.') &&
|
||||
primaryDomain.split('.').length >= 2 &&
|
||||
hasDnsChallenge;
|
||||
|
||||
if (shouldIncludeWildcard) {
|
||||
console.log(`Requesting wildcard certificate for ${primaryDomain} (DNS-01 available)`);
|
||||
}
|
||||
|
||||
// Use smartacme to get certificate with optional wildcard
|
||||
const cert = await this.smartAcme.getCertificateForDomain(
|
||||
primaryDomain,
|
||||
shouldIncludeWildcard ? { includeWildcard: true } : undefined
|
||||
);
|
||||
|
||||
// SmartAcme's Cert object has these properties:
|
||||
// - publicKey: The certificate PEM string
|
||||
|
@ -47,6 +47,7 @@ export interface IRouteContext {
|
||||
path?: string; // URL path (for HTTP connections)
|
||||
query?: string; // Query string (for HTTP connections)
|
||||
headers?: Record<string, string>; // HTTP headers (for HTTP connections)
|
||||
method?: string; // HTTP method (for HTTP connections)
|
||||
|
||||
// TLS information
|
||||
isTls: boolean; // Whether the connection is TLS
|
||||
|
@ -728,45 +728,139 @@ export class RouteConnectionHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build route context
|
||||
const context: IRouteContext = {
|
||||
port: record.localPort,
|
||||
domain: record.lockedDomain,
|
||||
clientIp: record.remoteIP,
|
||||
serverIp: socket.localAddress!,
|
||||
path: undefined, // Will need to be extracted from HTTP request
|
||||
isTls: record.isTLS,
|
||||
tlsVersion: record.tlsVersion,
|
||||
routeName: route.name,
|
||||
routeId: route.name,
|
||||
timestamp: Date.now(),
|
||||
connectionId
|
||||
};
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
const handleHttpData = async (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
// Call the handler
|
||||
const response = await route.action.handler(context);
|
||||
|
||||
// Send HTTP response
|
||||
const headers = response.headers || {};
|
||||
headers['Content-Length'] = Buffer.byteLength(response.body).toString();
|
||||
|
||||
let httpResponse = `HTTP/1.1 ${response.status} ${getStatusText(response.status)}\r\n`;
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
httpResponse += `${key}: ${value}\r\n`;
|
||||
// Look for end of HTTP headers
|
||||
const headerEndIndex = buffer.indexOf('\r\n\r\n');
|
||||
if (headerEndIndex === -1) {
|
||||
// Need more data
|
||||
if (buffer.length > 8192) { // Prevent excessive buffering
|
||||
console.error(`[${connectionId}] HTTP headers too large`);
|
||||
socket.end();
|
||||
this.connectionManager.cleanupConnection(record, 'headers_too_large');
|
||||
}
|
||||
return;
|
||||
}
|
||||
httpResponse += '\r\n';
|
||||
|
||||
socket.write(httpResponse);
|
||||
socket.write(response.body);
|
||||
socket.end();
|
||||
// Parse the HTTP request
|
||||
const headerBuffer = buffer.slice(0, headerEndIndex);
|
||||
const headers = headerBuffer.toString();
|
||||
const lines = headers.split('\r\n');
|
||||
|
||||
this.connectionManager.cleanupConnection(record, 'completed');
|
||||
} catch (error) {
|
||||
console.error(`[${connectionId}] Error in static handler: ${error}`);
|
||||
socket.end();
|
||||
this.connectionManager.cleanupConnection(record, 'handler_error');
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
console.error(`[${connectionId}] Invalid HTTP request`);
|
||||
socket.end();
|
||||
this.connectionManager.cleanupConnection(record, 'invalid_request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse request line
|
||||
const requestLine = lines[0];
|
||||
const requestParts = requestLine.split(' ');
|
||||
if (requestParts.length < 3) {
|
||||
console.error(`[${connectionId}] Invalid HTTP request line`);
|
||||
socket.end();
|
||||
this.connectionManager.cleanupConnection(record, 'invalid_request_line');
|
||||
return;
|
||||
}
|
||||
|
||||
const [method, path, httpVersion] = requestParts;
|
||||
|
||||
// Parse headers
|
||||
const headersMap: Record<string, string> = {};
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const colonIndex = lines[i].indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = lines[i].slice(0, colonIndex).trim().toLowerCase();
|
||||
const value = lines[i].slice(colonIndex + 1).trim();
|
||||
headersMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract query string if present
|
||||
let pathname = path;
|
||||
let query: string | undefined;
|
||||
const queryIndex = path.indexOf('?');
|
||||
if (queryIndex !== -1) {
|
||||
pathname = path.slice(0, queryIndex);
|
||||
query = path.slice(queryIndex + 1);
|
||||
}
|
||||
|
||||
try {
|
||||
// Build route context with parsed HTTP information
|
||||
const context: IRouteContext = {
|
||||
port: record.localPort,
|
||||
domain: record.lockedDomain || headersMap['host']?.split(':')[0],
|
||||
clientIp: record.remoteIP,
|
||||
serverIp: socket.localAddress!,
|
||||
path: pathname,
|
||||
query: query,
|
||||
headers: headersMap,
|
||||
method: method,
|
||||
isTls: record.isTLS,
|
||||
tlsVersion: record.tlsVersion,
|
||||
routeName: route.name,
|
||||
routeId: route.name,
|
||||
timestamp: Date.now(),
|
||||
connectionId
|
||||
};
|
||||
|
||||
// Remove the data listener since we're handling the request
|
||||
socket.removeListener('data', handleHttpData);
|
||||
|
||||
// Call the handler with the properly parsed context
|
||||
const response = await route.action.handler(context);
|
||||
|
||||
// Prepare the HTTP response
|
||||
const responseHeaders = response.headers || {};
|
||||
const contentLength = Buffer.byteLength(response.body || '');
|
||||
responseHeaders['Content-Length'] = contentLength.toString();
|
||||
|
||||
if (!responseHeaders['Content-Type']) {
|
||||
responseHeaders['Content-Type'] = 'text/plain';
|
||||
}
|
||||
|
||||
// Build the response
|
||||
let httpResponse = `HTTP/1.1 ${response.status} ${getStatusText(response.status)}\r\n`;
|
||||
for (const [key, value] of Object.entries(responseHeaders)) {
|
||||
httpResponse += `${key}: ${value}\r\n`;
|
||||
}
|
||||
httpResponse += '\r\n';
|
||||
|
||||
// Send response
|
||||
socket.write(httpResponse);
|
||||
if (response.body) {
|
||||
socket.write(response.body);
|
||||
}
|
||||
socket.end();
|
||||
|
||||
this.connectionManager.cleanupConnection(record, 'completed');
|
||||
} catch (error) {
|
||||
console.error(`[${connectionId}] Error in static handler: ${error}`);
|
||||
|
||||
// Send error response
|
||||
const errorResponse = 'HTTP/1.1 500 Internal Server Error\r\n' +
|
||||
'Content-Type: text/plain\r\n' +
|
||||
'Content-Length: 21\r\n' +
|
||||
'\r\n' +
|
||||
'Internal Server Error';
|
||||
socket.write(errorResponse);
|
||||
socket.end();
|
||||
|
||||
this.connectionManager.cleanupConnection(record, 'handler_error');
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for data
|
||||
socket.on('data', handleHttpData);
|
||||
|
||||
// Ensure cleanup on socket close
|
||||
socket.once('close', () => {
|
||||
socket.removeListener('data', handleHttpData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
Reference in New Issue
Block a user