Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
5fbcf81c2c | |||
6eac957baf | |||
64f5fa62a9 | |||
4fea28ffb7 | |||
ffc04c5b85 | |||
a459d77b6f | |||
b6d8b73599 | |||
8936f4ad46 | |||
36068a6d92 | |||
d47b048517 |
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
Binary file not shown.
68
.serena/project.yml
Normal file
68
.serena/project.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: typescript
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed)on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "smartproxy"
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"expiryDate": "2025-10-18T13:15:48.916Z",
|
||||
"issueDate": "2025-07-20T13:15:48.916Z",
|
||||
"savedAt": "2025-07-20T13:15:48.916Z"
|
||||
"expiryDate": "2025-11-12T14:20:10.043Z",
|
||||
"issueDate": "2025-08-14T14:20:10.043Z",
|
||||
"savedAt": "2025-08-14T14:20:10.044Z"
|
||||
}
|
31
changelog.md
31
changelog.md
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-08-14 - 21.1.4 - fix(security)
|
||||
Critical security and stability fixes
|
||||
|
||||
- Fixed critical socket.emit override vulnerability that was breaking TLS connections
|
||||
- Implemented comprehensive socket cleanup with new socket tracker utility
|
||||
- Improved code organization by extracting RouteOrchestrator from SmartProxy
|
||||
- Fixed IPv6 loopback detection for proper IPv6 support
|
||||
- Added memory bounds to prevent unbounded collection growth
|
||||
- Fixed certificate manager race conditions with proper synchronization
|
||||
- Unreferenced long-lived timers to prevent process hanging
|
||||
- Enhanced route validation for socket-handler actions
|
||||
- Fixed header parsing when extractFullHeaders option is enabled
|
||||
|
||||
## 2025-07-22 - 21.1.1 - fix(detection)
|
||||
Fix SNI detection in TLS detector
|
||||
|
||||
- Restored proper TLS detector implementation with ClientHello parsing
|
||||
- Fixed imports to use new protocols module locations
|
||||
- Added missing detectWithContext method for fragmented detection
|
||||
- Fixed method names to match BufferAccumulator interface
|
||||
- Removed unused import readUInt24BE
|
||||
|
||||
## 2025-07-21 - 21.1.0 - feat(protocols)
|
||||
Refactor protocol utilities into centralized protocols module
|
||||
|
||||
- Moved TLS utilities from `ts/tls/` to `ts/protocols/tls/`
|
||||
- Created centralized protocol modules for HTTP, WebSocket, Proxy, and TLS
|
||||
- Core utilities now delegate to protocol modules for parsing and utilities
|
||||
- Maintains backward compatibility through re-exports in original locations
|
||||
- Improves code organization and separation of concerns
|
||||
|
||||
## 2025-07-22 - 21.0.0 - BREAKING_CHANGE(forwarding)
|
||||
Remove legacy forwarding module
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "21.0.0",
|
||||
"version": "21.1.4",
|
||||
"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",
|
||||
@@ -19,7 +19,8 @@
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^2.3.1",
|
||||
"@types/node": "^22.15.29",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"why-is-node-running": "^3.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
|
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -78,6 +78,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
why-is-node-running:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2
|
||||
|
||||
packages:
|
||||
|
||||
@@ -4096,6 +4099,11 @@ packages:
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@3.2.2:
|
||||
resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==}
|
||||
engines: {node: '>=20.11'}
|
||||
hasBin: true
|
||||
|
||||
winston-transport@4.9.0:
|
||||
resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -10082,6 +10090,8 @@ snapshots:
|
||||
dependencies:
|
||||
isexe: 3.1.1
|
||||
|
||||
why-is-node-running@3.2.2: {}
|
||||
|
||||
winston-transport@4.9.0:
|
||||
dependencies:
|
||||
logform: 2.7.0
|
||||
|
BIN
readme.plan.md
BIN
readme.plan.md
Binary file not shown.
@@ -124,4 +124,4 @@ tap.test('should parse HTTP headers correctly', async (tools) => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -159,4 +159,4 @@ tap.test('should return 404 for non-existent challenge tokens', async (tapTest)
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -215,4 +215,4 @@ tap.test('should handle HTTP request parsing correctly', async (tools) => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -117,4 +117,4 @@ tap.test('should configure ACME challenge route', async () => {
|
||||
expect(challengeRoute.action.socketHandler).toBeDefined();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -119,4 +119,4 @@ tap.test('should defer certificate provisioning until ports are ready', async (t
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -238,4 +238,4 @@ tap.test('should renew certificates', async () => {
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -57,4 +57,4 @@ tap.test('should handle socket handler route type', async () => {
|
||||
expect(route.action.socketHandler).toBeDefined();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -143,4 +143,4 @@ tap.test('cleanup queue bug - verify queue processing handles more than batch si
|
||||
console.log('\n✓ Test complete: Cleanup queue now correctly processes all connections');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -239,4 +239,4 @@ tap.test('should handle clients that error during connection', async () => {
|
||||
console.log('\n✅ PASS: Connection error cleanup working correctly!');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -276,4 +276,4 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
|
||||
console.log('- NFTables connections');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -296,4 +296,4 @@ tap.test('Cleanup and shutdown', async () => {
|
||||
allServers.length = 0;
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
146
test/test.detection.ts
Normal file
146
test/test.detection.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartproxy from '../ts/index.js';
|
||||
|
||||
tap.test('Protocol Detection - TLS Detection', async () => {
|
||||
// Test TLS handshake detection
|
||||
const tlsHandshake = Buffer.from([
|
||||
0x16, // Handshake record type
|
||||
0x03, 0x01, // TLS 1.0
|
||||
0x00, 0x05, // Length: 5 bytes
|
||||
0x01, // ClientHello
|
||||
0x00, 0x00, 0x01, 0x00 // Handshake length and data
|
||||
]);
|
||||
|
||||
const detector = new smartproxy.detection.TlsDetector();
|
||||
expect(detector.canHandle(tlsHandshake)).toEqual(true);
|
||||
|
||||
const result = detector.detect(tlsHandshake);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.protocol).toEqual('tls');
|
||||
expect(result?.connectionInfo.tlsVersion).toEqual('TLSv1.0');
|
||||
});
|
||||
|
||||
tap.test('Protocol Detection - HTTP Detection', async () => {
|
||||
// Test HTTP request detection
|
||||
const httpRequest = Buffer.from(
|
||||
'GET /test HTTP/1.1\r\n' +
|
||||
'Host: example.com\r\n' +
|
||||
'User-Agent: TestClient/1.0\r\n' +
|
||||
'\r\n'
|
||||
);
|
||||
|
||||
const detector = new smartproxy.detection.HttpDetector();
|
||||
expect(detector.canHandle(httpRequest)).toEqual(true);
|
||||
|
||||
const result = detector.detect(httpRequest);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.protocol).toEqual('http');
|
||||
expect(result?.connectionInfo.method).toEqual('GET');
|
||||
expect(result?.connectionInfo.path).toEqual('/test');
|
||||
expect(result?.connectionInfo.domain).toEqual('example.com');
|
||||
});
|
||||
|
||||
tap.test('Protocol Detection - Main Detector TLS', async () => {
|
||||
const tlsHandshake = Buffer.from([
|
||||
0x16, // Handshake record type
|
||||
0x03, 0x03, // TLS 1.2
|
||||
0x00, 0x05, // Length: 5 bytes
|
||||
0x01, // ClientHello
|
||||
0x00, 0x00, 0x01, 0x00 // Handshake length and data
|
||||
]);
|
||||
|
||||
const result = await smartproxy.detection.ProtocolDetector.detect(tlsHandshake);
|
||||
expect(result.protocol).toEqual('tls');
|
||||
expect(result.connectionInfo.tlsVersion).toEqual('TLSv1.2');
|
||||
});
|
||||
|
||||
tap.test('Protocol Detection - Main Detector HTTP', async () => {
|
||||
const httpRequest = Buffer.from(
|
||||
'POST /api/test HTTP/1.1\r\n' +
|
||||
'Host: api.example.com\r\n' +
|
||||
'Content-Type: application/json\r\n' +
|
||||
'Content-Length: 2\r\n' +
|
||||
'\r\n' +
|
||||
'{}'
|
||||
);
|
||||
|
||||
const result = await smartproxy.detection.ProtocolDetector.detect(httpRequest);
|
||||
expect(result.protocol).toEqual('http');
|
||||
expect(result.connectionInfo.method).toEqual('POST');
|
||||
expect(result.connectionInfo.path).toEqual('/api/test');
|
||||
expect(result.connectionInfo.domain).toEqual('api.example.com');
|
||||
});
|
||||
|
||||
tap.test('Protocol Detection - Unknown Protocol', async () => {
|
||||
const unknownData = Buffer.from('UNKNOWN PROTOCOL DATA\r\n');
|
||||
|
||||
const result = await smartproxy.detection.ProtocolDetector.detect(unknownData);
|
||||
expect(result.protocol).toEqual('unknown');
|
||||
expect(result.isComplete).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('Protocol Detection - Fragmented HTTP', async () => {
|
||||
// Create connection context
|
||||
const context = smartproxy.detection.ProtocolDetector.createConnectionContext({
|
||||
sourceIp: '127.0.0.1',
|
||||
sourcePort: 12345,
|
||||
destIp: '127.0.0.1',
|
||||
destPort: 80,
|
||||
socketId: 'test-connection-1'
|
||||
});
|
||||
|
||||
// First fragment
|
||||
const fragment1 = Buffer.from('GET /test HT');
|
||||
let result = await smartproxy.detection.ProtocolDetector.detectWithContext(
|
||||
fragment1,
|
||||
context
|
||||
);
|
||||
expect(result.protocol).toEqual('http');
|
||||
expect(result.isComplete).toEqual(false);
|
||||
|
||||
// Second fragment
|
||||
const fragment2 = Buffer.from('TP/1.1\r\nHost: example.com\r\n\r\n');
|
||||
result = await smartproxy.detection.ProtocolDetector.detectWithContext(
|
||||
fragment2,
|
||||
context
|
||||
);
|
||||
expect(result.protocol).toEqual('http');
|
||||
expect(result.isComplete).toEqual(true);
|
||||
expect(result.connectionInfo.method).toEqual('GET');
|
||||
expect(result.connectionInfo.path).toEqual('/test');
|
||||
expect(result.connectionInfo.domain).toEqual('example.com');
|
||||
|
||||
// Clean up fragments
|
||||
smartproxy.detection.ProtocolDetector.cleanupConnection(context);
|
||||
});
|
||||
|
||||
tap.test('Protocol Detection - HTTP Methods', async () => {
|
||||
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
||||
|
||||
for (const method of methods) {
|
||||
const request = Buffer.from(
|
||||
`${method} /test HTTP/1.1\r\n` +
|
||||
'Host: example.com\r\n' +
|
||||
'\r\n'
|
||||
);
|
||||
|
||||
const detector = new smartproxy.detection.HttpDetector();
|
||||
const result = detector.detect(request);
|
||||
expect(result?.connectionInfo.method).toEqual(method);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Protocol Detection - Invalid Data', async () => {
|
||||
// Binary data that's not a valid protocol
|
||||
const binaryData = Buffer.from([0xFF, 0xFE, 0xFD, 0xFC, 0xFB]);
|
||||
|
||||
const result = await smartproxy.detection.ProtocolDetector.detect(binaryData);
|
||||
expect(result.protocol).toEqual('unknown');
|
||||
});
|
||||
|
||||
tap.test('cleanup detection', async () => {
|
||||
// Clean up the protocol detector instance
|
||||
smartproxy.detection.ProtocolDetector.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
@@ -79,4 +79,4 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
|
||||
console.log('Fix verified: Certificate manager callback is preserved on updateRoutes');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -180,4 +180,4 @@ tap.test('should handle ACME HTTP-01 challenges on port 80 with HttpProxy', asyn
|
||||
console.log('Test passed: ACME HTTP-01 challenges on port 80 use HttpProxy');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -242,4 +242,4 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
|
||||
}
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -117,4 +117,4 @@ tap.test('Cleanup HttpProxy SecurityManager', async () => {
|
||||
securityManager.clearIPTracking();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -247,4 +247,4 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
||||
console.log(' - Zombie detection respects keepalive settings');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -109,4 +109,4 @@ tap.test('Cleanup deduplicator', async () => {
|
||||
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -149,4 +149,4 @@ tap.test('should not have memory leaks in long-running operations', async (tools
|
||||
});
|
||||
|
||||
// Run with: node --expose-gc test.memory-leak-check.node.ts
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -57,4 +57,4 @@ tap.test('memory leak fixes verification', async () => {
|
||||
console.log('\n✅ All memory leak fixes verified!');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -128,4 +128,4 @@ tap.test('memory leak fixes - unit tests', async () => {
|
||||
console.log('\n✅ All memory leak fixes verified!');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -258,4 +258,4 @@ tap.test('should clean up resources', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -192,4 +192,4 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
|
||||
expect(finalCounts.proxy2).toEqual(0);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -130,4 +130,4 @@ tap.test('PROXY protocol v1 generator', async () => {
|
||||
// Skipping integration tests for now - focus on unit tests
|
||||
// Integration tests would require more complex setup and teardown
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -198,4 +198,4 @@ tap.test('should handle routing failures without leaking connections', async ()
|
||||
console.log('\n✅ PASS: Routing failures cleaned up correctly!');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -113,4 +113,4 @@ tap.test('should set update routes callback on certificate manager', async () =>
|
||||
await proxy.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -58,4 +58,4 @@ tap.test('route security should be correctly configured', async () => {
|
||||
expect(isBlockedIPAllowed).toBeFalse();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -336,4 +336,4 @@ tap.test('real code integration test - verify fix is applied', async () => {
|
||||
console.log('Real code integration test passed - fix is correctly applied!');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -154,4 +154,4 @@ tap.test('Cleanup SharedSecurityManager', async () => {
|
||||
securityManager.clearIPTracking();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -51,4 +51,4 @@ tap.test('should verify SmartAcme cert managers are accessible', async () => {
|
||||
expect(memoryCertManager).toBeDefined();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -141,4 +141,4 @@ tap.test('stuck connection cleanup - verify connections to hanging backends are
|
||||
console.log('✓ Test complete: Stuck connections are properly detected and cleaned up');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -155,4 +155,4 @@ tap.test('long-lived connection survival test', async (tools) => {
|
||||
console.log('✅ Long-lived connection survived past 30-second timeout!');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -303,4 +303,4 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
|
||||
expect(details.inner.halfZombies.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
@@ -12,6 +12,11 @@ declare module 'net' {
|
||||
getTLSVersion?(): string; // Returns the TLS version (e.g., 'TLSv1.2', 'TLSv1.3')
|
||||
getPeerCertificate?(detailed?: boolean): any; // Returns the peer's certificate
|
||||
getSession?(): Buffer; // Returns the TLS session data
|
||||
|
||||
// Connection tracking properties (used by HttpProxy)
|
||||
_connectionId?: string; // Unique identifier for the connection
|
||||
_remoteIP?: string; // Remote IP address
|
||||
_realRemoteIP?: string; // Real remote IP (when proxied)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,161 +1,44 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from './logger.js';
|
||||
import { ProxyProtocolParser as ProtocolParser, type IProxyInfo, type IProxyParseResult } from '../../protocols/proxy/index.js';
|
||||
|
||||
/**
|
||||
* Interface representing parsed PROXY protocol information
|
||||
*/
|
||||
export interface IProxyInfo {
|
||||
protocol: 'TCP4' | 'TCP6' | 'UNKNOWN';
|
||||
sourceIP: string;
|
||||
sourcePort: number;
|
||||
destinationIP: string;
|
||||
destinationPort: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for parse result including remaining data
|
||||
*/
|
||||
export interface IProxyParseResult {
|
||||
proxyInfo: IProxyInfo | null;
|
||||
remainingData: Buffer;
|
||||
}
|
||||
// Re-export types from protocols for backward compatibility
|
||||
export type { IProxyInfo, IProxyParseResult } from '../../protocols/proxy/index.js';
|
||||
|
||||
/**
|
||||
* Parser for PROXY protocol v1 (text format)
|
||||
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
|
||||
*
|
||||
* This class now delegates to the protocol parser but adds
|
||||
* smartproxy-specific features like socket reading and logging
|
||||
*/
|
||||
export class ProxyProtocolParser {
|
||||
static readonly PROXY_V1_SIGNATURE = 'PROXY ';
|
||||
static readonly MAX_HEADER_LENGTH = 107; // Max length for v1 header
|
||||
static readonly HEADER_TERMINATOR = '\r\n';
|
||||
static readonly PROXY_V1_SIGNATURE = ProtocolParser.PROXY_V1_SIGNATURE;
|
||||
static readonly MAX_HEADER_LENGTH = ProtocolParser.MAX_HEADER_LENGTH;
|
||||
static readonly HEADER_TERMINATOR = ProtocolParser.HEADER_TERMINATOR;
|
||||
|
||||
/**
|
||||
* Parse PROXY protocol v1 header from buffer
|
||||
* Returns proxy info and remaining data after header
|
||||
*/
|
||||
static parse(data: Buffer): IProxyParseResult {
|
||||
// Check if buffer starts with PROXY signature
|
||||
if (!data.toString('ascii', 0, 6).startsWith(this.PROXY_V1_SIGNATURE)) {
|
||||
return {
|
||||
proxyInfo: null,
|
||||
remainingData: data
|
||||
};
|
||||
}
|
||||
|
||||
// Find header terminator
|
||||
const headerEndIndex = data.indexOf(this.HEADER_TERMINATOR);
|
||||
if (headerEndIndex === -1) {
|
||||
// Header incomplete, need more data
|
||||
if (data.length > this.MAX_HEADER_LENGTH) {
|
||||
// Header too long, invalid
|
||||
throw new Error('PROXY protocol header exceeds maximum length');
|
||||
}
|
||||
return {
|
||||
proxyInfo: null,
|
||||
remainingData: data
|
||||
};
|
||||
}
|
||||
|
||||
// Extract header line
|
||||
const headerLine = data.toString('ascii', 0, headerEndIndex);
|
||||
const remainingData = data.slice(headerEndIndex + 2); // Skip \r\n
|
||||
|
||||
// Parse header
|
||||
const parts = headerLine.split(' ');
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
|
||||
}
|
||||
|
||||
const [signature, protocol] = parts;
|
||||
|
||||
// Validate protocol
|
||||
if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(protocol)) {
|
||||
throw new Error(`Invalid PROXY protocol: ${protocol}`);
|
||||
}
|
||||
|
||||
// For UNKNOWN protocol, ignore addresses
|
||||
if (protocol === 'UNKNOWN') {
|
||||
return {
|
||||
proxyInfo: {
|
||||
protocol: 'UNKNOWN',
|
||||
sourceIP: '',
|
||||
sourcePort: 0,
|
||||
destinationIP: '',
|
||||
destinationPort: 0
|
||||
},
|
||||
remainingData
|
||||
};
|
||||
}
|
||||
|
||||
// For TCP4/TCP6, we need all 6 parts
|
||||
if (parts.length !== 6) {
|
||||
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
|
||||
}
|
||||
|
||||
const [, , srcIP, dstIP, srcPort, dstPort] = parts;
|
||||
|
||||
// Validate and parse ports
|
||||
const sourcePort = parseInt(srcPort, 10);
|
||||
const destinationPort = parseInt(dstPort, 10);
|
||||
|
||||
if (isNaN(sourcePort) || sourcePort < 0 || sourcePort > 65535) {
|
||||
throw new Error(`Invalid source port: ${srcPort}`);
|
||||
}
|
||||
|
||||
if (isNaN(destinationPort) || destinationPort < 0 || destinationPort > 65535) {
|
||||
throw new Error(`Invalid destination port: ${dstPort}`);
|
||||
}
|
||||
|
||||
// Validate IP addresses
|
||||
const protocolType = protocol as 'TCP4' | 'TCP6' | 'UNKNOWN';
|
||||
if (!this.isValidIP(srcIP, protocolType)) {
|
||||
throw new Error(`Invalid source IP for ${protocol}: ${srcIP}`);
|
||||
}
|
||||
|
||||
if (!this.isValidIP(dstIP, protocolType)) {
|
||||
throw new Error(`Invalid destination IP for ${protocol}: ${dstIP}`);
|
||||
}
|
||||
|
||||
return {
|
||||
proxyInfo: {
|
||||
protocol: protocol as 'TCP4' | 'TCP6',
|
||||
sourceIP: srcIP,
|
||||
sourcePort,
|
||||
destinationIP: dstIP,
|
||||
destinationPort
|
||||
},
|
||||
remainingData
|
||||
};
|
||||
// Delegate to protocol parser
|
||||
return ProtocolParser.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PROXY protocol v1 header
|
||||
*/
|
||||
static generate(info: IProxyInfo): Buffer {
|
||||
if (info.protocol === 'UNKNOWN') {
|
||||
return Buffer.from(`PROXY UNKNOWN\r\n`, 'ascii');
|
||||
}
|
||||
|
||||
const header = `PROXY ${info.protocol} ${info.sourceIP} ${info.destinationIP} ${info.sourcePort} ${info.destinationPort}\r\n`;
|
||||
|
||||
if (header.length > this.MAX_HEADER_LENGTH) {
|
||||
throw new Error('Generated PROXY protocol header exceeds maximum length');
|
||||
}
|
||||
|
||||
return Buffer.from(header, 'ascii');
|
||||
// Delegate to protocol parser
|
||||
return ProtocolParser.generate(info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IP address format
|
||||
*/
|
||||
private static isValidIP(ip: string, protocol: 'TCP4' | 'TCP6' | 'UNKNOWN'): boolean {
|
||||
if (protocol === 'TCP4') {
|
||||
return plugins.net.isIPv4(ip);
|
||||
} else if (protocol === 'TCP6') {
|
||||
return plugins.net.isIPv6(ip);
|
||||
}
|
||||
return false;
|
||||
return ProtocolParser.isValidIP(ip, protocol);
|
||||
}
|
||||
|
||||
/**
|
||||
|
63
ts/core/utils/socket-tracker.ts
Normal file
63
ts/core/utils/socket-tracker.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Socket Tracker Utility
|
||||
* Provides standardized socket cleanup with proper listener and timer management
|
||||
*/
|
||||
|
||||
import type { Socket } from 'net';
|
||||
|
||||
export type SocketTracked = {
|
||||
cleanup: () => void;
|
||||
addListener: <E extends string>(event: E, listener: (...args: any[]) => void) => void;
|
||||
addTimer: (t: NodeJS.Timeout | null | undefined) => void;
|
||||
safeDestroy: (reason?: Error) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a socket tracker to manage listeners and timers
|
||||
* Ensures proper cleanup and prevents memory leaks
|
||||
*/
|
||||
export function createSocketTracker(socket: Socket): SocketTracked {
|
||||
const listeners: Array<{ event: string; listener: (...args: any[]) => void }> = [];
|
||||
const timers: NodeJS.Timeout[] = [];
|
||||
let cleaned = false;
|
||||
|
||||
const addListener = (event: string, listener: (...args: any[]) => void) => {
|
||||
socket.on(event, listener);
|
||||
listeners.push({ event, listener });
|
||||
};
|
||||
|
||||
const addTimer = (t: NodeJS.Timeout | null | undefined) => {
|
||||
if (!t) return;
|
||||
timers.push(t);
|
||||
// Unref timer so it doesn't keep process alive
|
||||
if (typeof t.unref === 'function') {
|
||||
t.unref();
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
|
||||
// Clear all tracked timers
|
||||
for (const t of timers) {
|
||||
clearTimeout(t);
|
||||
}
|
||||
timers.length = 0;
|
||||
|
||||
// Remove all tracked listeners
|
||||
for (const { event, listener } of listeners) {
|
||||
socket.off(event, listener);
|
||||
}
|
||||
listeners.length = 0;
|
||||
};
|
||||
|
||||
const safeDestroy = (reason?: Error) => {
|
||||
cleanup();
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy(reason);
|
||||
}
|
||||
};
|
||||
|
||||
return { cleanup, addListener, addTimer, safeDestroy };
|
||||
}
|
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* WebSocket utility functions
|
||||
*
|
||||
* This module provides smartproxy-specific WebSocket utilities
|
||||
* and re-exports protocol utilities from the protocols module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type for WebSocket RawData that can be different types in different environments
|
||||
* This matches the ws library's type definition
|
||||
*/
|
||||
export type RawData = Buffer | ArrayBuffer | Buffer[] | any;
|
||||
// Import and re-export from protocols
|
||||
import { getMessageSize as protocolGetMessageSize, toBuffer as protocolToBuffer } from '../../protocols/websocket/index.js';
|
||||
export type { RawData } from '../../protocols/websocket/index.js';
|
||||
|
||||
/**
|
||||
* Get the length of a WebSocket message regardless of its type
|
||||
@@ -15,35 +16,9 @@ export type RawData = Buffer | ArrayBuffer | Buffer[] | any;
|
||||
* @param data - The data message from WebSocket (could be any RawData type)
|
||||
* @returns The length of the data in bytes
|
||||
*/
|
||||
export function getMessageSize(data: RawData): number {
|
||||
if (typeof data === 'string') {
|
||||
// For string data, get the byte length
|
||||
return Buffer.from(data, 'utf8').length;
|
||||
} else if (data instanceof Buffer) {
|
||||
// For Node.js Buffer
|
||||
return data.length;
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
// For ArrayBuffer
|
||||
return data.byteLength;
|
||||
} else if (Array.isArray(data)) {
|
||||
// For array of buffers, sum their lengths
|
||||
return data.reduce((sum, chunk) => {
|
||||
if (chunk instanceof Buffer) {
|
||||
return sum + chunk.length;
|
||||
} else if (chunk instanceof ArrayBuffer) {
|
||||
return sum + chunk.byteLength;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
} else {
|
||||
// For other types, try to determine the size or return 0
|
||||
try {
|
||||
return Buffer.from(data).length;
|
||||
} catch (e) {
|
||||
console.warn('Could not determine message size', e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
export function getMessageSize(data: import('../../protocols/websocket/index.js').RawData): number {
|
||||
// Delegate to protocol implementation
|
||||
return protocolGetMessageSize(data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,30 +27,7 @@ export function getMessageSize(data: RawData): number {
|
||||
* @param data - The data message from WebSocket (could be any RawData type)
|
||||
* @returns A Buffer containing the data
|
||||
*/
|
||||
export function toBuffer(data: RawData): Buffer {
|
||||
if (typeof data === 'string') {
|
||||
return Buffer.from(data, 'utf8');
|
||||
} else if (data instanceof Buffer) {
|
||||
return data;
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data);
|
||||
} else if (Array.isArray(data)) {
|
||||
// For array of buffers, concatenate them
|
||||
return Buffer.concat(data.map(chunk => {
|
||||
if (chunk instanceof Buffer) {
|
||||
return chunk;
|
||||
} else if (chunk instanceof ArrayBuffer) {
|
||||
return Buffer.from(chunk);
|
||||
}
|
||||
return Buffer.from(chunk);
|
||||
}));
|
||||
} else {
|
||||
// For other types, try to convert to Buffer or return empty Buffer
|
||||
try {
|
||||
return Buffer.from(data);
|
||||
} catch (e) {
|
||||
console.warn('Could not convert message to Buffer', e);
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
}
|
||||
export function toBuffer(data: import('../../protocols/websocket/index.js').RawData): Buffer {
|
||||
// Delegate to protocol implementation
|
||||
return protocolToBuffer(data);
|
||||
}
|
127
ts/detection/detectors/http-detector.ts
Normal file
127
ts/detection/detectors/http-detector.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* HTTP Protocol Detector
|
||||
*
|
||||
* Simplified HTTP detection using the new architecture
|
||||
*/
|
||||
|
||||
import type { IProtocolDetector } from '../models/interfaces.js';
|
||||
import type { IDetectionResult, IDetectionOptions } from '../models/detection-types.js';
|
||||
import type { IProtocolDetectionResult, IConnectionContext } from '../../protocols/common/types.js';
|
||||
import type { THttpMethod } from '../../protocols/http/index.js';
|
||||
import { QuickProtocolDetector } from './quick-detector.js';
|
||||
import { RoutingExtractor } from './routing-extractor.js';
|
||||
import { DetectionFragmentManager } from '../utils/fragment-manager.js';
|
||||
import { HttpParser } from '../../protocols/http/parser.js';
|
||||
|
||||
/**
|
||||
* Simplified HTTP detector
|
||||
*/
|
||||
export class HttpDetector implements IProtocolDetector {
|
||||
private quickDetector = new QuickProtocolDetector();
|
||||
private fragmentManager: DetectionFragmentManager;
|
||||
|
||||
constructor(fragmentManager?: DetectionFragmentManager) {
|
||||
this.fragmentManager = fragmentManager || new DetectionFragmentManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer can be handled by this detector
|
||||
*/
|
||||
canHandle(buffer: Buffer): boolean {
|
||||
const result = this.quickDetector.quickDetect(buffer);
|
||||
return result.protocol === 'http' && result.confidence > 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum bytes needed for detection
|
||||
*/
|
||||
getMinimumBytes(): number {
|
||||
return 4; // "GET " minimum
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect HTTP protocol from buffer
|
||||
*/
|
||||
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null {
|
||||
// Quick detection first
|
||||
const quickResult = this.quickDetector.quickDetect(buffer);
|
||||
|
||||
if (quickResult.protocol !== 'http' || quickResult.confidence < 50) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we have complete headers first
|
||||
const headersEnd = buffer.indexOf('\r\n\r\n');
|
||||
const isComplete = headersEnd !== -1;
|
||||
|
||||
// Extract routing information
|
||||
const routing = RoutingExtractor.extract(buffer, 'http');
|
||||
|
||||
// Extract headers if requested and we have complete headers
|
||||
let headers: Record<string, string> | undefined;
|
||||
if (options?.extractFullHeaders && isComplete) {
|
||||
const headerSection = buffer.slice(0, headersEnd).toString();
|
||||
const lines = headerSection.split('\r\n');
|
||||
if (lines.length > 1) {
|
||||
// Skip the request line and parse headers
|
||||
headers = HttpParser.parseHeaders(lines.slice(1));
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't need full headers and we have complete headers, we can return early
|
||||
if (quickResult.confidence >= 95 && !options?.extractFullHeaders && isComplete) {
|
||||
return {
|
||||
protocol: 'http',
|
||||
connectionInfo: {
|
||||
protocol: 'http',
|
||||
method: quickResult.metadata?.method as THttpMethod,
|
||||
domain: routing?.domain,
|
||||
path: routing?.path
|
||||
},
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: 'http',
|
||||
connectionInfo: {
|
||||
protocol: 'http',
|
||||
domain: routing?.domain,
|
||||
path: routing?.path,
|
||||
method: quickResult.metadata?.method as THttpMethod,
|
||||
headers: headers
|
||||
},
|
||||
isComplete,
|
||||
bytesNeeded: isComplete ? undefined : buffer.length + 512 // Need more for headers
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fragmented detection
|
||||
*/
|
||||
detectWithContext(
|
||||
buffer: Buffer,
|
||||
context: IConnectionContext,
|
||||
options?: IDetectionOptions
|
||||
): IDetectionResult | null {
|
||||
const handler = this.fragmentManager.getHandler('http');
|
||||
const connectionId = DetectionFragmentManager.createConnectionId(context);
|
||||
|
||||
// Add fragment
|
||||
const result = handler.addFragment(connectionId, buffer);
|
||||
|
||||
if (result.error) {
|
||||
handler.complete(connectionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try detection on accumulated buffer
|
||||
const detectResult = this.detect(result.buffer!, options);
|
||||
|
||||
if (detectResult && detectResult.isComplete) {
|
||||
handler.complete(connectionId);
|
||||
}
|
||||
|
||||
return detectResult;
|
||||
}
|
||||
}
|
148
ts/detection/detectors/quick-detector.ts
Normal file
148
ts/detection/detectors/quick-detector.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Quick Protocol Detector
|
||||
*
|
||||
* Lightweight protocol identification based on minimal bytes
|
||||
* No parsing, just identification
|
||||
*/
|
||||
|
||||
import type { IProtocolDetector, IProtocolDetectionResult } from '../../protocols/common/types.js';
|
||||
import { TlsRecordType } from '../../protocols/tls/index.js';
|
||||
import { HttpParser } from '../../protocols/http/index.js';
|
||||
|
||||
/**
|
||||
* Quick protocol detector for fast identification
|
||||
*/
|
||||
export class QuickProtocolDetector implements IProtocolDetector {
|
||||
/**
|
||||
* Check if this detector can handle the data
|
||||
*/
|
||||
canHandle(data: Buffer): boolean {
|
||||
return data.length >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform quick detection based on first few bytes
|
||||
*/
|
||||
quickDetect(data: Buffer): IProtocolDetectionResult {
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
protocol: 'unknown',
|
||||
confidence: 0,
|
||||
requiresMoreData: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check for TLS
|
||||
const tlsResult = this.checkTls(data);
|
||||
if (tlsResult.confidence > 80) {
|
||||
return tlsResult;
|
||||
}
|
||||
|
||||
// Check for HTTP
|
||||
const httpResult = this.checkHttp(data);
|
||||
if (httpResult.confidence > 80) {
|
||||
return httpResult;
|
||||
}
|
||||
|
||||
// Need more data or unknown
|
||||
return {
|
||||
protocol: 'unknown',
|
||||
confidence: 0,
|
||||
requiresMoreData: data.length < 20
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data looks like TLS
|
||||
*/
|
||||
private checkTls(data: Buffer): IProtocolDetectionResult {
|
||||
if (data.length < 3) {
|
||||
return {
|
||||
protocol: 'tls',
|
||||
confidence: 0,
|
||||
requiresMoreData: true
|
||||
};
|
||||
}
|
||||
|
||||
const firstByte = data[0];
|
||||
const secondByte = data[1];
|
||||
|
||||
// Check for valid TLS record type
|
||||
const validRecordTypes = [
|
||||
TlsRecordType.CHANGE_CIPHER_SPEC,
|
||||
TlsRecordType.ALERT,
|
||||
TlsRecordType.HANDSHAKE,
|
||||
TlsRecordType.APPLICATION_DATA,
|
||||
TlsRecordType.HEARTBEAT
|
||||
];
|
||||
|
||||
if (!validRecordTypes.includes(firstByte)) {
|
||||
return {
|
||||
protocol: 'tls',
|
||||
confidence: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Check TLS version byte (0x03 for all TLS/SSL versions)
|
||||
if (secondByte !== 0x03) {
|
||||
return {
|
||||
protocol: 'tls',
|
||||
confidence: 0
|
||||
};
|
||||
}
|
||||
|
||||
// High confidence it's TLS
|
||||
return {
|
||||
protocol: 'tls',
|
||||
confidence: 95,
|
||||
metadata: {
|
||||
recordType: firstByte
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data looks like HTTP
|
||||
*/
|
||||
private checkHttp(data: Buffer): IProtocolDetectionResult {
|
||||
if (data.length < 3) {
|
||||
return {
|
||||
protocol: 'http',
|
||||
confidence: 0,
|
||||
requiresMoreData: true
|
||||
};
|
||||
}
|
||||
|
||||
// Quick check for HTTP methods
|
||||
const start = data.subarray(0, Math.min(10, data.length)).toString('ascii');
|
||||
|
||||
// Check common HTTP methods
|
||||
const httpMethods = ['GET ', 'POST ', 'PUT ', 'DELETE ', 'HEAD ', 'OPTIONS', 'PATCH ', 'CONNECT', 'TRACE '];
|
||||
for (const method of httpMethods) {
|
||||
if (start.startsWith(method)) {
|
||||
return {
|
||||
protocol: 'http',
|
||||
confidence: 95,
|
||||
metadata: {
|
||||
method: method.trim()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it might be HTTP but need more data
|
||||
if (HttpParser.isPrintableAscii(data, Math.min(20, data.length))) {
|
||||
// Could be HTTP, but not sure
|
||||
return {
|
||||
protocol: 'http',
|
||||
confidence: 30,
|
||||
requiresMoreData: data.length < 20
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: 'http',
|
||||
confidence: 0
|
||||
};
|
||||
}
|
||||
}
|
147
ts/detection/detectors/routing-extractor.ts
Normal file
147
ts/detection/detectors/routing-extractor.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Routing Information Extractor
|
||||
*
|
||||
* Extracts minimal routing information from protocols
|
||||
* without full parsing
|
||||
*/
|
||||
|
||||
import type { IRoutingInfo, IConnectionContext, TProtocolType } from '../../protocols/common/types.js';
|
||||
import { SniExtraction } from '../../protocols/tls/sni/sni-extraction.js';
|
||||
import { HttpParser } from '../../protocols/http/index.js';
|
||||
|
||||
/**
|
||||
* Extracts routing information from protocol data
|
||||
*/
|
||||
export class RoutingExtractor {
|
||||
/**
|
||||
* Extract routing info based on protocol type
|
||||
*/
|
||||
static extract(
|
||||
data: Buffer,
|
||||
protocol: TProtocolType,
|
||||
context?: IConnectionContext
|
||||
): IRoutingInfo | null {
|
||||
switch (protocol) {
|
||||
case 'tls':
|
||||
case 'https':
|
||||
return this.extractTlsRouting(data, context);
|
||||
|
||||
case 'http':
|
||||
return this.extractHttpRouting(data);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract routing from TLS ClientHello (SNI)
|
||||
*/
|
||||
private static extractTlsRouting(
|
||||
data: Buffer,
|
||||
context?: IConnectionContext
|
||||
): IRoutingInfo | null {
|
||||
try {
|
||||
// Quick SNI extraction without full parsing
|
||||
const sni = SniExtraction.extractSNI(data);
|
||||
|
||||
if (sni) {
|
||||
return {
|
||||
domain: sni,
|
||||
protocol: 'tls',
|
||||
port: 443 // Default HTTPS port
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Extraction failed, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract routing from HTTP headers (Host header)
|
||||
*/
|
||||
private static extractHttpRouting(data: Buffer): IRoutingInfo | null {
|
||||
try {
|
||||
// Look for first line
|
||||
const firstLineEnd = data.indexOf('\n');
|
||||
if (firstLineEnd === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse request line
|
||||
const firstLine = data.subarray(0, firstLineEnd).toString('ascii').trim();
|
||||
const requestLine = HttpParser.parseRequestLine(firstLine);
|
||||
|
||||
if (!requestLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look for Host header
|
||||
let pos = firstLineEnd + 1;
|
||||
const maxSearch = Math.min(data.length, 4096); // Don't search too far
|
||||
|
||||
while (pos < maxSearch) {
|
||||
const lineEnd = data.indexOf('\n', pos);
|
||||
if (lineEnd === -1) break;
|
||||
|
||||
const line = data.subarray(pos, lineEnd).toString('ascii').trim();
|
||||
|
||||
// Empty line means end of headers
|
||||
if (line.length === 0) break;
|
||||
|
||||
// Check for Host header
|
||||
if (line.toLowerCase().startsWith('host:')) {
|
||||
const hostValue = line.substring(5).trim();
|
||||
const domain = HttpParser.extractDomainFromHost(hostValue);
|
||||
|
||||
return {
|
||||
domain,
|
||||
path: requestLine.path,
|
||||
protocol: 'http',
|
||||
port: 80 // Default HTTP port
|
||||
};
|
||||
}
|
||||
|
||||
pos = lineEnd + 1;
|
||||
}
|
||||
|
||||
// No Host header found, but we have the path
|
||||
return {
|
||||
path: requestLine.path,
|
||||
protocol: 'http',
|
||||
port: 80
|
||||
};
|
||||
} catch (error) {
|
||||
// Extraction failed
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract domain from any protocol
|
||||
*/
|
||||
static extractDomain(data: Buffer, hint?: TProtocolType): string | null {
|
||||
// If we have a hint, use it
|
||||
if (hint) {
|
||||
const routing = this.extract(data, hint);
|
||||
return routing?.domain || null;
|
||||
}
|
||||
|
||||
// Try TLS first (more specific)
|
||||
const tlsRouting = this.extractTlsRouting(data);
|
||||
if (tlsRouting?.domain) {
|
||||
return tlsRouting.domain;
|
||||
}
|
||||
|
||||
// Try HTTP
|
||||
const httpRouting = this.extractHttpRouting(data);
|
||||
if (httpRouting?.domain) {
|
||||
return httpRouting.domain;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
223
ts/detection/detectors/tls-detector.ts
Normal file
223
ts/detection/detectors/tls-detector.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* TLS protocol detector
|
||||
*/
|
||||
|
||||
// TLS detector doesn't need plugins imports
|
||||
import type { IProtocolDetector } from '../models/interfaces.js';
|
||||
import type { IDetectionResult, IDetectionOptions, IConnectionInfo } from '../models/detection-types.js';
|
||||
import { readUInt16BE } from '../utils/buffer-utils.js';
|
||||
import { tlsVersionToString } from '../utils/parser-utils.js';
|
||||
|
||||
// Import from protocols
|
||||
import { TlsRecordType, TlsHandshakeType, TlsExtensionType } from '../../protocols/tls/index.js';
|
||||
|
||||
// Import TLS utilities for SNI extraction from protocols
|
||||
import { SniExtraction } from '../../protocols/tls/sni/sni-extraction.js';
|
||||
import { ClientHelloParser } from '../../protocols/tls/sni/client-hello-parser.js';
|
||||
|
||||
/**
|
||||
* TLS detector implementation
|
||||
*/
|
||||
export class TlsDetector implements IProtocolDetector {
|
||||
/**
|
||||
* Minimum bytes needed to identify TLS (record header)
|
||||
*/
|
||||
private static readonly MIN_TLS_HEADER_SIZE = 5;
|
||||
|
||||
|
||||
/**
|
||||
* Detect TLS protocol from buffer
|
||||
*/
|
||||
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null {
|
||||
// Check if buffer is too small
|
||||
if (buffer.length < TlsDetector.MIN_TLS_HEADER_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this is a TLS record
|
||||
if (!this.isTlsRecord(buffer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract basic TLS info
|
||||
const recordType = buffer[0];
|
||||
const tlsMajor = buffer[1];
|
||||
const tlsMinor = buffer[2];
|
||||
const recordLength = readUInt16BE(buffer, 3);
|
||||
|
||||
// Initialize connection info
|
||||
const connectionInfo: IConnectionInfo = {
|
||||
protocol: 'tls',
|
||||
tlsVersion: tlsVersionToString(tlsMajor, tlsMinor) || undefined
|
||||
};
|
||||
|
||||
// If it's a handshake, try to extract more info
|
||||
if (recordType === TlsRecordType.HANDSHAKE && buffer.length >= 6) {
|
||||
const handshakeType = buffer[5];
|
||||
|
||||
// For ClientHello, extract SNI and other info
|
||||
if (handshakeType === TlsHandshakeType.CLIENT_HELLO) {
|
||||
// Check if we have the complete handshake
|
||||
const totalRecordLength = recordLength + 5; // Including TLS header
|
||||
if (buffer.length >= totalRecordLength) {
|
||||
// Extract SNI using existing logic
|
||||
const sni = SniExtraction.extractSNI(buffer);
|
||||
if (sni) {
|
||||
connectionInfo.domain = sni;
|
||||
connectionInfo.sni = sni;
|
||||
}
|
||||
|
||||
// Parse ClientHello for additional info
|
||||
const parseResult = ClientHelloParser.parseClientHello(buffer);
|
||||
if (parseResult.isValid) {
|
||||
// Extract ALPN if present
|
||||
const alpnExtension = parseResult.extensions.find(
|
||||
ext => ext.type === TlsExtensionType.APPLICATION_LAYER_PROTOCOL_NEGOTIATION
|
||||
);
|
||||
|
||||
if (alpnExtension) {
|
||||
connectionInfo.alpn = this.parseAlpnExtension(alpnExtension.data);
|
||||
}
|
||||
|
||||
// Store cipher suites if needed
|
||||
if (parseResult.cipherSuites && options?.extractFullHeaders) {
|
||||
connectionInfo.cipherSuites = this.parseCipherSuites(parseResult.cipherSuites);
|
||||
}
|
||||
}
|
||||
|
||||
// Return complete result
|
||||
return {
|
||||
protocol: 'tls',
|
||||
connectionInfo,
|
||||
remainingBuffer: buffer.length > totalRecordLength
|
||||
? buffer.subarray(totalRecordLength)
|
||||
: undefined,
|
||||
isComplete: true
|
||||
};
|
||||
} else {
|
||||
// Incomplete handshake
|
||||
return {
|
||||
protocol: 'tls',
|
||||
connectionInfo,
|
||||
isComplete: false,
|
||||
bytesNeeded: totalRecordLength
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For other TLS record types, just return basic info
|
||||
return {
|
||||
protocol: 'tls',
|
||||
connectionInfo,
|
||||
isComplete: true,
|
||||
remainingBuffer: buffer.length > recordLength + 5
|
||||
? buffer.subarray(recordLength + 5)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer can be handled by this detector
|
||||
*/
|
||||
canHandle(buffer: Buffer): boolean {
|
||||
return buffer.length >= TlsDetector.MIN_TLS_HEADER_SIZE &&
|
||||
this.isTlsRecord(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum bytes needed for detection
|
||||
*/
|
||||
getMinimumBytes(): number {
|
||||
return TlsDetector.MIN_TLS_HEADER_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer contains a valid TLS record
|
||||
*/
|
||||
private isTlsRecord(buffer: Buffer): boolean {
|
||||
const recordType = buffer[0];
|
||||
|
||||
// Check for valid record type
|
||||
const validTypes = [
|
||||
TlsRecordType.CHANGE_CIPHER_SPEC,
|
||||
TlsRecordType.ALERT,
|
||||
TlsRecordType.HANDSHAKE,
|
||||
TlsRecordType.APPLICATION_DATA,
|
||||
TlsRecordType.HEARTBEAT
|
||||
];
|
||||
|
||||
if (!validTypes.includes(recordType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check TLS version bytes (should be 0x03 0x0X)
|
||||
if (buffer[1] !== 0x03) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check record length is reasonable
|
||||
const recordLength = readUInt16BE(buffer, 3);
|
||||
if (recordLength > 16384) { // Max TLS record size
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ALPN extension data
|
||||
*/
|
||||
private parseAlpnExtension(data: Buffer): string[] {
|
||||
const protocols: string[] = [];
|
||||
|
||||
if (data.length < 2) {
|
||||
return protocols;
|
||||
}
|
||||
|
||||
const listLength = readUInt16BE(data, 0);
|
||||
let offset = 2;
|
||||
|
||||
while (offset < Math.min(2 + listLength, data.length)) {
|
||||
const protoLength = data[offset];
|
||||
offset++;
|
||||
|
||||
if (offset + protoLength <= data.length) {
|
||||
const protocol = data.subarray(offset, offset + protoLength).toString('ascii');
|
||||
protocols.push(protocol);
|
||||
offset += protoLength;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return protocols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cipher suites
|
||||
*/
|
||||
private parseCipherSuites(cipherData: Buffer): number[] {
|
||||
const suites: number[] = [];
|
||||
|
||||
for (let i = 0; i < cipherData.length - 1; i += 2) {
|
||||
const suite = readUInt16BE(cipherData, i);
|
||||
suites.push(suite);
|
||||
}
|
||||
|
||||
return suites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect with context for fragmented data
|
||||
*/
|
||||
detectWithContext(
|
||||
buffer: Buffer,
|
||||
_context: { sourceIp?: string; sourcePort?: number; destIp?: string; destPort?: number },
|
||||
options?: IDetectionOptions
|
||||
): IDetectionResult | null {
|
||||
// This method is deprecated - TLS detection should use the fragment manager
|
||||
// from the parent detector system, not maintain its own fragments
|
||||
return this.detect(buffer, options);
|
||||
}
|
||||
}
|
25
ts/detection/index.ts
Normal file
25
ts/detection/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Centralized Protocol Detection Module
|
||||
*
|
||||
* This module provides unified protocol detection capabilities for
|
||||
* both TLS and HTTP protocols, extracting connection information
|
||||
* without consuming the data stream.
|
||||
*/
|
||||
|
||||
// Main detector
|
||||
export * from './protocol-detector.js';
|
||||
|
||||
// Models
|
||||
export * from './models/detection-types.js';
|
||||
export * from './models/interfaces.js';
|
||||
|
||||
// Individual detectors
|
||||
export * from './detectors/tls-detector.js';
|
||||
export * from './detectors/http-detector.js';
|
||||
export * from './detectors/quick-detector.js';
|
||||
export * from './detectors/routing-extractor.js';
|
||||
|
||||
// Utilities
|
||||
export * from './utils/buffer-utils.js';
|
||||
export * from './utils/parser-utils.js';
|
||||
export * from './utils/fragment-manager.js';
|
102
ts/detection/models/detection-types.ts
Normal file
102
ts/detection/models/detection-types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Type definitions for protocol detection
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported protocol types that can be detected
|
||||
*/
|
||||
export type TProtocolType = 'tls' | 'http' | 'unknown';
|
||||
|
||||
/**
|
||||
* HTTP method types
|
||||
*/
|
||||
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
|
||||
|
||||
/**
|
||||
* TLS version identifiers
|
||||
*/
|
||||
export type TTlsVersion = 'SSLv3' | 'TLSv1.0' | 'TLSv1.1' | 'TLSv1.2' | 'TLSv1.3';
|
||||
|
||||
/**
|
||||
* Connection information extracted from protocol detection
|
||||
*/
|
||||
export interface IConnectionInfo {
|
||||
/**
|
||||
* The detected protocol type
|
||||
*/
|
||||
protocol: TProtocolType;
|
||||
|
||||
/**
|
||||
* Domain/hostname extracted from the connection
|
||||
* - For TLS: from SNI extension
|
||||
* - For HTTP: from Host header
|
||||
*/
|
||||
domain?: string;
|
||||
|
||||
/**
|
||||
* HTTP-specific fields
|
||||
*/
|
||||
method?: THttpMethod;
|
||||
path?: string;
|
||||
httpVersion?: string;
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* TLS-specific fields
|
||||
*/
|
||||
tlsVersion?: TTlsVersion;
|
||||
sni?: string;
|
||||
alpn?: string[];
|
||||
cipherSuites?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of protocol detection
|
||||
*/
|
||||
export interface IDetectionResult {
|
||||
/**
|
||||
* The detected protocol type
|
||||
*/
|
||||
protocol: TProtocolType;
|
||||
|
||||
/**
|
||||
* Extracted connection information
|
||||
*/
|
||||
connectionInfo: IConnectionInfo;
|
||||
|
||||
/**
|
||||
* Any remaining buffer data after detection headers
|
||||
* This can be used to continue processing the stream
|
||||
*/
|
||||
remainingBuffer?: Buffer;
|
||||
|
||||
/**
|
||||
* Whether the detection is complete or needs more data
|
||||
*/
|
||||
isComplete: boolean;
|
||||
|
||||
/**
|
||||
* Minimum bytes needed for complete detection (if incomplete)
|
||||
*/
|
||||
bytesNeeded?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for protocol detection
|
||||
*/
|
||||
export interface IDetectionOptions {
|
||||
/**
|
||||
* Maximum bytes to buffer for detection (default: 8192)
|
||||
*/
|
||||
maxBufferSize?: number;
|
||||
|
||||
/**
|
||||
* Timeout for detection in milliseconds (default: 5000)
|
||||
*/
|
||||
timeout?: number;
|
||||
|
||||
/**
|
||||
* Whether to extract full headers or just essential info
|
||||
*/
|
||||
extractFullHeaders?: boolean;
|
||||
}
|
115
ts/detection/models/interfaces.ts
Normal file
115
ts/detection/models/interfaces.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Interface definitions for protocol detection components
|
||||
*/
|
||||
|
||||
import type { IDetectionResult, IDetectionOptions } from './detection-types.js';
|
||||
|
||||
/**
|
||||
* Interface for protocol detectors
|
||||
*/
|
||||
export interface IProtocolDetector {
|
||||
/**
|
||||
* Detect protocol from buffer data
|
||||
* @param buffer The buffer to analyze
|
||||
* @param options Detection options
|
||||
* @returns Detection result or null if protocol cannot be determined
|
||||
*/
|
||||
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null;
|
||||
|
||||
/**
|
||||
* Check if buffer potentially contains this protocol
|
||||
* @param buffer The buffer to check
|
||||
* @returns True if buffer might contain this protocol
|
||||
*/
|
||||
canHandle(buffer: Buffer): boolean;
|
||||
|
||||
/**
|
||||
* Get the minimum bytes needed for detection
|
||||
*/
|
||||
getMinimumBytes(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for connection tracking during fragmented detection
|
||||
*/
|
||||
export interface IConnectionTracker {
|
||||
/**
|
||||
* Connection identifier
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Accumulated buffer data
|
||||
*/
|
||||
buffer: Buffer;
|
||||
|
||||
/**
|
||||
* Timestamp of first data
|
||||
*/
|
||||
startTime: number;
|
||||
|
||||
/**
|
||||
* Current detection state
|
||||
*/
|
||||
state: 'detecting' | 'complete' | 'failed';
|
||||
|
||||
/**
|
||||
* Partial detection result (if any)
|
||||
*/
|
||||
partialResult?: Partial<IDetectionResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for buffer accumulator (handles fragmented data)
|
||||
*/
|
||||
export interface IBufferAccumulator {
|
||||
/**
|
||||
* Add data to accumulator
|
||||
*/
|
||||
append(data: Buffer): void;
|
||||
|
||||
/**
|
||||
* Get accumulated buffer
|
||||
*/
|
||||
getBuffer(): Buffer;
|
||||
|
||||
/**
|
||||
* Get buffer length
|
||||
*/
|
||||
length(): number;
|
||||
|
||||
/**
|
||||
* Clear accumulated data
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
* Check if accumulator has enough data
|
||||
*/
|
||||
hasMinimumBytes(minBytes: number): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection events
|
||||
*/
|
||||
export interface IDetectionEvents {
|
||||
/**
|
||||
* Emitted when protocol is successfully detected
|
||||
*/
|
||||
detected: (result: IDetectionResult) => void;
|
||||
|
||||
/**
|
||||
* Emitted when detection fails
|
||||
*/
|
||||
failed: (error: Error) => void;
|
||||
|
||||
/**
|
||||
* Emitted when detection times out
|
||||
*/
|
||||
timeout: () => void;
|
||||
|
||||
/**
|
||||
* Emitted when more data is needed
|
||||
*/
|
||||
needMoreData: (bytesNeeded: number) => void;
|
||||
}
|
311
ts/detection/protocol-detector.ts
Normal file
311
ts/detection/protocol-detector.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Protocol Detector
|
||||
*
|
||||
* Simplified protocol detection using the new architecture
|
||||
*/
|
||||
|
||||
import type { IDetectionResult, IDetectionOptions } from './models/detection-types.js';
|
||||
import type { IConnectionContext } from '../protocols/common/types.js';
|
||||
import { TlsDetector } from './detectors/tls-detector.js';
|
||||
import { HttpDetector } from './detectors/http-detector.js';
|
||||
import { DetectionFragmentManager } from './utils/fragment-manager.js';
|
||||
|
||||
/**
|
||||
* Main protocol detector class
|
||||
*/
|
||||
export class ProtocolDetector {
|
||||
private static instance: ProtocolDetector;
|
||||
private fragmentManager: DetectionFragmentManager;
|
||||
private tlsDetector: TlsDetector;
|
||||
private httpDetector: HttpDetector;
|
||||
private connectionProtocols: Map<string, 'tls' | 'http'> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.fragmentManager = new DetectionFragmentManager();
|
||||
this.tlsDetector = new TlsDetector();
|
||||
this.httpDetector = new HttpDetector(this.fragmentManager);
|
||||
}
|
||||
|
||||
private static getInstance(): ProtocolDetector {
|
||||
if (!this.instance) {
|
||||
this.instance = new ProtocolDetector();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect protocol from buffer data
|
||||
*/
|
||||
static async detect(buffer: Buffer, options?: IDetectionOptions): Promise<IDetectionResult> {
|
||||
return this.getInstance().detectInstance(buffer, options);
|
||||
}
|
||||
|
||||
private async detectInstance(buffer: Buffer, options?: IDetectionOptions): Promise<IDetectionResult> {
|
||||
// Quick sanity check
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return {
|
||||
protocol: 'unknown',
|
||||
connectionInfo: { protocol: 'unknown' },
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
|
||||
// Try TLS detection first (more specific)
|
||||
if (this.tlsDetector.canHandle(buffer)) {
|
||||
const tlsResult = this.tlsDetector.detect(buffer, options);
|
||||
if (tlsResult) {
|
||||
return tlsResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Try HTTP detection
|
||||
if (this.httpDetector.canHandle(buffer)) {
|
||||
const httpResult = this.httpDetector.detect(buffer, options);
|
||||
if (httpResult) {
|
||||
return httpResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Neither TLS nor HTTP
|
||||
return {
|
||||
protocol: 'unknown',
|
||||
connectionInfo: { protocol: 'unknown' },
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect protocol with connection tracking for fragmented data
|
||||
* @deprecated Use detectWithContext instead
|
||||
*/
|
||||
static async detectWithConnectionTracking(
|
||||
buffer: Buffer,
|
||||
connectionId: string,
|
||||
options?: IDetectionOptions
|
||||
): Promise<IDetectionResult> {
|
||||
// Convert connection ID to context
|
||||
const context: IConnectionContext = {
|
||||
id: connectionId,
|
||||
sourceIp: 'unknown',
|
||||
sourcePort: 0,
|
||||
destIp: 'unknown',
|
||||
destPort: 0,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
return this.getInstance().detectWithContextInstance(buffer, context, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect protocol with connection context for fragmented data
|
||||
*/
|
||||
static async detectWithContext(
|
||||
buffer: Buffer,
|
||||
context: IConnectionContext,
|
||||
options?: IDetectionOptions
|
||||
): Promise<IDetectionResult> {
|
||||
return this.getInstance().detectWithContextInstance(buffer, context, options);
|
||||
}
|
||||
|
||||
private async detectWithContextInstance(
|
||||
buffer: Buffer,
|
||||
context: IConnectionContext,
|
||||
options?: IDetectionOptions
|
||||
): Promise<IDetectionResult> {
|
||||
// Quick sanity check
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return {
|
||||
protocol: 'unknown',
|
||||
connectionInfo: { protocol: 'unknown' },
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
|
||||
const connectionId = DetectionFragmentManager.createConnectionId(context);
|
||||
|
||||
// Check if we already know the protocol for this connection
|
||||
const knownProtocol = this.connectionProtocols.get(connectionId);
|
||||
|
||||
if (knownProtocol === 'http') {
|
||||
const result = this.httpDetector.detectWithContext(buffer, context, options);
|
||||
if (result) {
|
||||
if (result.isComplete) {
|
||||
this.connectionProtocols.delete(connectionId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} else if (knownProtocol === 'tls') {
|
||||
// Handle TLS with fragment accumulation
|
||||
const handler = this.fragmentManager.getHandler('tls');
|
||||
const fragmentResult = handler.addFragment(connectionId, buffer);
|
||||
|
||||
if (fragmentResult.error) {
|
||||
handler.complete(connectionId);
|
||||
this.connectionProtocols.delete(connectionId);
|
||||
return {
|
||||
protocol: 'unknown',
|
||||
connectionInfo: { protocol: 'unknown' },
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
|
||||
const result = this.tlsDetector.detect(fragmentResult.buffer!, options);
|
||||
if (result) {
|
||||
if (result.isComplete) {
|
||||
handler.complete(connectionId);
|
||||
this.connectionProtocols.delete(connectionId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't know the protocol yet, try to detect it
|
||||
if (!knownProtocol) {
|
||||
// First peek to determine protocol type
|
||||
if (this.tlsDetector.canHandle(buffer)) {
|
||||
this.connectionProtocols.set(connectionId, 'tls');
|
||||
// Handle TLS with fragment accumulation
|
||||
const handler = this.fragmentManager.getHandler('tls');
|
||||
const fragmentResult = handler.addFragment(connectionId, buffer);
|
||||
|
||||
if (fragmentResult.error) {
|
||||
handler.complete(connectionId);
|
||||
this.connectionProtocols.delete(connectionId);
|
||||
return {
|
||||
protocol: 'unknown',
|
||||
connectionInfo: { protocol: 'unknown' },
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
|
||||
const result = this.tlsDetector.detect(fragmentResult.buffer!, options);
|
||||
if (result) {
|
||||
if (result.isComplete) {
|
||||
handler.complete(connectionId);
|
||||
this.connectionProtocols.delete(connectionId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.httpDetector.canHandle(buffer)) {
|
||||
this.connectionProtocols.set(connectionId, 'http');
|
||||
const result = this.httpDetector.detectWithContext(buffer, context, options);
|
||||
if (result) {
|
||||
if (result.isComplete) {
|
||||
this.connectionProtocols.delete(connectionId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Can't determine protocol
|
||||
return {
|
||||
protocol: 'unknown',
|
||||
connectionInfo: { protocol: 'unknown' },
|
||||
isComplete: false,
|
||||
bytesNeeded: Math.max(
|
||||
this.tlsDetector.getMinimumBytes(),
|
||||
this.httpDetector.getMinimumBytes()
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
static cleanup(): void {
|
||||
this.getInstance().cleanupInstance();
|
||||
}
|
||||
|
||||
private cleanupInstance(): void {
|
||||
this.fragmentManager.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy detector instance
|
||||
*/
|
||||
static destroy(): void {
|
||||
this.getInstance().destroyInstance();
|
||||
this.instance = null as any;
|
||||
}
|
||||
|
||||
private destroyInstance(): void {
|
||||
this.fragmentManager.destroy();
|
||||
this.connectionProtocols.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old connection tracking entries
|
||||
*
|
||||
* @param _maxAge Maximum age in milliseconds (default: 30 seconds)
|
||||
*/
|
||||
static cleanupConnections(_maxAge: number = 30000): void {
|
||||
// Cleanup is now handled internally by the fragment manager
|
||||
this.getInstance().fragmentManager.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up fragments for a specific connection
|
||||
*/
|
||||
static cleanupConnection(context: IConnectionContext): void {
|
||||
const instance = this.getInstance();
|
||||
const connectionId = DetectionFragmentManager.createConnectionId(context);
|
||||
|
||||
// Clean up both TLS and HTTP fragments for this connection
|
||||
instance.fragmentManager.getHandler('tls').complete(connectionId);
|
||||
instance.fragmentManager.getHandler('http').complete(connectionId);
|
||||
|
||||
// Remove from connection protocols tracking
|
||||
instance.connectionProtocols.delete(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from connection info
|
||||
*/
|
||||
static extractDomain(connectionInfo: any): string | undefined {
|
||||
return connectionInfo.domain || connectionInfo.sni || connectionInfo.host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection ID from connection parameters
|
||||
* @deprecated Use createConnectionContext instead
|
||||
*/
|
||||
static createConnectionId(params: {
|
||||
sourceIp?: string;
|
||||
sourcePort?: number;
|
||||
destIp?: string;
|
||||
destPort?: number;
|
||||
socketId?: string;
|
||||
}): string {
|
||||
// If socketId is provided, use it
|
||||
if (params.socketId) {
|
||||
return params.socketId;
|
||||
}
|
||||
|
||||
// Otherwise create from connection tuple
|
||||
const { sourceIp = 'unknown', sourcePort = 0, destIp = 'unknown', destPort = 0 } = params;
|
||||
return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection context from parameters
|
||||
*/
|
||||
static createConnectionContext(params: {
|
||||
sourceIp?: string;
|
||||
sourcePort?: number;
|
||||
destIp?: string;
|
||||
destPort?: number;
|
||||
socketId?: string;
|
||||
}): IConnectionContext {
|
||||
return {
|
||||
id: params.socketId,
|
||||
sourceIp: params.sourceIp || 'unknown',
|
||||
sourcePort: params.sourcePort || 0,
|
||||
destIp: params.destIp || 'unknown',
|
||||
destPort: params.destPort || 0,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
}
|
141
ts/detection/utils/buffer-utils.ts
Normal file
141
ts/detection/utils/buffer-utils.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Buffer manipulation utilities for protocol detection
|
||||
*/
|
||||
|
||||
// Import from protocols
|
||||
import { HttpParser } from '../../protocols/http/index.js';
|
||||
|
||||
/**
|
||||
* BufferAccumulator class for handling fragmented data
|
||||
*/
|
||||
export class BufferAccumulator {
|
||||
private chunks: Buffer[] = [];
|
||||
private totalLength = 0;
|
||||
|
||||
/**
|
||||
* Append data to the accumulator
|
||||
*/
|
||||
append(data: Buffer): void {
|
||||
this.chunks.push(data);
|
||||
this.totalLength += data.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the accumulated buffer
|
||||
*/
|
||||
getBuffer(): Buffer {
|
||||
if (this.chunks.length === 0) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
if (this.chunks.length === 1) {
|
||||
return this.chunks[0];
|
||||
}
|
||||
return Buffer.concat(this.chunks, this.totalLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current buffer length
|
||||
*/
|
||||
length(): number {
|
||||
return this.totalLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all accumulated data
|
||||
*/
|
||||
clear(): void {
|
||||
this.chunks = [];
|
||||
this.totalLength = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if accumulator has minimum bytes
|
||||
*/
|
||||
hasMinimumBytes(minBytes: number): boolean {
|
||||
return this.totalLength >= minBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a big-endian 16-bit integer from buffer
|
||||
*/
|
||||
export function readUInt16BE(buffer: Buffer, offset: number): number {
|
||||
if (offset + 2 > buffer.length) {
|
||||
throw new Error('Buffer too short for UInt16BE read');
|
||||
}
|
||||
return (buffer[offset] << 8) | buffer[offset + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a big-endian 24-bit integer from buffer
|
||||
*/
|
||||
export function readUInt24BE(buffer: Buffer, offset: number): number {
|
||||
if (offset + 3 > buffer.length) {
|
||||
throw new Error('Buffer too short for UInt24BE read');
|
||||
}
|
||||
return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a byte sequence in a buffer
|
||||
*/
|
||||
export function findSequence(buffer: Buffer, sequence: Buffer, startOffset = 0): number {
|
||||
if (sequence.length === 0) {
|
||||
return startOffset;
|
||||
}
|
||||
|
||||
const searchLength = buffer.length - sequence.length + 1;
|
||||
for (let i = startOffset; i < searchLength; i++) {
|
||||
let found = true;
|
||||
for (let j = 0; j < sequence.length; j++) {
|
||||
if (buffer[i + j] !== sequence[j]) {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a line from buffer (up to CRLF or LF)
|
||||
*/
|
||||
export function extractLine(buffer: Buffer, startOffset = 0): { line: string; nextOffset: number } | null {
|
||||
// Delegate to protocol parser
|
||||
return HttpParser.extractLine(buffer, startOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer starts with a string (case-insensitive)
|
||||
*/
|
||||
export function startsWithString(buffer: Buffer, str: string, offset = 0): boolean {
|
||||
if (offset + str.length > buffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bufferStr = buffer.slice(offset, offset + str.length).toString('utf8');
|
||||
return bufferStr.toLowerCase() === str.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe buffer slice that doesn't throw on out-of-bounds
|
||||
*/
|
||||
export function safeSlice(buffer: Buffer, start: number, end?: number): Buffer {
|
||||
const safeStart = Math.max(0, Math.min(start, buffer.length));
|
||||
const safeEnd = end === undefined
|
||||
? buffer.length
|
||||
: Math.max(safeStart, Math.min(end, buffer.length));
|
||||
|
||||
return buffer.slice(safeStart, safeEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer contains printable ASCII
|
||||
*/
|
||||
export function isPrintableAscii(buffer: Buffer, length?: number): boolean {
|
||||
// Delegate to protocol parser
|
||||
return HttpParser.isPrintableAscii(buffer, length);
|
||||
}
|
64
ts/detection/utils/fragment-manager.ts
Normal file
64
ts/detection/utils/fragment-manager.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Fragment Manager for Detection Module
|
||||
*
|
||||
* Manages fragmented protocol data using the shared fragment handler
|
||||
*/
|
||||
|
||||
import { FragmentHandler, type IFragmentOptions } from '../../protocols/common/fragment-handler.js';
|
||||
import type { IConnectionContext } from '../../protocols/common/types.js';
|
||||
|
||||
/**
|
||||
* Detection-specific fragment manager
|
||||
*/
|
||||
export class DetectionFragmentManager {
|
||||
private tlsFragments: FragmentHandler;
|
||||
private httpFragments: FragmentHandler;
|
||||
|
||||
constructor() {
|
||||
// Configure fragment handlers with appropriate limits
|
||||
const tlsOptions: IFragmentOptions = {
|
||||
maxBufferSize: 16384, // TLS record max size
|
||||
timeout: 5000,
|
||||
cleanupInterval: 30000
|
||||
};
|
||||
|
||||
const httpOptions: IFragmentOptions = {
|
||||
maxBufferSize: 8192, // HTTP header reasonable limit
|
||||
timeout: 5000,
|
||||
cleanupInterval: 30000
|
||||
};
|
||||
|
||||
this.tlsFragments = new FragmentHandler(tlsOptions);
|
||||
this.httpFragments = new FragmentHandler(httpOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fragment handler for protocol type
|
||||
*/
|
||||
getHandler(protocol: 'tls' | 'http'): FragmentHandler {
|
||||
return protocol === 'tls' ? this.tlsFragments : this.httpFragments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create connection ID from context
|
||||
*/
|
||||
static createConnectionId(context: IConnectionContext): string {
|
||||
return context.id || `${context.sourceIp}:${context.sourcePort}-${context.destIp}:${context.destPort}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all handlers
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.tlsFragments.cleanup();
|
||||
this.httpFragments.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all handlers
|
||||
*/
|
||||
destroy(): void {
|
||||
this.tlsFragments.destroy();
|
||||
this.httpFragments.destroy();
|
||||
}
|
||||
}
|
77
ts/detection/utils/parser-utils.ts
Normal file
77
ts/detection/utils/parser-utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Parser utilities for protocol detection
|
||||
* Now delegates to protocol modules for actual parsing
|
||||
*/
|
||||
|
||||
import type { THttpMethod, TTlsVersion } from '../models/detection-types.js';
|
||||
import { HttpParser, HTTP_METHODS, HTTP_VERSIONS } from '../../protocols/http/index.js';
|
||||
import { tlsVersionToString as protocolTlsVersionToString } from '../../protocols/tls/index.js';
|
||||
|
||||
// Re-export constants for backward compatibility
|
||||
export { HTTP_METHODS, HTTP_VERSIONS };
|
||||
|
||||
/**
|
||||
* Parse HTTP request line
|
||||
*/
|
||||
export function parseHttpRequestLine(line: string): {
|
||||
method: THttpMethod;
|
||||
path: string;
|
||||
version: string;
|
||||
} | null {
|
||||
// Delegate to protocol parser
|
||||
const result = HttpParser.parseRequestLine(line);
|
||||
return result ? {
|
||||
method: result.method as THttpMethod,
|
||||
path: result.path,
|
||||
version: result.version
|
||||
} : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTTP header line
|
||||
*/
|
||||
export function parseHttpHeader(line: string): { name: string; value: string } | null {
|
||||
// Delegate to protocol parser
|
||||
return HttpParser.parseHeaderLine(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTTP headers from lines
|
||||
*/
|
||||
export function parseHttpHeaders(lines: string[]): Record<string, string> {
|
||||
// Delegate to protocol parser
|
||||
return HttpParser.parseHeaders(lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert TLS version bytes to version string
|
||||
*/
|
||||
export function tlsVersionToString(major: number, minor: number): TTlsVersion | null {
|
||||
// Delegate to protocol parser
|
||||
return protocolTlsVersionToString(major, minor) as TTlsVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from Host header value
|
||||
*/
|
||||
export function extractDomainFromHost(hostHeader: string): string {
|
||||
// Delegate to protocol parser
|
||||
return HttpParser.extractDomainFromHost(hostHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate domain name
|
||||
*/
|
||||
export function isValidDomain(domain: string): boolean {
|
||||
// Delegate to protocol parser
|
||||
return HttpParser.isValidDomain(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is a valid HTTP method
|
||||
*/
|
||||
export function isHttpMethod(str: string): str is THttpMethod {
|
||||
// Delegate to protocol parser
|
||||
return HttpParser.isHttpMethod(str) && (str as THttpMethod) !== undefined;
|
||||
}
|
||||
|
@@ -35,3 +35,5 @@ export type { IAcmeOptions } from './proxies/smart-proxy/models/interfaces.js';
|
||||
// Certificate module has been removed - use SmartCertManager instead
|
||||
export * as tls from './tls/index.js';
|
||||
export * as routing from './routing/index.js';
|
||||
export * as detection from './detection/index.js';
|
||||
export * as protocols from './protocols/index.js';
|
163
ts/protocols/common/fragment-handler.ts
Normal file
163
ts/protocols/common/fragment-handler.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Shared Fragment Handler for Protocol Detection
|
||||
*
|
||||
* Provides unified fragment buffering and reassembly for protocols
|
||||
* that may span multiple TCP packets.
|
||||
*/
|
||||
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
/**
|
||||
* Fragment tracking information
|
||||
*/
|
||||
export interface IFragmentInfo {
|
||||
buffer: Buffer;
|
||||
timestamp: number;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for fragment handling
|
||||
*/
|
||||
export interface IFragmentOptions {
|
||||
maxBufferSize?: number;
|
||||
timeout?: number;
|
||||
cleanupInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of fragment processing
|
||||
*/
|
||||
export interface IFragmentResult {
|
||||
isComplete: boolean;
|
||||
buffer?: Buffer;
|
||||
needsMoreData: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared fragment handler for protocol detection
|
||||
*/
|
||||
export class FragmentHandler {
|
||||
private fragments = new Map<string, IFragmentInfo>();
|
||||
private cleanupTimer?: NodeJS.Timeout;
|
||||
|
||||
constructor(private options: IFragmentOptions = {}) {
|
||||
// Start cleanup timer if not already running
|
||||
if (options.cleanupInterval && !this.cleanupTimer) {
|
||||
this.cleanupTimer = setInterval(
|
||||
() => this.cleanup(),
|
||||
options.cleanupInterval
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a fragment for a connection
|
||||
*/
|
||||
addFragment(connectionId: string, fragment: Buffer): IFragmentResult {
|
||||
const existing = this.fragments.get(connectionId);
|
||||
|
||||
if (existing) {
|
||||
// Append to existing buffer
|
||||
const newBuffer = Buffer.concat([existing.buffer, fragment]);
|
||||
|
||||
// Check size limit
|
||||
const maxSize = this.options.maxBufferSize || 65536;
|
||||
if (newBuffer.length > maxSize) {
|
||||
this.fragments.delete(connectionId);
|
||||
return {
|
||||
isComplete: false,
|
||||
needsMoreData: false,
|
||||
error: 'Buffer size exceeded maximum allowed'
|
||||
};
|
||||
}
|
||||
|
||||
// Update fragment info
|
||||
this.fragments.set(connectionId, {
|
||||
buffer: newBuffer,
|
||||
timestamp: Date.now(),
|
||||
connectionId
|
||||
});
|
||||
|
||||
return {
|
||||
isComplete: false,
|
||||
buffer: newBuffer,
|
||||
needsMoreData: true
|
||||
};
|
||||
} else {
|
||||
// New fragment
|
||||
this.fragments.set(connectionId, {
|
||||
buffer: fragment,
|
||||
timestamp: Date.now(),
|
||||
connectionId
|
||||
});
|
||||
|
||||
return {
|
||||
isComplete: false,
|
||||
buffer: fragment,
|
||||
needsMoreData: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current buffer for a connection
|
||||
*/
|
||||
getBuffer(connectionId: string): Buffer | undefined {
|
||||
return this.fragments.get(connectionId)?.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a connection as complete and clean up
|
||||
*/
|
||||
complete(connectionId: string): void {
|
||||
this.fragments.delete(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're tracking a connection
|
||||
*/
|
||||
hasConnection(connectionId: string): boolean {
|
||||
return this.fragments.has(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired fragments
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const timeout = this.options.timeout || 5000;
|
||||
|
||||
for (const [connectionId, info] of this.fragments.entries()) {
|
||||
if (now - info.timestamp > timeout) {
|
||||
this.fragments.delete(connectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all fragments
|
||||
*/
|
||||
clear(): void {
|
||||
this.fragments.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the handler and clean up resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = undefined;
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of tracked connections
|
||||
*/
|
||||
get size(): number {
|
||||
return this.fragments.size;
|
||||
}
|
||||
}
|
8
ts/protocols/common/index.ts
Normal file
8
ts/protocols/common/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Common Protocol Infrastructure
|
||||
*
|
||||
* Shared utilities and types for protocol handling
|
||||
*/
|
||||
|
||||
export * from './fragment-handler.js';
|
||||
export * from './types.js';
|
76
ts/protocols/common/types.ts
Normal file
76
ts/protocols/common/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Common Protocol Types
|
||||
*
|
||||
* Shared types used across different protocol implementations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported protocol types
|
||||
*/
|
||||
export type TProtocolType = 'tls' | 'http' | 'https' | 'websocket' | 'unknown';
|
||||
|
||||
/**
|
||||
* Protocol detection result
|
||||
*/
|
||||
export interface IProtocolDetectionResult {
|
||||
protocol: TProtocolType;
|
||||
confidence: number; // 0-100
|
||||
requiresMoreData?: boolean;
|
||||
metadata?: {
|
||||
version?: string;
|
||||
method?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing information extracted from protocols
|
||||
*/
|
||||
export interface IRoutingInfo {
|
||||
domain?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
protocol: TProtocolType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection context for protocol operations
|
||||
*/
|
||||
export interface IConnectionContext {
|
||||
id: string;
|
||||
sourceIp?: string;
|
||||
sourcePort?: number;
|
||||
destIp?: string;
|
||||
destPort?: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol detection options
|
||||
*/
|
||||
export interface IProtocolDetectionOptions {
|
||||
quickMode?: boolean; // Only do minimal detection
|
||||
extractRouting?: boolean; // Extract routing information
|
||||
maxWaitTime?: number; // Max time to wait for complete data
|
||||
maxBufferSize?: number; // Max buffer size for fragmented data
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for protocol detectors
|
||||
*/
|
||||
export interface IProtocolDetector {
|
||||
/**
|
||||
* Check if this detector can handle the data
|
||||
*/
|
||||
canHandle(data: Buffer): boolean;
|
||||
|
||||
/**
|
||||
* Perform quick detection (first few bytes only)
|
||||
*/
|
||||
quickDetect(data: Buffer): IProtocolDetectionResult;
|
||||
|
||||
/**
|
||||
* Extract routing information if possible
|
||||
*/
|
||||
extractRouting?(data: Buffer, context?: IConnectionContext): IRoutingInfo | null;
|
||||
}
|
219
ts/protocols/http/constants.ts
Normal file
219
ts/protocols/http/constants.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* HTTP Protocol Constants
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTTP methods
|
||||
*/
|
||||
export const HTTP_METHODS = [
|
||||
'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE'
|
||||
] as const;
|
||||
|
||||
export type THttpMethod = typeof HTTP_METHODS[number];
|
||||
|
||||
/**
|
||||
* HTTP version strings
|
||||
*/
|
||||
export const HTTP_VERSIONS = ['HTTP/1.0', 'HTTP/1.1', 'HTTP/2', 'HTTP/3'] as const;
|
||||
|
||||
export type THttpVersion = typeof HTTP_VERSIONS[number];
|
||||
|
||||
/**
|
||||
* HTTP status codes
|
||||
*/
|
||||
export enum HttpStatus {
|
||||
// 1xx Informational
|
||||
CONTINUE = 100,
|
||||
SWITCHING_PROTOCOLS = 101,
|
||||
PROCESSING = 102,
|
||||
EARLY_HINTS = 103,
|
||||
|
||||
// 2xx Success
|
||||
OK = 200,
|
||||
CREATED = 201,
|
||||
ACCEPTED = 202,
|
||||
NON_AUTHORITATIVE_INFORMATION = 203,
|
||||
NO_CONTENT = 204,
|
||||
RESET_CONTENT = 205,
|
||||
PARTIAL_CONTENT = 206,
|
||||
MULTI_STATUS = 207,
|
||||
ALREADY_REPORTED = 208,
|
||||
IM_USED = 226,
|
||||
|
||||
// 3xx Redirection
|
||||
MULTIPLE_CHOICES = 300,
|
||||
MOVED_PERMANENTLY = 301,
|
||||
FOUND = 302,
|
||||
SEE_OTHER = 303,
|
||||
NOT_MODIFIED = 304,
|
||||
USE_PROXY = 305,
|
||||
TEMPORARY_REDIRECT = 307,
|
||||
PERMANENT_REDIRECT = 308,
|
||||
|
||||
// 4xx Client Error
|
||||
BAD_REQUEST = 400,
|
||||
UNAUTHORIZED = 401,
|
||||
PAYMENT_REQUIRED = 402,
|
||||
FORBIDDEN = 403,
|
||||
NOT_FOUND = 404,
|
||||
METHOD_NOT_ALLOWED = 405,
|
||||
NOT_ACCEPTABLE = 406,
|
||||
PROXY_AUTHENTICATION_REQUIRED = 407,
|
||||
REQUEST_TIMEOUT = 408,
|
||||
CONFLICT = 409,
|
||||
GONE = 410,
|
||||
LENGTH_REQUIRED = 411,
|
||||
PRECONDITION_FAILED = 412,
|
||||
PAYLOAD_TOO_LARGE = 413,
|
||||
URI_TOO_LONG = 414,
|
||||
UNSUPPORTED_MEDIA_TYPE = 415,
|
||||
RANGE_NOT_SATISFIABLE = 416,
|
||||
EXPECTATION_FAILED = 417,
|
||||
IM_A_TEAPOT = 418,
|
||||
MISDIRECTED_REQUEST = 421,
|
||||
UNPROCESSABLE_ENTITY = 422,
|
||||
LOCKED = 423,
|
||||
FAILED_DEPENDENCY = 424,
|
||||
TOO_EARLY = 425,
|
||||
UPGRADE_REQUIRED = 426,
|
||||
PRECONDITION_REQUIRED = 428,
|
||||
TOO_MANY_REQUESTS = 429,
|
||||
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
|
||||
UNAVAILABLE_FOR_LEGAL_REASONS = 451,
|
||||
|
||||
// 5xx Server Error
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
NOT_IMPLEMENTED = 501,
|
||||
BAD_GATEWAY = 502,
|
||||
SERVICE_UNAVAILABLE = 503,
|
||||
GATEWAY_TIMEOUT = 504,
|
||||
HTTP_VERSION_NOT_SUPPORTED = 505,
|
||||
VARIANT_ALSO_NEGOTIATES = 506,
|
||||
INSUFFICIENT_STORAGE = 507,
|
||||
LOOP_DETECTED = 508,
|
||||
NOT_EXTENDED = 510,
|
||||
NETWORK_AUTHENTICATION_REQUIRED = 511,
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP status text mapping
|
||||
*/
|
||||
export const HTTP_STATUS_TEXT: Record<HttpStatus, string> = {
|
||||
// 1xx
|
||||
[HttpStatus.CONTINUE]: 'Continue',
|
||||
[HttpStatus.SWITCHING_PROTOCOLS]: 'Switching Protocols',
|
||||
[HttpStatus.PROCESSING]: 'Processing',
|
||||
[HttpStatus.EARLY_HINTS]: 'Early Hints',
|
||||
|
||||
// 2xx
|
||||
[HttpStatus.OK]: 'OK',
|
||||
[HttpStatus.CREATED]: 'Created',
|
||||
[HttpStatus.ACCEPTED]: 'Accepted',
|
||||
[HttpStatus.NON_AUTHORITATIVE_INFORMATION]: 'Non-Authoritative Information',
|
||||
[HttpStatus.NO_CONTENT]: 'No Content',
|
||||
[HttpStatus.RESET_CONTENT]: 'Reset Content',
|
||||
[HttpStatus.PARTIAL_CONTENT]: 'Partial Content',
|
||||
[HttpStatus.MULTI_STATUS]: 'Multi-Status',
|
||||
[HttpStatus.ALREADY_REPORTED]: 'Already Reported',
|
||||
[HttpStatus.IM_USED]: 'IM Used',
|
||||
|
||||
// 3xx
|
||||
[HttpStatus.MULTIPLE_CHOICES]: 'Multiple Choices',
|
||||
[HttpStatus.MOVED_PERMANENTLY]: 'Moved Permanently',
|
||||
[HttpStatus.FOUND]: 'Found',
|
||||
[HttpStatus.SEE_OTHER]: 'See Other',
|
||||
[HttpStatus.NOT_MODIFIED]: 'Not Modified',
|
||||
[HttpStatus.USE_PROXY]: 'Use Proxy',
|
||||
[HttpStatus.TEMPORARY_REDIRECT]: 'Temporary Redirect',
|
||||
[HttpStatus.PERMANENT_REDIRECT]: 'Permanent Redirect',
|
||||
|
||||
// 4xx
|
||||
[HttpStatus.BAD_REQUEST]: 'Bad Request',
|
||||
[HttpStatus.UNAUTHORIZED]: 'Unauthorized',
|
||||
[HttpStatus.PAYMENT_REQUIRED]: 'Payment Required',
|
||||
[HttpStatus.FORBIDDEN]: 'Forbidden',
|
||||
[HttpStatus.NOT_FOUND]: 'Not Found',
|
||||
[HttpStatus.METHOD_NOT_ALLOWED]: 'Method Not Allowed',
|
||||
[HttpStatus.NOT_ACCEPTABLE]: 'Not Acceptable',
|
||||
[HttpStatus.PROXY_AUTHENTICATION_REQUIRED]: 'Proxy Authentication Required',
|
||||
[HttpStatus.REQUEST_TIMEOUT]: 'Request Timeout',
|
||||
[HttpStatus.CONFLICT]: 'Conflict',
|
||||
[HttpStatus.GONE]: 'Gone',
|
||||
[HttpStatus.LENGTH_REQUIRED]: 'Length Required',
|
||||
[HttpStatus.PRECONDITION_FAILED]: 'Precondition Failed',
|
||||
[HttpStatus.PAYLOAD_TOO_LARGE]: 'Payload Too Large',
|
||||
[HttpStatus.URI_TOO_LONG]: 'URI Too Long',
|
||||
[HttpStatus.UNSUPPORTED_MEDIA_TYPE]: 'Unsupported Media Type',
|
||||
[HttpStatus.RANGE_NOT_SATISFIABLE]: 'Range Not Satisfiable',
|
||||
[HttpStatus.EXPECTATION_FAILED]: 'Expectation Failed',
|
||||
[HttpStatus.IM_A_TEAPOT]: "I'm a teapot",
|
||||
[HttpStatus.MISDIRECTED_REQUEST]: 'Misdirected Request',
|
||||
[HttpStatus.UNPROCESSABLE_ENTITY]: 'Unprocessable Entity',
|
||||
[HttpStatus.LOCKED]: 'Locked',
|
||||
[HttpStatus.FAILED_DEPENDENCY]: 'Failed Dependency',
|
||||
[HttpStatus.TOO_EARLY]: 'Too Early',
|
||||
[HttpStatus.UPGRADE_REQUIRED]: 'Upgrade Required',
|
||||
[HttpStatus.PRECONDITION_REQUIRED]: 'Precondition Required',
|
||||
[HttpStatus.TOO_MANY_REQUESTS]: 'Too Many Requests',
|
||||
[HttpStatus.REQUEST_HEADER_FIELDS_TOO_LARGE]: 'Request Header Fields Too Large',
|
||||
[HttpStatus.UNAVAILABLE_FOR_LEGAL_REASONS]: 'Unavailable For Legal Reasons',
|
||||
|
||||
// 5xx
|
||||
[HttpStatus.INTERNAL_SERVER_ERROR]: 'Internal Server Error',
|
||||
[HttpStatus.NOT_IMPLEMENTED]: 'Not Implemented',
|
||||
[HttpStatus.BAD_GATEWAY]: 'Bad Gateway',
|
||||
[HttpStatus.SERVICE_UNAVAILABLE]: 'Service Unavailable',
|
||||
[HttpStatus.GATEWAY_TIMEOUT]: 'Gateway Timeout',
|
||||
[HttpStatus.HTTP_VERSION_NOT_SUPPORTED]: 'HTTP Version Not Supported',
|
||||
[HttpStatus.VARIANT_ALSO_NEGOTIATES]: 'Variant Also Negotiates',
|
||||
[HttpStatus.INSUFFICIENT_STORAGE]: 'Insufficient Storage',
|
||||
[HttpStatus.LOOP_DETECTED]: 'Loop Detected',
|
||||
[HttpStatus.NOT_EXTENDED]: 'Not Extended',
|
||||
[HttpStatus.NETWORK_AUTHENTICATION_REQUIRED]: 'Network Authentication Required',
|
||||
};
|
||||
|
||||
/**
|
||||
* Common HTTP headers
|
||||
*/
|
||||
export const HTTP_HEADERS = {
|
||||
// Request headers
|
||||
HOST: 'host',
|
||||
USER_AGENT: 'user-agent',
|
||||
ACCEPT: 'accept',
|
||||
ACCEPT_LANGUAGE: 'accept-language',
|
||||
ACCEPT_ENCODING: 'accept-encoding',
|
||||
AUTHORIZATION: 'authorization',
|
||||
CACHE_CONTROL: 'cache-control',
|
||||
CONNECTION: 'connection',
|
||||
CONTENT_TYPE: 'content-type',
|
||||
CONTENT_LENGTH: 'content-length',
|
||||
COOKIE: 'cookie',
|
||||
|
||||
// Response headers
|
||||
SET_COOKIE: 'set-cookie',
|
||||
LOCATION: 'location',
|
||||
SERVER: 'server',
|
||||
DATE: 'date',
|
||||
EXPIRES: 'expires',
|
||||
LAST_MODIFIED: 'last-modified',
|
||||
ETAG: 'etag',
|
||||
|
||||
// CORS headers
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN: 'access-control-allow-origin',
|
||||
ACCESS_CONTROL_ALLOW_METHODS: 'access-control-allow-methods',
|
||||
ACCESS_CONTROL_ALLOW_HEADERS: 'access-control-allow-headers',
|
||||
|
||||
// Security headers
|
||||
STRICT_TRANSPORT_SECURITY: 'strict-transport-security',
|
||||
X_CONTENT_TYPE_OPTIONS: 'x-content-type-options',
|
||||
X_FRAME_OPTIONS: 'x-frame-options',
|
||||
X_XSS_PROTECTION: 'x-xss-protection',
|
||||
CONTENT_SECURITY_POLICY: 'content-security-policy',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get HTTP status text
|
||||
*/
|
||||
export function getStatusText(status: HttpStatus): string {
|
||||
return HTTP_STATUS_TEXT[status] || 'Unknown';
|
||||
}
|
8
ts/protocols/http/index.ts
Normal file
8
ts/protocols/http/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* HTTP Protocol Module
|
||||
* Generic HTTP protocol knowledge and parsing utilities
|
||||
*/
|
||||
|
||||
export * from './constants.js';
|
||||
export * from './types.js';
|
||||
export * from './parser.js';
|
219
ts/protocols/http/parser.ts
Normal file
219
ts/protocols/http/parser.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* HTTP Protocol Parser
|
||||
* Generic HTTP parsing utilities
|
||||
*/
|
||||
|
||||
import { HTTP_METHODS, type THttpMethod, type THttpVersion } from './constants.js';
|
||||
import type { IHttpRequestLine, IHttpHeader } from './types.js';
|
||||
|
||||
/**
|
||||
* HTTP parser utilities
|
||||
*/
|
||||
export class HttpParser {
|
||||
/**
|
||||
* Check if string is a valid HTTP method
|
||||
*/
|
||||
static isHttpMethod(str: string): str is THttpMethod {
|
||||
return HTTP_METHODS.includes(str as THttpMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTTP request line
|
||||
*/
|
||||
static parseRequestLine(line: string): IHttpRequestLine | null {
|
||||
const parts = line.trim().split(' ');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [method, path, version] = parts;
|
||||
|
||||
// Validate method
|
||||
if (!this.isHttpMethod(method)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate version
|
||||
if (!version.startsWith('HTTP/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
method: method as THttpMethod,
|
||||
path,
|
||||
version: version as THttpVersion
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTTP header line
|
||||
*/
|
||||
static parseHeaderLine(line: string): IHttpHeader | null {
|
||||
const colonIndex = line.indexOf(':');
|
||||
|
||||
if (colonIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = line.slice(0, colonIndex).trim();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTTP headers from lines
|
||||
*/
|
||||
static parseHeaders(lines: string[]): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
const header = this.parseHeaderLine(line);
|
||||
if (header) {
|
||||
// Convert header names to lowercase for consistency
|
||||
headers[header.name.toLowerCase()] = header.value;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from Host header value
|
||||
*/
|
||||
static extractDomainFromHost(hostHeader: string): string {
|
||||
// Remove port if present
|
||||
const colonIndex = hostHeader.lastIndexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
// Check if it's not part of IPv6 address
|
||||
const beforeColon = hostHeader.slice(0, colonIndex);
|
||||
if (!beforeColon.includes(']')) {
|
||||
return beforeColon;
|
||||
}
|
||||
}
|
||||
return hostHeader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate domain name
|
||||
*/
|
||||
static isValidDomain(domain: string): boolean {
|
||||
// Basic domain validation
|
||||
if (!domain || domain.length > 253) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for valid characters and structure
|
||||
const domainRegex = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z0-9-]{1,63})*$/;
|
||||
return domainRegex.test(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract line from buffer
|
||||
*/
|
||||
static extractLine(buffer: Buffer, offset: number = 0): { line: string; nextOffset: number } | null {
|
||||
// Look for CRLF
|
||||
const crlfIndex = buffer.indexOf('\r\n', offset);
|
||||
if (crlfIndex === -1) {
|
||||
// Look for just LF
|
||||
const lfIndex = buffer.indexOf('\n', offset);
|
||||
if (lfIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
line: buffer.slice(offset, lfIndex).toString('utf8'),
|
||||
nextOffset: lfIndex + 1
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
line: buffer.slice(offset, crlfIndex).toString('utf8'),
|
||||
nextOffset: crlfIndex + 2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer contains printable ASCII
|
||||
*/
|
||||
static isPrintableAscii(buffer: Buffer, length?: number): boolean {
|
||||
const checkLength = Math.min(length || buffer.length, buffer.length);
|
||||
|
||||
for (let i = 0; i < checkLength; i++) {
|
||||
const byte = buffer[i];
|
||||
// Allow printable ASCII (32-126) plus tab (9), LF (10), and CR (13)
|
||||
if (byte < 32 || byte > 126) {
|
||||
if (byte !== 9 && byte !== 10 && byte !== 13) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if buffer starts with HTTP method
|
||||
*/
|
||||
static quickCheck(buffer: Buffer): boolean {
|
||||
if (buffer.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check common HTTP methods
|
||||
const start = buffer.slice(0, 7).toString('ascii');
|
||||
return start.startsWith('GET ') ||
|
||||
start.startsWith('POST ') ||
|
||||
start.startsWith('PUT ') ||
|
||||
start.startsWith('DELETE ') ||
|
||||
start.startsWith('HEAD ') ||
|
||||
start.startsWith('OPTIONS') ||
|
||||
start.startsWith('PATCH ') ||
|
||||
start.startsWith('CONNECT') ||
|
||||
start.startsWith('TRACE ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse query string
|
||||
*/
|
||||
static parseQueryString(queryString: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (!queryString) {
|
||||
return params;
|
||||
}
|
||||
|
||||
// Remove leading '?' if present
|
||||
if (queryString.startsWith('?')) {
|
||||
queryString = queryString.slice(1);
|
||||
}
|
||||
|
||||
const pairs = queryString.split('&');
|
||||
for (const pair of pairs) {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from params
|
||||
*/
|
||||
static buildQueryString(params: Record<string, string>): string {
|
||||
const pairs: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
|
||||
return pairs.length > 0 ? '?' + pairs.join('&') : '';
|
||||
}
|
||||
}
|
70
ts/protocols/http/types.ts
Normal file
70
ts/protocols/http/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* HTTP Protocol Type Definitions
|
||||
*/
|
||||
|
||||
import type { THttpMethod, THttpVersion, HttpStatus } from './constants.js';
|
||||
|
||||
/**
|
||||
* HTTP request line structure
|
||||
*/
|
||||
export interface IHttpRequestLine {
|
||||
method: THttpMethod;
|
||||
path: string;
|
||||
version: THttpVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP response line structure
|
||||
*/
|
||||
export interface IHttpResponseLine {
|
||||
version: THttpVersion;
|
||||
status: HttpStatus;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP header structure
|
||||
*/
|
||||
export interface IHttpHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP message structure (base for request and response)
|
||||
*/
|
||||
export interface IHttpMessage {
|
||||
headers: Record<string, string>;
|
||||
body?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request structure
|
||||
*/
|
||||
export interface IHttpRequest extends IHttpMessage {
|
||||
method: THttpMethod;
|
||||
path: string;
|
||||
version: THttpVersion;
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP response structure
|
||||
*/
|
||||
export interface IHttpResponse extends IHttpMessage {
|
||||
status: HttpStatus;
|
||||
statusText: string;
|
||||
version: THttpVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed URL structure
|
||||
*/
|
||||
export interface IParsedUrl {
|
||||
protocol?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
query?: string;
|
||||
fragment?: string;
|
||||
}
|
12
ts/protocols/index.ts
Normal file
12
ts/protocols/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Protocol-specific modules for smartproxy
|
||||
*
|
||||
* This directory contains generic protocol knowledge separated from
|
||||
* smartproxy-specific implementation details.
|
||||
*/
|
||||
|
||||
export * as common from './common/index.js';
|
||||
export * as tls from './tls/index.js';
|
||||
export * as http from './http/index.js';
|
||||
export * as proxy from './proxy/index.js';
|
||||
export * as websocket from './websocket/index.js';
|
7
ts/protocols/proxy/index.ts
Normal file
7
ts/protocols/proxy/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* PROXY Protocol Module
|
||||
* HAProxy PROXY protocol implementation
|
||||
*/
|
||||
|
||||
export * from './types.js';
|
||||
export * from './parser.js';
|
183
ts/protocols/proxy/parser.ts
Normal file
183
ts/protocols/proxy/parser.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* PROXY Protocol Parser
|
||||
* Implementation of HAProxy PROXY protocol v1 (text format)
|
||||
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
|
||||
*/
|
||||
|
||||
import type { IProxyInfo, IProxyParseResult, TProxyProtocol } from './types.js';
|
||||
|
||||
/**
|
||||
* PROXY protocol parser
|
||||
*/
|
||||
export class ProxyProtocolParser {
|
||||
static readonly PROXY_V1_SIGNATURE = 'PROXY ';
|
||||
static readonly MAX_HEADER_LENGTH = 107; // Max length for v1 header
|
||||
static readonly HEADER_TERMINATOR = '\r\n';
|
||||
|
||||
/**
|
||||
* Parse PROXY protocol v1 header from buffer
|
||||
* Returns proxy info and remaining data after header
|
||||
*/
|
||||
static parse(data: Buffer): IProxyParseResult {
|
||||
// Check if buffer starts with PROXY signature
|
||||
if (!data.toString('ascii', 0, 6).startsWith(this.PROXY_V1_SIGNATURE)) {
|
||||
return {
|
||||
proxyInfo: null,
|
||||
remainingData: data
|
||||
};
|
||||
}
|
||||
|
||||
// Find header terminator
|
||||
const headerEndIndex = data.indexOf(this.HEADER_TERMINATOR);
|
||||
if (headerEndIndex === -1) {
|
||||
// Header incomplete, need more data
|
||||
if (data.length > this.MAX_HEADER_LENGTH) {
|
||||
// Header too long, invalid
|
||||
throw new Error('PROXY protocol header exceeds maximum length');
|
||||
}
|
||||
return {
|
||||
proxyInfo: null,
|
||||
remainingData: data
|
||||
};
|
||||
}
|
||||
|
||||
// Extract header line
|
||||
const headerLine = data.toString('ascii', 0, headerEndIndex);
|
||||
const remainingData = data.slice(headerEndIndex + 2); // Skip \r\n
|
||||
|
||||
// Parse header
|
||||
const parts = headerLine.split(' ');
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
|
||||
}
|
||||
|
||||
const [signature, protocol] = parts;
|
||||
|
||||
// Validate protocol
|
||||
if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(protocol)) {
|
||||
throw new Error(`Invalid PROXY protocol: ${protocol}`);
|
||||
}
|
||||
|
||||
// For UNKNOWN protocol, ignore addresses
|
||||
if (protocol === 'UNKNOWN') {
|
||||
return {
|
||||
proxyInfo: {
|
||||
protocol: 'UNKNOWN',
|
||||
sourceIP: '',
|
||||
sourcePort: 0,
|
||||
destinationIP: '',
|
||||
destinationPort: 0
|
||||
},
|
||||
remainingData
|
||||
};
|
||||
}
|
||||
|
||||
// For TCP4/TCP6, we need all 6 parts
|
||||
if (parts.length !== 6) {
|
||||
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
|
||||
}
|
||||
|
||||
const [, , srcIP, dstIP, srcPort, dstPort] = parts;
|
||||
|
||||
// Validate and parse ports
|
||||
const sourcePort = parseInt(srcPort, 10);
|
||||
const destinationPort = parseInt(dstPort, 10);
|
||||
|
||||
if (isNaN(sourcePort) || sourcePort < 0 || sourcePort > 65535) {
|
||||
throw new Error(`Invalid source port: ${srcPort}`);
|
||||
}
|
||||
|
||||
if (isNaN(destinationPort) || destinationPort < 0 || destinationPort > 65535) {
|
||||
throw new Error(`Invalid destination port: ${dstPort}`);
|
||||
}
|
||||
|
||||
// Validate IP addresses
|
||||
const protocolType = protocol as TProxyProtocol;
|
||||
if (!this.isValidIP(srcIP, protocolType)) {
|
||||
throw new Error(`Invalid source IP for ${protocol}: ${srcIP}`);
|
||||
}
|
||||
|
||||
if (!this.isValidIP(dstIP, protocolType)) {
|
||||
throw new Error(`Invalid destination IP for ${protocol}: ${dstIP}`);
|
||||
}
|
||||
|
||||
return {
|
||||
proxyInfo: {
|
||||
protocol: protocolType,
|
||||
sourceIP: srcIP,
|
||||
sourcePort,
|
||||
destinationIP: dstIP,
|
||||
destinationPort
|
||||
},
|
||||
remainingData
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PROXY protocol v1 header
|
||||
*/
|
||||
static generate(info: IProxyInfo): Buffer {
|
||||
if (info.protocol === 'UNKNOWN') {
|
||||
return Buffer.from(`PROXY UNKNOWN\r\n`, 'ascii');
|
||||
}
|
||||
|
||||
const header = `PROXY ${info.protocol} ${info.sourceIP} ${info.destinationIP} ${info.sourcePort} ${info.destinationPort}\r\n`;
|
||||
|
||||
if (header.length > this.MAX_HEADER_LENGTH) {
|
||||
throw new Error('Generated PROXY protocol header exceeds maximum length');
|
||||
}
|
||||
|
||||
return Buffer.from(header, 'ascii');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IP address format
|
||||
*/
|
||||
static isValidIP(ip: string, protocol: TProxyProtocol): boolean {
|
||||
if (protocol === 'TCP4') {
|
||||
return this.isIPv4(ip);
|
||||
} else if (protocol === 'TCP6') {
|
||||
return this.isIPv6(ip);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is valid IPv4
|
||||
*/
|
||||
static isIPv4(ip: string): boolean {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
|
||||
for (const part of parts) {
|
||||
const num = parseInt(part, 10);
|
||||
if (isNaN(num) || num < 0 || num > 255 || part !== num.toString()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is valid IPv6
|
||||
*/
|
||||
static isIPv6(ip: string): boolean {
|
||||
// Basic IPv6 validation
|
||||
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
|
||||
return ipv6Regex.test(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection ID string for tracking
|
||||
*/
|
||||
static createConnectionId(connectionInfo: {
|
||||
sourceIp?: string;
|
||||
sourcePort?: number;
|
||||
destIp?: string;
|
||||
destPort?: number;
|
||||
}): string {
|
||||
const { sourceIp, sourcePort, destIp, destPort } = connectionInfo;
|
||||
return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`;
|
||||
}
|
||||
}
|
53
ts/protocols/proxy/types.ts
Normal file
53
ts/protocols/proxy/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* PROXY Protocol Type Definitions
|
||||
* Based on HAProxy PROXY protocol specification
|
||||
*/
|
||||
|
||||
/**
|
||||
* PROXY protocol version
|
||||
*/
|
||||
export type TProxyProtocolVersion = 'v1' | 'v2';
|
||||
|
||||
/**
|
||||
* Connection protocol type
|
||||
*/
|
||||
export type TProxyProtocol = 'TCP4' | 'TCP6' | 'UNKNOWN';
|
||||
|
||||
/**
|
||||
* Interface representing parsed PROXY protocol information
|
||||
*/
|
||||
export interface IProxyInfo {
|
||||
protocol: TProxyProtocol;
|
||||
sourceIP: string;
|
||||
sourcePort: number;
|
||||
destinationIP: string;
|
||||
destinationPort: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for parse result including remaining data
|
||||
*/
|
||||
export interface IProxyParseResult {
|
||||
proxyInfo: IProxyInfo | null;
|
||||
remainingData: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* PROXY protocol v2 header format
|
||||
*/
|
||||
export interface IProxyV2Header {
|
||||
signature: Buffer;
|
||||
versionCommand: number;
|
||||
family: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection information for PROXY protocol
|
||||
*/
|
||||
export interface IProxyConnectionInfo {
|
||||
sourceIp?: string;
|
||||
sourcePort?: number;
|
||||
destIp?: string;
|
||||
destPort?: number;
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import { TlsAlertLevel, TlsAlertDescription, TlsVersion } from '../utils/tls-utils.js';
|
||||
|
||||
/**
|
37
ts/protocols/tls/index.ts
Normal file
37
ts/protocols/tls/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* TLS Protocol Module
|
||||
* Contains generic TLS protocol knowledge including parsers, constants, and utilities
|
||||
*/
|
||||
|
||||
// Export all sub-modules
|
||||
export * from './alerts/index.js';
|
||||
export * from './sni/index.js';
|
||||
export * from './utils/index.js';
|
||||
|
||||
// Re-export main utilities and types for convenience
|
||||
export {
|
||||
TlsUtils,
|
||||
TlsRecordType,
|
||||
TlsHandshakeType,
|
||||
TlsExtensionType,
|
||||
TlsAlertLevel,
|
||||
TlsAlertDescription,
|
||||
TlsVersion
|
||||
} from './utils/tls-utils.js';
|
||||
export { TlsAlert } from './alerts/tls-alert.js';
|
||||
export { ClientHelloParser } from './sni/client-hello-parser.js';
|
||||
export { SniExtraction } from './sni/sni-extraction.js';
|
||||
|
||||
// Export tlsVersionToString helper
|
||||
export function tlsVersionToString(major: number, minor: number): string | null {
|
||||
if (major === 0x03) {
|
||||
switch (minor) {
|
||||
case 0x00: return 'SSLv3';
|
||||
case 0x01: return 'TLSv1.0';
|
||||
case 0x02: return 'TLSv1.1';
|
||||
case 0x03: return 'TLSv1.2';
|
||||
case 0x04: return 'TLSv1.3';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
6
ts/protocols/tls/sni/index.ts
Normal file
6
ts/protocols/tls/sni/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* TLS SNI (Server Name Indication) protocol utilities
|
||||
*/
|
||||
|
||||
export * from './client-hello-parser.js';
|
||||
export * from './sni-extraction.js';
|
@@ -1,4 +1,4 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as plugins from '../../../plugins.js';
|
||||
|
||||
/**
|
||||
* TLS record types as defined in various RFCs
|
60
ts/protocols/websocket/constants.ts
Normal file
60
ts/protocols/websocket/constants.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* WebSocket Protocol Constants
|
||||
* Based on RFC 6455
|
||||
*/
|
||||
|
||||
/**
|
||||
* WebSocket opcode types
|
||||
*/
|
||||
export enum WebSocketOpcode {
|
||||
CONTINUATION = 0x0,
|
||||
TEXT = 0x1,
|
||||
BINARY = 0x2,
|
||||
CLOSE = 0x8,
|
||||
PING = 0x9,
|
||||
PONG = 0xa,
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket close codes
|
||||
*/
|
||||
export enum WebSocketCloseCode {
|
||||
NORMAL_CLOSURE = 1000,
|
||||
GOING_AWAY = 1001,
|
||||
PROTOCOL_ERROR = 1002,
|
||||
UNSUPPORTED_DATA = 1003,
|
||||
NO_STATUS_RECEIVED = 1005,
|
||||
ABNORMAL_CLOSURE = 1006,
|
||||
INVALID_FRAME_PAYLOAD_DATA = 1007,
|
||||
POLICY_VIOLATION = 1008,
|
||||
MESSAGE_TOO_BIG = 1009,
|
||||
MISSING_EXTENSION = 1010,
|
||||
INTERNAL_ERROR = 1011,
|
||||
SERVICE_RESTART = 1012,
|
||||
TRY_AGAIN_LATER = 1013,
|
||||
BAD_GATEWAY = 1014,
|
||||
TLS_HANDSHAKE = 1015,
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket protocol version
|
||||
*/
|
||||
export const WEBSOCKET_VERSION = 13;
|
||||
|
||||
/**
|
||||
* WebSocket magic string for handshake
|
||||
*/
|
||||
export const WEBSOCKET_MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
/**
|
||||
* WebSocket headers
|
||||
*/
|
||||
export const WEBSOCKET_HEADERS = {
|
||||
UPGRADE: 'upgrade',
|
||||
CONNECTION: 'connection',
|
||||
SEC_WEBSOCKET_KEY: 'sec-websocket-key',
|
||||
SEC_WEBSOCKET_VERSION: 'sec-websocket-version',
|
||||
SEC_WEBSOCKET_ACCEPT: 'sec-websocket-accept',
|
||||
SEC_WEBSOCKET_PROTOCOL: 'sec-websocket-protocol',
|
||||
SEC_WEBSOCKET_EXTENSIONS: 'sec-websocket-extensions',
|
||||
} as const;
|
8
ts/protocols/websocket/index.ts
Normal file
8
ts/protocols/websocket/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* WebSocket Protocol Module
|
||||
* WebSocket protocol utilities and constants
|
||||
*/
|
||||
|
||||
export * from './constants.js';
|
||||
export * from './types.js';
|
||||
export * from './utils.js';
|
53
ts/protocols/websocket/types.ts
Normal file
53
ts/protocols/websocket/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* WebSocket Protocol Type Definitions
|
||||
*/
|
||||
|
||||
import type { WebSocketOpcode, WebSocketCloseCode } from './constants.js';
|
||||
|
||||
/**
|
||||
* WebSocket frame header
|
||||
*/
|
||||
export interface IWebSocketFrameHeader {
|
||||
fin: boolean;
|
||||
rsv1: boolean;
|
||||
rsv2: boolean;
|
||||
rsv3: boolean;
|
||||
opcode: WebSocketOpcode;
|
||||
masked: boolean;
|
||||
payloadLength: number;
|
||||
maskingKey?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket frame
|
||||
*/
|
||||
export interface IWebSocketFrame {
|
||||
header: IWebSocketFrameHeader;
|
||||
payload: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket close frame payload
|
||||
*/
|
||||
export interface IWebSocketClosePayload {
|
||||
code: WebSocketCloseCode;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket handshake request headers
|
||||
*/
|
||||
export interface IWebSocketHandshakeHeaders {
|
||||
upgrade: string;
|
||||
connection: string;
|
||||
'sec-websocket-key': string;
|
||||
'sec-websocket-version': string;
|
||||
'sec-websocket-protocol'?: string;
|
||||
'sec-websocket-extensions'?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for WebSocket raw data (matching ws library)
|
||||
*/
|
||||
export type RawData = Buffer | ArrayBuffer | Buffer[] | any;
|
98
ts/protocols/websocket/utils.ts
Normal file
98
ts/protocols/websocket/utils.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* WebSocket Protocol Utilities
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { WEBSOCKET_MAGIC_STRING } from './constants.js';
|
||||
import type { RawData } from './types.js';
|
||||
|
||||
/**
|
||||
* Get the length of a WebSocket message regardless of its type
|
||||
* (handles all possible WebSocket message data types)
|
||||
*/
|
||||
export function getMessageSize(data: RawData): number {
|
||||
if (typeof data === 'string') {
|
||||
// For string data, get the byte length
|
||||
return Buffer.from(data, 'utf8').length;
|
||||
} else if (data instanceof Buffer) {
|
||||
// For Node.js Buffer
|
||||
return data.length;
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
// For ArrayBuffer
|
||||
return data.byteLength;
|
||||
} else if (Array.isArray(data)) {
|
||||
// For array of buffers, sum their lengths
|
||||
return data.reduce((sum, chunk) => {
|
||||
if (chunk instanceof Buffer) {
|
||||
return sum + chunk.length;
|
||||
} else if (chunk instanceof ArrayBuffer) {
|
||||
return sum + chunk.byteLength;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
} else {
|
||||
// For other types, try to determine the size or return 0
|
||||
try {
|
||||
return Buffer.from(data).length;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any raw WebSocket data to Buffer for consistent handling
|
||||
*/
|
||||
export function toBuffer(data: RawData): Buffer {
|
||||
if (typeof data === 'string') {
|
||||
return Buffer.from(data, 'utf8');
|
||||
} else if (data instanceof Buffer) {
|
||||
return data;
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data);
|
||||
} else if (Array.isArray(data)) {
|
||||
// For array of buffers, concatenate them
|
||||
return Buffer.concat(data.map(chunk => {
|
||||
if (chunk instanceof Buffer) {
|
||||
return chunk;
|
||||
} else if (chunk instanceof ArrayBuffer) {
|
||||
return Buffer.from(chunk);
|
||||
}
|
||||
return Buffer.from(chunk);
|
||||
}));
|
||||
} else {
|
||||
// For other types, try to convert to Buffer or return empty Buffer
|
||||
try {
|
||||
return Buffer.from(data);
|
||||
} catch (e) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate WebSocket accept key from client key
|
||||
*/
|
||||
export function generateAcceptKey(clientKey: string): string {
|
||||
const hash = crypto.createHash('sha1');
|
||||
hash.update(clientKey + WEBSOCKET_MAGIC_STRING);
|
||||
return hash.digest('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate WebSocket upgrade request
|
||||
*/
|
||||
export function isWebSocketUpgrade(headers: Record<string, string>): boolean {
|
||||
const upgrade = headers['upgrade'];
|
||||
const connection = headers['connection'];
|
||||
|
||||
return upgrade?.toLowerCase() === 'websocket' &&
|
||||
connection?.toLowerCase().includes('upgrade');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random WebSocket key for client handshake
|
||||
*/
|
||||
export function generateWebSocketKey(): string {
|
||||
return crypto.randomBytes(16).toString('base64');
|
||||
}
|
@@ -35,7 +35,7 @@ export class HttpProxy implements IMetricsTracker {
|
||||
public routes: IRouteConfig[] = [];
|
||||
|
||||
// Server instances (HTTP/2 with HTTP/1 fallback)
|
||||
public httpsServer: any;
|
||||
public httpsServer: plugins.http2.Http2SecureServer;
|
||||
|
||||
// Core components
|
||||
private certificateManager: CertificateManager;
|
||||
@@ -196,8 +196,9 @@ export class HttpProxy implements IMetricsTracker {
|
||||
this.options.keepAliveTimeout = keepAliveTimeout;
|
||||
|
||||
if (this.httpsServer) {
|
||||
this.httpsServer.keepAliveTimeout = keepAliveTimeout;
|
||||
this.logger.info(`Updated keep-alive timeout to ${keepAliveTimeout}ms`);
|
||||
// HTTP/2 servers have setTimeout method for timeout management
|
||||
this.httpsServer.setTimeout(keepAliveTimeout);
|
||||
this.logger.info(`Updated server timeout to ${keepAliveTimeout}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,18 +250,19 @@ export class HttpProxy implements IMetricsTracker {
|
||||
this.setupConnectionTracking();
|
||||
|
||||
// Handle incoming HTTP/2 streams
|
||||
this.httpsServer.on('stream', (stream: any, headers: any) => {
|
||||
this.httpsServer.on('stream', (stream: plugins.http2.ServerHttp2Stream, headers: plugins.http2.IncomingHttpHeaders) => {
|
||||
this.requestHandler.handleHttp2(stream, headers);
|
||||
});
|
||||
// Handle HTTP/1.x fallback requests
|
||||
this.httpsServer.on('request', (req: any, res: any) => {
|
||||
this.httpsServer.on('request', (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => {
|
||||
this.requestHandler.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Share server with certificate manager for dynamic contexts
|
||||
this.certificateManager.setHttpsServer(this.httpsServer);
|
||||
// Cast to https.Server as Http2SecureServer is compatible for certificate contexts
|
||||
this.certificateManager.setHttpsServer(this.httpsServer as any);
|
||||
// Setup WebSocket support on HTTP/1 fallback
|
||||
this.webSocketHandler.initialize(this.httpsServer);
|
||||
this.webSocketHandler.initialize(this.httpsServer as any);
|
||||
// Start metrics logging
|
||||
this.setupMetricsCollection();
|
||||
// Start periodic connection pool cleanup
|
||||
@@ -275,6 +277,21 @@ export class HttpProxy implements IMetricsTracker {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an address is a loopback address (IPv4 or IPv6)
|
||||
*/
|
||||
private isLoopback(addr?: string): boolean {
|
||||
if (!addr) return false;
|
||||
// Check for IPv6 loopback
|
||||
if (addr === '::1') return true;
|
||||
// Handle IPv6-mapped IPv4 addresses
|
||||
if (addr.startsWith('::ffff:')) {
|
||||
addr = addr.substring(7);
|
||||
}
|
||||
// Check for IPv4 loopback range (127.0.0.0/8)
|
||||
return addr.startsWith('127.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up tracking of TCP connections
|
||||
*/
|
||||
@@ -282,30 +299,47 @@ export class HttpProxy implements IMetricsTracker {
|
||||
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
|
||||
let remoteIP = connection.remoteAddress || '';
|
||||
const connectionId = Math.random().toString(36).substring(2, 15);
|
||||
const isFromSmartProxy = this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1');
|
||||
const isFromSmartProxy = this.options.portProxyIntegration && this.isLoopback(connection.remoteAddress);
|
||||
|
||||
// For SmartProxy connections, wait for CLIENT_IP header
|
||||
if (isFromSmartProxy) {
|
||||
let headerBuffer = Buffer.alloc(0);
|
||||
let headerParsed = false;
|
||||
const MAX_PREFACE = 256; // bytes - prevent DoS
|
||||
const HEADER_TIMEOUT_MS = 500; // timeout for header parsing
|
||||
let headerTimer: NodeJS.Timeout | undefined;
|
||||
let buffered = Buffer.alloc(0);
|
||||
|
||||
const parseHeader = (data: Buffer) => {
|
||||
if (headerParsed) return data;
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
|
||||
headerBuffer = Buffer.concat([headerBuffer, data]);
|
||||
const headerStr = headerBuffer.toString();
|
||||
const headerEnd = headerStr.indexOf('\r\n');
|
||||
// Prevent unbounded growth
|
||||
if (buffered.length > MAX_PREFACE) {
|
||||
connection.removeListener('data', onData);
|
||||
if (headerTimer) clearTimeout(headerTimer);
|
||||
this.logger.warn('Header preface too large, closing connection');
|
||||
connection.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (headerEnd !== -1) {
|
||||
const header = headerStr.substring(0, headerEnd);
|
||||
if (header.startsWith('CLIENT_IP:')) {
|
||||
remoteIP = header.substring(10); // Extract IP after "CLIENT_IP:"
|
||||
const idx = buffered.indexOf('\r\n');
|
||||
if (idx !== -1) {
|
||||
const headerLine = buffered.slice(0, idx).toString('utf8');
|
||||
if (headerLine.startsWith('CLIENT_IP:')) {
|
||||
remoteIP = headerLine.substring(10).trim();
|
||||
this.logger.debug(`Extracted client IP from SmartProxy: ${remoteIP}`);
|
||||
}
|
||||
headerParsed = true;
|
||||
|
||||
// Clean up listener and timer
|
||||
connection.removeListener('data', onData);
|
||||
if (headerTimer) clearTimeout(headerTimer);
|
||||
|
||||
// Put remaining data back onto the stream
|
||||
const remaining = buffered.slice(idx + 2);
|
||||
if (remaining.length > 0) {
|
||||
connection.unshift(remaining);
|
||||
}
|
||||
|
||||
// Store the real IP on the connection
|
||||
(connection as any)._realRemoteIP = remoteIP;
|
||||
connection._realRemoteIP = remoteIP;
|
||||
|
||||
// Validate the real IP
|
||||
const ipValidation = this.securityManager.validateIP(remoteIP);
|
||||
@@ -318,35 +352,26 @@ export class HttpProxy implements IMetricsTracker {
|
||||
remoteIP
|
||||
);
|
||||
connection.destroy();
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Track connection by real IP
|
||||
this.securityManager.trackConnectionByIP(remoteIP, connectionId);
|
||||
|
||||
// Return remaining data after header
|
||||
return headerBuffer.slice(headerEnd + 2);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Override the first data handler to parse header
|
||||
const originalEmit = connection.emit;
|
||||
connection.emit = function(event: string, ...args: any[]) {
|
||||
if (event === 'data' && !headerParsed) {
|
||||
const remaining = parseHeader(args[0]);
|
||||
if (remaining && remaining.length > 0) {
|
||||
// Call original emit with remaining data
|
||||
return originalEmit.apply(connection, ['data', remaining]);
|
||||
} else if (headerParsed) {
|
||||
// Header parsed but no remaining data
|
||||
return true;
|
||||
}
|
||||
// Header not complete yet, suppress this data event
|
||||
return true;
|
||||
}
|
||||
return originalEmit.apply(connection, [event, ...args]);
|
||||
} as any;
|
||||
// Set timeout for header parsing
|
||||
headerTimer = setTimeout(() => {
|
||||
connection.removeListener('data', onData);
|
||||
this.logger.warn('Header parsing timeout, closing connection');
|
||||
connection.destroy();
|
||||
}, HEADER_TIMEOUT_MS);
|
||||
|
||||
// Unref the timer so it doesn't keep the process alive
|
||||
if (headerTimer.unref) headerTimer.unref();
|
||||
|
||||
// Use prependListener to get data first
|
||||
connection.prependListener('data', onData);
|
||||
} else {
|
||||
// Direct connection - validate immediately
|
||||
const ipValidation = this.securityManager.validateIP(remoteIP);
|
||||
@@ -385,8 +410,8 @@ export class HttpProxy implements IMetricsTracker {
|
||||
}
|
||||
|
||||
// Add connection to tracking with metadata
|
||||
(connection as any)._connectionId = connectionId;
|
||||
(connection as any)._remoteIP = remoteIP;
|
||||
connection._connectionId = connectionId;
|
||||
connection._remoteIP = remoteIP;
|
||||
this.socketMap.add(connection);
|
||||
this.connectedClients = this.socketMap.getArray().length;
|
||||
|
||||
@@ -409,8 +434,8 @@ export class HttpProxy implements IMetricsTracker {
|
||||
this.connectedClients = this.socketMap.getArray().length;
|
||||
|
||||
// Remove IP tracking
|
||||
const connId = (connection as any)._connectionId;
|
||||
const connIP = (connection as any)._realRemoteIP || (connection as any)._remoteIP;
|
||||
const connId = connection._connectionId;
|
||||
const connIP = connection._realRemoteIP || connection._remoteIP;
|
||||
if (connId && connIP) {
|
||||
this.securityManager.removeConnectionByIP(connIP, connId);
|
||||
}
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
// Import from protocols for consistent status codes
|
||||
import { HttpStatus as ProtocolHttpStatus, getStatusText as getProtocolStatusText } from '../../../protocols/http/index.js';
|
||||
|
||||
/**
|
||||
* HTTP-specific event types
|
||||
@@ -10,34 +12,33 @@ export enum HttpEvents {
|
||||
REQUEST_ERROR = 'request-error',
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP status codes as an enum for better type safety
|
||||
*/
|
||||
export enum HttpStatus {
|
||||
OK = 200,
|
||||
MOVED_PERMANENTLY = 301,
|
||||
FOUND = 302,
|
||||
TEMPORARY_REDIRECT = 307,
|
||||
PERMANENT_REDIRECT = 308,
|
||||
BAD_REQUEST = 400,
|
||||
UNAUTHORIZED = 401,
|
||||
FORBIDDEN = 403,
|
||||
NOT_FOUND = 404,
|
||||
METHOD_NOT_ALLOWED = 405,
|
||||
REQUEST_TIMEOUT = 408,
|
||||
TOO_MANY_REQUESTS = 429,
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
NOT_IMPLEMENTED = 501,
|
||||
BAD_GATEWAY = 502,
|
||||
SERVICE_UNAVAILABLE = 503,
|
||||
GATEWAY_TIMEOUT = 504,
|
||||
}
|
||||
|
||||
// Re-export for backward compatibility with subset of commonly used codes
|
||||
export const HttpStatus = {
|
||||
OK: ProtocolHttpStatus.OK,
|
||||
MOVED_PERMANENTLY: ProtocolHttpStatus.MOVED_PERMANENTLY,
|
||||
FOUND: ProtocolHttpStatus.FOUND,
|
||||
TEMPORARY_REDIRECT: ProtocolHttpStatus.TEMPORARY_REDIRECT,
|
||||
PERMANENT_REDIRECT: ProtocolHttpStatus.PERMANENT_REDIRECT,
|
||||
BAD_REQUEST: ProtocolHttpStatus.BAD_REQUEST,
|
||||
UNAUTHORIZED: ProtocolHttpStatus.UNAUTHORIZED,
|
||||
FORBIDDEN: ProtocolHttpStatus.FORBIDDEN,
|
||||
NOT_FOUND: ProtocolHttpStatus.NOT_FOUND,
|
||||
METHOD_NOT_ALLOWED: ProtocolHttpStatus.METHOD_NOT_ALLOWED,
|
||||
REQUEST_TIMEOUT: ProtocolHttpStatus.REQUEST_TIMEOUT,
|
||||
TOO_MANY_REQUESTS: ProtocolHttpStatus.TOO_MANY_REQUESTS,
|
||||
INTERNAL_SERVER_ERROR: ProtocolHttpStatus.INTERNAL_SERVER_ERROR,
|
||||
NOT_IMPLEMENTED: ProtocolHttpStatus.NOT_IMPLEMENTED,
|
||||
BAD_GATEWAY: ProtocolHttpStatus.BAD_GATEWAY,
|
||||
SERVICE_UNAVAILABLE: ProtocolHttpStatus.SERVICE_UNAVAILABLE,
|
||||
GATEWAY_TIMEOUT: ProtocolHttpStatus.GATEWAY_TIMEOUT,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Base error class for HTTP-related errors
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
constructor(message: string, public readonly statusCode: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
constructor(message: string, public readonly statusCode: number = HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
@@ -61,7 +62,7 @@ export class CertificateError extends HttpError {
|
||||
* Error related to server operations
|
||||
*/
|
||||
export class ServerError extends HttpError {
|
||||
constructor(message: string, public readonly code?: string, statusCode: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
constructor(message: string, public readonly code?: string, statusCode: number = HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
super(message, statusCode);
|
||||
this.name = 'ServerError';
|
||||
}
|
||||
@@ -93,7 +94,7 @@ export class NotFoundError extends HttpError {
|
||||
export interface IRedirectConfig {
|
||||
source: string; // Source path or pattern
|
||||
destination: string; // Destination URL
|
||||
type: HttpStatus; // Redirect status code
|
||||
type: number; // Redirect status code
|
||||
preserveQuery?: boolean; // Whether to preserve query parameters
|
||||
}
|
||||
|
||||
@@ -115,30 +116,12 @@ export interface IRouterConfig {
|
||||
*/
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
|
||||
|
||||
|
||||
/**
|
||||
* Helper function to get HTTP status text
|
||||
*/
|
||||
export function getStatusText(status: HttpStatus): string {
|
||||
const statusTexts: Record<HttpStatus, string> = {
|
||||
[HttpStatus.OK]: 'OK',
|
||||
[HttpStatus.MOVED_PERMANENTLY]: 'Moved Permanently',
|
||||
[HttpStatus.FOUND]: 'Found',
|
||||
[HttpStatus.TEMPORARY_REDIRECT]: 'Temporary Redirect',
|
||||
[HttpStatus.PERMANENT_REDIRECT]: 'Permanent Redirect',
|
||||
[HttpStatus.BAD_REQUEST]: 'Bad Request',
|
||||
[HttpStatus.UNAUTHORIZED]: 'Unauthorized',
|
||||
[HttpStatus.FORBIDDEN]: 'Forbidden',
|
||||
[HttpStatus.NOT_FOUND]: 'Not Found',
|
||||
[HttpStatus.METHOD_NOT_ALLOWED]: 'Method Not Allowed',
|
||||
[HttpStatus.REQUEST_TIMEOUT]: 'Request Timeout',
|
||||
[HttpStatus.TOO_MANY_REQUESTS]: 'Too Many Requests',
|
||||
[HttpStatus.INTERNAL_SERVER_ERROR]: 'Internal Server Error',
|
||||
[HttpStatus.NOT_IMPLEMENTED]: 'Not Implemented',
|
||||
[HttpStatus.BAD_GATEWAY]: 'Bad Gateway',
|
||||
[HttpStatus.SERVICE_UNAVAILABLE]: 'Service Unavailable',
|
||||
[HttpStatus.GATEWAY_TIMEOUT]: 'Gateway Timeout',
|
||||
};
|
||||
return statusTexts[status] || 'Unknown';
|
||||
export function getStatusText(status: number): string {
|
||||
return getProtocolStatusText(status as ProtocolHttpStatus);
|
||||
}
|
||||
|
||||
// Legacy interfaces for backward compatibility
|
||||
|
@@ -110,6 +110,14 @@ export class SmartCertManager {
|
||||
this.certProvisionFallbackToAcme = fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the routes array to keep it in sync with SmartProxy
|
||||
* This prevents stale route data when adding/removing challenge routes
|
||||
*/
|
||||
public setRoutes(routes: IRouteConfig[]): void {
|
||||
this.routes = routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for updating routes (used for challenge routes)
|
||||
*/
|
||||
@@ -391,15 +399,14 @@ export class SmartCertManager {
|
||||
}
|
||||
|
||||
// Parse certificate to get dates
|
||||
// Parse certificate to get dates - for now just use defaults
|
||||
// TODO: Implement actual certificate parsing if needed
|
||||
const certInfo = { validTo: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), validFrom: new Date() };
|
||||
const expiryDate = this.extractExpiryDate(cert);
|
||||
const issueDate = new Date(); // Current date as issue date
|
||||
|
||||
const certData: ICertificateData = {
|
||||
cert,
|
||||
key,
|
||||
expiryDate: certInfo.validTo,
|
||||
issueDate: certInfo.validFrom,
|
||||
expiryDate,
|
||||
issueDate,
|
||||
source: 'static'
|
||||
};
|
||||
|
||||
@@ -573,6 +580,8 @@ export class SmartCertManager {
|
||||
// With the re-ordering of start(), port binding should already be done
|
||||
// This updateRoutes call should just add the route without binding again
|
||||
await this.updateRoutesCallback(updatedRoutes);
|
||||
// Keep local routes in sync after updating
|
||||
this.routes = updatedRoutes;
|
||||
this.challengeRouteActive = true;
|
||||
|
||||
// Register with state manager
|
||||
@@ -662,6 +671,8 @@ export class SmartCertManager {
|
||||
try {
|
||||
const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
|
||||
await this.updateRoutesCallback(filteredRoutes);
|
||||
// Keep local routes in sync after updating
|
||||
this.routes = filteredRoutes;
|
||||
this.challengeRouteActive = false;
|
||||
|
||||
// Remove from state manager
|
||||
@@ -697,6 +708,11 @@ export class SmartCertManager {
|
||||
this.checkAndRenewCertificates();
|
||||
}, 12 * 60 * 60 * 1000);
|
||||
|
||||
// Unref the timer so it doesn't keep the process alive
|
||||
if (this.renewalTimer.unref) {
|
||||
this.renewalTimer.unref();
|
||||
}
|
||||
|
||||
// Also do an immediate check
|
||||
this.checkAndRenewCertificates();
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@ import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js'
|
||||
import { LifecycleComponent } from '../../core/utils/lifecycle-component.js';
|
||||
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
||||
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
||||
import { ProtocolDetector } from '../../detection/index.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
|
||||
/**
|
||||
@@ -323,6 +324,18 @@ export class ConnectionManager extends LifecycleComponent {
|
||||
this.smartProxy.metricsCollector.removeConnection(record.id);
|
||||
}
|
||||
|
||||
// Clean up protocol detection fragments
|
||||
const context = ProtocolDetector.createConnectionContext({
|
||||
sourceIp: record.remoteIP,
|
||||
sourcePort: record.incoming?.remotePort || 0,
|
||||
destIp: record.incoming?.localAddress || '',
|
||||
destPort: record.localPort,
|
||||
socketId: record.id
|
||||
});
|
||||
|
||||
// Clean up any pending detection fragments for this connection
|
||||
ProtocolDetector.cleanupConnection(context);
|
||||
|
||||
if (record.cleanupTimer) {
|
||||
clearTimeout(record.cleanupTimer);
|
||||
record.cleanupTimer = undefined;
|
||||
|
@@ -20,6 +20,7 @@ export { HttpProxyBridge } from './http-proxy-bridge.js';
|
||||
export { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
export { RouteConnectionHandler } from './route-connection-handler.js';
|
||||
export { NFTablesManager } from './nftables-manager.js';
|
||||
export { RouteOrchestrator } from './route-orchestrator.js';
|
||||
|
||||
// Export certificate management
|
||||
export { SmartCertManager } from './certificate-manager.js';
|
||||
|
@@ -33,6 +33,11 @@ export class MetricsCollector implements IMetrics {
|
||||
private readonly sampleIntervalMs: number;
|
||||
private readonly retentionSeconds: number;
|
||||
|
||||
// Track connection durations for percentile calculations
|
||||
private connectionDurations: number[] = [];
|
||||
private bytesInArray: number[] = [];
|
||||
private bytesOutArray: number[] = [];
|
||||
|
||||
constructor(
|
||||
private smartProxy: SmartProxy,
|
||||
config?: {
|
||||
@@ -211,21 +216,39 @@ export class MetricsCollector implements IMetrics {
|
||||
}
|
||||
};
|
||||
|
||||
// Percentiles implementation (placeholder for now)
|
||||
// Helper to calculate percentiles from an array
|
||||
private calculatePercentile(arr: number[], percentile: number): number {
|
||||
if (arr.length === 0) return 0;
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const index = Math.floor((sorted.length - 1) * percentile);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
// Percentiles implementation
|
||||
public percentiles = {
|
||||
connectionDuration: (): { p50: number; p95: number; p99: number } => {
|
||||
// TODO: Implement percentile calculations
|
||||
return { p50: 0, p95: 0, p99: 0 };
|
||||
return {
|
||||
p50: this.calculatePercentile(this.connectionDurations, 0.5),
|
||||
p95: this.calculatePercentile(this.connectionDurations, 0.95),
|
||||
p99: this.calculatePercentile(this.connectionDurations, 0.99)
|
||||
};
|
||||
},
|
||||
|
||||
bytesTransferred: (): {
|
||||
in: { p50: number; p95: number; p99: number };
|
||||
out: { p50: number; p95: number; p99: number };
|
||||
} => {
|
||||
// TODO: Implement percentile calculations
|
||||
return {
|
||||
in: { p50: 0, p95: 0, p99: 0 },
|
||||
out: { p50: 0, p95: 0, p99: 0 }
|
||||
in: {
|
||||
p50: this.calculatePercentile(this.bytesInArray, 0.5),
|
||||
p95: this.calculatePercentile(this.bytesInArray, 0.95),
|
||||
p99: this.calculatePercentile(this.bytesInArray, 0.99)
|
||||
},
|
||||
out: {
|
||||
p50: this.calculatePercentile(this.bytesOutArray, 0.5),
|
||||
p95: this.calculatePercentile(this.bytesOutArray, 0.95),
|
||||
p99: this.calculatePercentile(this.bytesOutArray, 0.99)
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -298,6 +321,30 @@ export class MetricsCollector implements IMetrics {
|
||||
* Clean up tracking for a closed connection
|
||||
*/
|
||||
public removeConnection(connectionId: string): void {
|
||||
const tracker = this.connectionByteTrackers.get(connectionId);
|
||||
if (tracker) {
|
||||
// Calculate connection duration
|
||||
const duration = Date.now() - tracker.startTime;
|
||||
|
||||
// Add to arrays for percentile calculations (bounded to prevent memory growth)
|
||||
const MAX_SAMPLES = 5000;
|
||||
|
||||
this.connectionDurations.push(duration);
|
||||
if (this.connectionDurations.length > MAX_SAMPLES) {
|
||||
this.connectionDurations.shift();
|
||||
}
|
||||
|
||||
this.bytesInArray.push(tracker.bytesIn);
|
||||
if (this.bytesInArray.length > MAX_SAMPLES) {
|
||||
this.bytesInArray.shift();
|
||||
}
|
||||
|
||||
this.bytesOutArray.push(tracker.bytesOut);
|
||||
if (this.bytesOutArray.length > MAX_SAMPLES) {
|
||||
this.bytesOutArray.shift();
|
||||
}
|
||||
}
|
||||
|
||||
this.connectionByteTrackers.delete(connectionId);
|
||||
}
|
||||
|
||||
@@ -349,6 +396,11 @@ export class MetricsCollector implements IMetrics {
|
||||
}
|
||||
}, this.sampleIntervalMs);
|
||||
|
||||
// Unref the interval so it doesn't keep the process alive
|
||||
if (this.samplingInterval.unref) {
|
||||
this.samplingInterval.unref();
|
||||
}
|
||||
|
||||
// Subscribe to new connections
|
||||
this.connectionSubscription = this.smartProxy.routeConnectionHandler.newConnectionSubject.subscribe({
|
||||
next: (record) => {
|
||||
|
@@ -195,4 +195,11 @@ export interface IConnectionRecord {
|
||||
|
||||
// NFTables tracking
|
||||
nftablesHandled?: boolean; // Whether this connection is being handled by NFTables at kernel level
|
||||
|
||||
// HTTP-specific information (extracted from protocol detection)
|
||||
httpInfo?: {
|
||||
method?: string;
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
}
|
@@ -10,6 +10,7 @@ import { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
||||
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
|
||||
import { ProxyProtocolParser } from '../../core/utils/proxy-protocol.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
import { ProtocolDetector } from '../../detection/index.js';
|
||||
|
||||
/**
|
||||
* Handles new connection processing and setup logic with support for route-based configuration
|
||||
@@ -301,11 +302,27 @@ export class RouteConnectionHandler {
|
||||
});
|
||||
|
||||
// Handler for processing initial data (after potential PROXY protocol)
|
||||
const processInitialData = (chunk: Buffer) => {
|
||||
const processInitialData = async (chunk: Buffer) => {
|
||||
// Create connection context for protocol detection
|
||||
const context = ProtocolDetector.createConnectionContext({
|
||||
sourceIp: record.remoteIP,
|
||||
sourcePort: socket.remotePort || 0,
|
||||
destIp: socket.localAddress || '',
|
||||
destPort: socket.localPort || 0,
|
||||
socketId: record.id
|
||||
});
|
||||
|
||||
const detectionResult = await ProtocolDetector.detectWithContext(
|
||||
chunk,
|
||||
context,
|
||||
{ extractFullHeaders: false } // Only extract essential info for routing
|
||||
);
|
||||
|
||||
// Block non-TLS connections on port 443
|
||||
if (!this.smartProxy.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
|
||||
logger.log('warn', `Non-TLS connection ${connectionId} detected on port 443. Terminating connection - only TLS traffic is allowed on standard HTTPS port.`, {
|
||||
connectionId,
|
||||
if (localPort === 443 && detectionResult.protocol !== 'tls') {
|
||||
logger.log('warn', `Non-TLS connection ${record.id} detected on port 443. Terminating connection - only TLS traffic is allowed on standard HTTPS port.`, {
|
||||
connectionId: record.id,
|
||||
detectedProtocol: detectionResult.protocol,
|
||||
message: 'Terminating connection - only TLS traffic is allowed on standard HTTPS port.',
|
||||
component: 'route-handler'
|
||||
});
|
||||
@@ -318,71 +335,78 @@ export class RouteConnectionHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this looks like a TLS handshake
|
||||
// Extract domain and protocol info
|
||||
let serverName = '';
|
||||
if (this.smartProxy.tlsManager.isTlsHandshake(chunk)) {
|
||||
if (detectionResult.protocol === 'tls') {
|
||||
record.isTLS = true;
|
||||
serverName = detectionResult.connectionInfo.domain || '';
|
||||
|
||||
// Check for ClientHello to extract SNI
|
||||
if (this.smartProxy.tlsManager.isClientHello(chunk)) {
|
||||
// Create connection info for SNI extraction
|
||||
const connInfo = {
|
||||
sourceIp: record.remoteIP,
|
||||
sourcePort: socket.remotePort || 0,
|
||||
destIp: socket.localAddress || '',
|
||||
destPort: socket.localPort || 0,
|
||||
};
|
||||
// Lock the connection to the negotiated SNI
|
||||
record.lockedDomain = serverName;
|
||||
|
||||
// Extract SNI
|
||||
serverName = this.smartProxy.tlsManager.extractSNI(chunk, connInfo) || '';
|
||||
|
||||
// Lock the connection to the negotiated SNI
|
||||
record.lockedDomain = serverName;
|
||||
|
||||
// Check if we should reject connections without SNI
|
||||
if (!serverName && this.smartProxy.settings.allowSessionTicket === false) {
|
||||
logger.log('warn', `No SNI detected in TLS ClientHello for connection ${connectionId}; sending TLS alert`, {
|
||||
connectionId,
|
||||
component: 'route-handler'
|
||||
});
|
||||
if (record.incomingTerminationReason === null) {
|
||||
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
|
||||
this.smartProxy.connectionManager.incrementTerminationStat(
|
||||
'incoming',
|
||||
'session_ticket_blocked_no_sni'
|
||||
);
|
||||
}
|
||||
const alert = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x70]);
|
||||
try {
|
||||
// Count the alert bytes being sent
|
||||
record.bytesSent += alert.length;
|
||||
if (this.smartProxy.metricsCollector) {
|
||||
this.smartProxy.metricsCollector.recordBytes(record.id, 0, alert.length);
|
||||
}
|
||||
|
||||
socket.cork();
|
||||
socket.write(alert);
|
||||
socket.uncork();
|
||||
socket.end();
|
||||
} catch {
|
||||
socket.end();
|
||||
}
|
||||
this.smartProxy.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
|
||||
return;
|
||||
// Check if we should reject connections without SNI
|
||||
if (!serverName && this.smartProxy.settings.allowSessionTicket === false) {
|
||||
logger.log('warn', `No SNI detected in TLS ClientHello for connection ${record.id}; sending TLS alert`, {
|
||||
connectionId: record.id,
|
||||
component: 'route-handler'
|
||||
});
|
||||
if (record.incomingTerminationReason === null) {
|
||||
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
|
||||
this.smartProxy.connectionManager.incrementTerminationStat(
|
||||
'incoming',
|
||||
'session_ticket_blocked_no_sni'
|
||||
);
|
||||
}
|
||||
const alert = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x70]);
|
||||
try {
|
||||
// Count the alert bytes being sent
|
||||
record.bytesSent += alert.length;
|
||||
if (this.smartProxy.metricsCollector) {
|
||||
this.smartProxy.metricsCollector.recordBytes(record.id, 0, alert.length);
|
||||
}
|
||||
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
logger.log('info', `TLS connection with SNI`, {
|
||||
connectionId,
|
||||
serverName: serverName || '(empty)',
|
||||
component: 'route-handler'
|
||||
});
|
||||
socket.cork();
|
||||
socket.write(alert);
|
||||
socket.uncork();
|
||||
socket.end();
|
||||
} catch {
|
||||
socket.end();
|
||||
}
|
||||
this.smartProxy.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
logger.log('info', `TLS connection with SNI`, {
|
||||
connectionId: record.id,
|
||||
serverName: serverName || '(empty)',
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
} else if (detectionResult.protocol === 'http') {
|
||||
// For HTTP, extract domain from Host header
|
||||
serverName = detectionResult.connectionInfo.domain || '';
|
||||
|
||||
// Store HTTP-specific info for later use
|
||||
record.httpInfo = {
|
||||
method: detectionResult.connectionInfo.method,
|
||||
path: detectionResult.connectionInfo.path,
|
||||
headers: detectionResult.connectionInfo.headers
|
||||
};
|
||||
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
logger.log('info', `HTTP connection detected`, {
|
||||
connectionId: record.id,
|
||||
domain: serverName || '(no host header)',
|
||||
method: detectionResult.connectionInfo.method,
|
||||
path: detectionResult.connectionInfo.path,
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find the appropriate route for this connection
|
||||
this.routeConnection(socket, record, serverName, chunk);
|
||||
this.routeConnection(socket, record, serverName, chunk, detectionResult);
|
||||
};
|
||||
|
||||
// First data handler to capture initial TLS handshake or PROXY protocol
|
||||
@@ -454,7 +478,8 @@ export class RouteConnectionHandler {
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
serverName: string,
|
||||
initialChunk?: Buffer
|
||||
initialChunk?: Buffer,
|
||||
detectionResult?: any // Using any temporarily to avoid circular dependency issues
|
||||
): void {
|
||||
const connectionId = record.id;
|
||||
const localPort = record.localPort;
|
||||
@@ -635,7 +660,7 @@ export class RouteConnectionHandler {
|
||||
// Handle the route based on its action type
|
||||
switch (route.action.type) {
|
||||
case 'forward':
|
||||
return this.handleForwardAction(socket, record, route, initialChunk);
|
||||
return this.handleForwardAction(socket, record, route, initialChunk, detectionResult);
|
||||
|
||||
case 'socket-handler':
|
||||
logger.log('info', `Handling socket-handler action for route ${route.name}`, {
|
||||
@@ -738,7 +763,8 @@ export class RouteConnectionHandler {
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
route: IRouteConfig,
|
||||
initialChunk?: Buffer
|
||||
initialChunk?: Buffer,
|
||||
detectionResult?: any // Using any temporarily to avoid circular dependency issues
|
||||
): void {
|
||||
const connectionId = record.id;
|
||||
const action = route.action as IRouteAction;
|
||||
@@ -819,14 +845,11 @@ export class RouteConnectionHandler {
|
||||
// Create context for target selection
|
||||
const targetSelectionContext = {
|
||||
port: record.localPort,
|
||||
path: undefined, // Will be populated from HTTP headers if available
|
||||
headers: undefined, // Will be populated from HTTP headers if available
|
||||
method: undefined // Will be populated from HTTP headers if available
|
||||
path: record.httpInfo?.path,
|
||||
headers: record.httpInfo?.headers,
|
||||
method: record.httpInfo?.method
|
||||
};
|
||||
|
||||
// TODO: Extract path, headers, and method from initialChunk if it's HTTP
|
||||
// For now, we'll select based on port only
|
||||
|
||||
const selectedTarget = this.selectTarget(action.targets, targetSelectionContext);
|
||||
if (!selectedTarget) {
|
||||
logger.log('error', `No matching target found for connection ${connectionId}`, {
|
||||
|
297
ts/proxies/smart-proxy/route-orchestrator.ts
Normal file
297
ts/proxies/smart-proxy/route-orchestrator.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { logger } from '../../core/utils/logger.js';
|
||||
import type { IRouteConfig } from './models/route-types.js';
|
||||
import type { ILogger } from '../http-proxy/models/types.js';
|
||||
import { RouteValidator } from './utils/route-validator.js';
|
||||
import { Mutex } from './utils/mutex.js';
|
||||
import type { PortManager } from './port-manager.js';
|
||||
import type { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
import type { HttpProxyBridge } from './http-proxy-bridge.js';
|
||||
import type { NFTablesManager } from './nftables-manager.js';
|
||||
import type { SmartCertManager } from './certificate-manager.js';
|
||||
|
||||
/**
|
||||
* Orchestrates route updates and coordination between components
|
||||
* Extracted from SmartProxy to reduce class complexity
|
||||
*/
|
||||
export class RouteOrchestrator {
|
||||
private routeUpdateLock: Mutex;
|
||||
private portManager: PortManager;
|
||||
private routeManager: RouteManager;
|
||||
private httpProxyBridge: HttpProxyBridge;
|
||||
private nftablesManager: NFTablesManager;
|
||||
private certManager: SmartCertManager | null = null;
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(
|
||||
portManager: PortManager,
|
||||
routeManager: RouteManager,
|
||||
httpProxyBridge: HttpProxyBridge,
|
||||
nftablesManager: NFTablesManager,
|
||||
certManager: SmartCertManager | null,
|
||||
logger: ILogger
|
||||
) {
|
||||
this.portManager = portManager;
|
||||
this.routeManager = routeManager;
|
||||
this.httpProxyBridge = httpProxyBridge;
|
||||
this.nftablesManager = nftablesManager;
|
||||
this.certManager = certManager;
|
||||
this.logger = logger;
|
||||
this.routeUpdateLock = new Mutex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update certificate manager reference
|
||||
*/
|
||||
public setCertManager(certManager: SmartCertManager | null): void {
|
||||
this.certManager = certManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get certificate manager reference
|
||||
*/
|
||||
public getCertManager(): SmartCertManager | null {
|
||||
return this.certManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routes with validation and coordination
|
||||
*/
|
||||
public async updateRoutes(
|
||||
oldRoutes: IRouteConfig[],
|
||||
newRoutes: IRouteConfig[],
|
||||
options: {
|
||||
acmePort?: number;
|
||||
acmeOptions?: any;
|
||||
acmeState?: any;
|
||||
globalChallengeRouteActive?: boolean;
|
||||
createCertificateManager?: (
|
||||
routes: IRouteConfig[],
|
||||
certStore: string,
|
||||
acmeOptions?: any,
|
||||
initialState?: any
|
||||
) => Promise<SmartCertManager>;
|
||||
verifyChallengeRouteRemoved?: () => Promise<void>;
|
||||
} = {}
|
||||
): Promise<{
|
||||
portUsageMap: Map<number, Set<string>>;
|
||||
newChallengeRouteActive: boolean;
|
||||
newCertManager?: SmartCertManager;
|
||||
}> {
|
||||
return this.routeUpdateLock.runExclusive(async () => {
|
||||
// Validate route configurations
|
||||
const validation = RouteValidator.validateRoutes(newRoutes);
|
||||
if (!validation.valid) {
|
||||
RouteValidator.logValidationErrors(validation.errors);
|
||||
throw new Error(`Route validation failed: ${validation.errors.size} route(s) have errors`);
|
||||
}
|
||||
|
||||
// Track port usage before and after updates
|
||||
const oldPortUsage = this.updatePortUsageMap(oldRoutes);
|
||||
const newPortUsage = this.updatePortUsageMap(newRoutes);
|
||||
|
||||
// Get the lists of currently listening ports and new ports needed
|
||||
const currentPorts = new Set(this.portManager.getListeningPorts());
|
||||
const newPortsSet = new Set(newPortUsage.keys());
|
||||
|
||||
// Log the port usage for debugging
|
||||
this.logger.debug(`Current listening ports: ${Array.from(currentPorts).join(', ')}`);
|
||||
this.logger.debug(`Ports needed for new routes: ${Array.from(newPortsSet).join(', ')}`);
|
||||
|
||||
// Find orphaned ports - ports that no longer have any routes
|
||||
const orphanedPorts = this.findOrphanedPorts(oldPortUsage, newPortUsage);
|
||||
|
||||
// Find new ports that need binding (only ports that we aren't already listening on)
|
||||
const newBindingPorts = Array.from(newPortsSet).filter(p => !currentPorts.has(p));
|
||||
|
||||
// Check for ACME challenge port to give it special handling
|
||||
const acmePort = options.acmePort || 80;
|
||||
const acmePortNeeded = newPortsSet.has(acmePort);
|
||||
const acmePortListed = newBindingPorts.includes(acmePort);
|
||||
|
||||
if (acmePortNeeded && acmePortListed) {
|
||||
this.logger.info(`Adding ACME challenge port ${acmePort} to routes`);
|
||||
}
|
||||
|
||||
// Update NFTables routes
|
||||
await this.updateNfTablesRoutes(oldRoutes, newRoutes);
|
||||
|
||||
// Update routes in RouteManager
|
||||
this.routeManager.updateRoutes(newRoutes);
|
||||
|
||||
// Release orphaned ports first to free resources
|
||||
if (orphanedPorts.length > 0) {
|
||||
this.logger.info(`Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`);
|
||||
await this.portManager.removePorts(orphanedPorts);
|
||||
}
|
||||
|
||||
// Add new ports if needed
|
||||
if (newBindingPorts.length > 0) {
|
||||
this.logger.info(`Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`);
|
||||
|
||||
// Handle port binding with improved error recovery
|
||||
try {
|
||||
await this.portManager.addPorts(newBindingPorts);
|
||||
} catch (error) {
|
||||
// Special handling for port binding errors
|
||||
if ((error as any).code === 'EADDRINUSE') {
|
||||
const port = (error as any).port || newBindingPorts[0];
|
||||
const isAcmePort = port === acmePort;
|
||||
|
||||
if (isAcmePort) {
|
||||
this.logger.warn(`Could not bind to ACME challenge port ${port}. It may be in use by another application.`);
|
||||
|
||||
// Re-throw with more helpful message
|
||||
throw new Error(
|
||||
`ACME challenge port ${port} is already in use by another application. ` +
|
||||
`Configure a different port in settings.acme.port (e.g., 8080) or free up port ${port}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw the original error for other cases
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If HttpProxy is initialized, resync the configurations
|
||||
if (this.httpProxyBridge.getHttpProxy()) {
|
||||
await this.httpProxyBridge.syncRoutesToHttpProxy(newRoutes);
|
||||
}
|
||||
|
||||
// Update certificate manager if needed
|
||||
let newCertManager: SmartCertManager | undefined;
|
||||
let newChallengeRouteActive = options.globalChallengeRouteActive || false;
|
||||
|
||||
if (this.certManager && options.createCertificateManager) {
|
||||
const existingAcmeOptions = this.certManager.getAcmeOptions();
|
||||
const existingState = this.certManager.getState();
|
||||
|
||||
// Store global state before stopping
|
||||
newChallengeRouteActive = existingState.challengeRouteActive;
|
||||
|
||||
// Keep certificate manager routes in sync before stopping
|
||||
this.certManager.setRoutes(newRoutes);
|
||||
|
||||
await this.certManager.stop();
|
||||
|
||||
// Verify the challenge route has been properly removed
|
||||
if (options.verifyChallengeRouteRemoved) {
|
||||
await options.verifyChallengeRouteRemoved();
|
||||
}
|
||||
|
||||
// Create new certificate manager with preserved state
|
||||
newCertManager = await options.createCertificateManager(
|
||||
newRoutes,
|
||||
'./certs',
|
||||
existingAcmeOptions,
|
||||
{ challengeRouteActive: newChallengeRouteActive }
|
||||
);
|
||||
|
||||
this.certManager = newCertManager;
|
||||
}
|
||||
|
||||
return {
|
||||
portUsageMap: newPortUsage,
|
||||
newChallengeRouteActive,
|
||||
newCertManager
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update port usage map based on the provided routes
|
||||
*/
|
||||
public updatePortUsageMap(routes: IRouteConfig[]): Map<number, Set<string>> {
|
||||
const portUsage = new Map<number, Set<string>>();
|
||||
|
||||
for (const route of routes) {
|
||||
// Get the ports for this route
|
||||
const portsConfig = Array.isArray(route.match.ports)
|
||||
? route.match.ports
|
||||
: [route.match.ports];
|
||||
|
||||
// Expand port range objects to individual port numbers
|
||||
const expandedPorts: number[] = [];
|
||||
for (const portConfig of portsConfig) {
|
||||
if (typeof portConfig === 'number') {
|
||||
expandedPorts.push(portConfig);
|
||||
} else if (typeof portConfig === 'object' && 'from' in portConfig && 'to' in portConfig) {
|
||||
// Expand the port range
|
||||
for (let p = portConfig.from; p <= portConfig.to; p++) {
|
||||
expandedPorts.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use route name if available, otherwise generate a unique ID
|
||||
const routeName = route.name || `unnamed_${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Add each port to the usage map
|
||||
for (const port of expandedPorts) {
|
||||
if (!portUsage.has(port)) {
|
||||
portUsage.set(port, new Set());
|
||||
}
|
||||
portUsage.get(port)!.add(routeName);
|
||||
}
|
||||
}
|
||||
|
||||
// Log port usage for debugging
|
||||
for (const [port, routes] of portUsage.entries()) {
|
||||
this.logger.debug(`Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`);
|
||||
}
|
||||
|
||||
return portUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ports that have no routes in the new configuration
|
||||
*/
|
||||
private findOrphanedPorts(oldUsage: Map<number, Set<string>>, newUsage: Map<number, Set<string>>): number[] {
|
||||
const orphanedPorts: number[] = [];
|
||||
|
||||
for (const [port, routes] of oldUsage.entries()) {
|
||||
if (!newUsage.has(port) || newUsage.get(port)!.size === 0) {
|
||||
orphanedPorts.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
return orphanedPorts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update NFTables routes
|
||||
*/
|
||||
private async updateNfTablesRoutes(oldRoutes: IRouteConfig[], newRoutes: IRouteConfig[]): Promise<void> {
|
||||
// Get existing routes that use NFTables and update them
|
||||
const oldNfTablesRoutes = oldRoutes.filter(
|
||||
r => r.action.forwardingEngine === 'nftables'
|
||||
);
|
||||
|
||||
const newNfTablesRoutes = newRoutes.filter(
|
||||
r => r.action.forwardingEngine === 'nftables'
|
||||
);
|
||||
|
||||
// Update existing NFTables routes
|
||||
for (const oldRoute of oldNfTablesRoutes) {
|
||||
const newRoute = newNfTablesRoutes.find(r => r.name === oldRoute.name);
|
||||
|
||||
if (!newRoute) {
|
||||
// Route was removed
|
||||
await this.nftablesManager.deprovisionRoute(oldRoute);
|
||||
} else {
|
||||
// Route was updated
|
||||
await this.nftablesManager.updateRoute(oldRoute, newRoute);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new NFTables routes
|
||||
for (const newRoute of newNfTablesRoutes) {
|
||||
const oldRoute = oldNfTablesRoutes.find(r => r.name === newRoute.name);
|
||||
|
||||
if (!oldRoute) {
|
||||
// New route
|
||||
await this.nftablesManager.provisionRoute(newRoute);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -25,6 +25,12 @@ import type { IRouteConfig } from './models/route-types.js';
|
||||
// Import mutex for route update synchronization
|
||||
import { Mutex } from './utils/mutex.js';
|
||||
|
||||
// Import route validator
|
||||
import { RouteValidator } from './utils/route-validator.js';
|
||||
|
||||
// Import route orchestrator for route management
|
||||
import { RouteOrchestrator } from './route-orchestrator.js';
|
||||
|
||||
// Import ACME state manager
|
||||
import { AcmeStateManager } from './acme-state-manager.js';
|
||||
|
||||
@@ -66,12 +72,15 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
|
||||
// Global challenge route tracking
|
||||
private globalChallengeRouteActive: boolean = false;
|
||||
private routeUpdateLock: any = null; // Will be initialized as AsyncMutex
|
||||
private routeUpdateLock: Mutex;
|
||||
public acmeStateManager: AcmeStateManager;
|
||||
|
||||
// Metrics collector
|
||||
public metricsCollector: MetricsCollector;
|
||||
|
||||
// Route orchestrator for managing route updates
|
||||
private routeOrchestrator: RouteOrchestrator;
|
||||
|
||||
// Track port usage across route updates
|
||||
private portUsageMap: Map<number, Set<string>> = new Map();
|
||||
|
||||
@@ -175,6 +184,15 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
error: (message: string, data?: any) => logger.log('error', message, data)
|
||||
};
|
||||
|
||||
// Validate initial routes
|
||||
if (this.settings.routes && this.settings.routes.length > 0) {
|
||||
const validation = RouteValidator.validateRoutes(this.settings.routes);
|
||||
if (!validation.valid) {
|
||||
RouteValidator.logValidationErrors(validation.errors);
|
||||
throw new Error(`Initial route validation failed: ${validation.errors.size} route(s) have errors`);
|
||||
}
|
||||
}
|
||||
|
||||
this.routeManager = new RouteManager({
|
||||
logger: loggerAdapter,
|
||||
enableDetailedLogging: this.settings.enableDetailedLogging,
|
||||
@@ -206,6 +224,16 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
sampleIntervalMs: this.settings.metrics?.sampleIntervalMs,
|
||||
retentionSeconds: this.settings.metrics?.retentionSeconds
|
||||
});
|
||||
|
||||
// Initialize route orchestrator for managing route updates
|
||||
this.routeOrchestrator = new RouteOrchestrator(
|
||||
this.portManager,
|
||||
this.routeManager,
|
||||
this.httpProxyBridge,
|
||||
this.nftablesManager,
|
||||
null, // certManager will be set later
|
||||
loggerAdapter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,8 +382,8 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
// Get listening ports from RouteManager
|
||||
const listeningPorts = this.routeManager.getListeningPorts();
|
||||
|
||||
// Initialize port usage tracking
|
||||
this.portUsageMap = this.updatePortUsageMap(this.settings.routes);
|
||||
// Initialize port usage tracking using RouteOrchestrator
|
||||
this.portUsageMap = this.routeOrchestrator.updatePortUsageMap(this.settings.routes);
|
||||
|
||||
// Log port usage for startup
|
||||
logger.log('info', `SmartProxy starting with ${listeningPorts.length} ports: ${listeningPorts.join(', ')}`, {
|
||||
@@ -516,7 +544,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
logger.log('info', 'All servers closed. Cleaning up active connections...');
|
||||
|
||||
// Clean up all active connections
|
||||
this.connectionManager.clearConnections();
|
||||
await this.connectionManager.clearConnections();
|
||||
|
||||
// Stop HttpProxy
|
||||
await this.httpProxyBridge.stop();
|
||||
@@ -527,6 +555,10 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
// Stop metrics collector
|
||||
this.metricsCollector.stop();
|
||||
|
||||
// Clean up ProtocolDetector singleton
|
||||
const detection = await import('../../detection/index.js');
|
||||
detection.ProtocolDetector.destroy();
|
||||
|
||||
// Flush any pending deduplicated logs
|
||||
connectionLogDeduplicator.flushAll();
|
||||
|
||||
@@ -606,202 +638,46 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
try {
|
||||
logger.log('info', `Updating routes (${newRoutes.length} routes)`, {
|
||||
routeCount: newRoutes.length,
|
||||
component: 'route-manager'
|
||||
component: 'smart-proxy'
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[INFO] Updating routes (${newRoutes.length} routes)`);
|
||||
}
|
||||
|
||||
// Track port usage before and after updates
|
||||
const oldPortUsage = this.updatePortUsageMap(this.settings.routes);
|
||||
const newPortUsage = this.updatePortUsageMap(newRoutes);
|
||||
|
||||
// Get the lists of currently listening ports and new ports needed
|
||||
const currentPorts = new Set(this.portManager.getListeningPorts());
|
||||
const newPortsSet = new Set(newPortUsage.keys());
|
||||
|
||||
// Log the port usage for debugging
|
||||
try {
|
||||
logger.log('debug', `Current listening ports: ${Array.from(currentPorts).join(', ')}`, {
|
||||
ports: Array.from(currentPorts),
|
||||
component: 'smart-proxy'
|
||||
});
|
||||
|
||||
logger.log('debug', `Ports needed for new routes: ${Array.from(newPortsSet).join(', ')}`, {
|
||||
ports: Array.from(newPortsSet),
|
||||
component: 'smart-proxy'
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[DEBUG] Current listening ports: ${Array.from(currentPorts).join(', ')}`);
|
||||
console.log(`[DEBUG] Ports needed for new routes: ${Array.from(newPortsSet).join(', ')}`);
|
||||
// Update route orchestrator dependencies if cert manager changed
|
||||
if (this.certManager && !this.routeOrchestrator.getCertManager()) {
|
||||
this.routeOrchestrator.setCertManager(this.certManager);
|
||||
}
|
||||
|
||||
// Find orphaned ports - ports that no longer have any routes
|
||||
const orphanedPorts = this.findOrphanedPorts(oldPortUsage, newPortUsage);
|
||||
|
||||
// Find new ports that need binding (only ports that we aren't already listening on)
|
||||
const newBindingPorts = Array.from(newPortsSet).filter(p => !currentPorts.has(p));
|
||||
|
||||
// Check for ACME challenge port to give it special handling
|
||||
const acmePort = this.settings.acme?.port || 80;
|
||||
const acmePortNeeded = newPortsSet.has(acmePort);
|
||||
const acmePortListed = newBindingPorts.includes(acmePort);
|
||||
|
||||
if (acmePortNeeded && acmePortListed) {
|
||||
try {
|
||||
logger.log('info', `Adding ACME challenge port ${acmePort} to routes`, {
|
||||
port: acmePort,
|
||||
component: 'smart-proxy'
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[INFO] Adding ACME challenge port ${acmePort} to routes`);
|
||||
// Delegate the complex route update logic to RouteOrchestrator
|
||||
const updateResult = await this.routeOrchestrator.updateRoutes(
|
||||
this.settings.routes,
|
||||
newRoutes,
|
||||
{
|
||||
acmePort: this.settings.acme?.port || 80,
|
||||
acmeOptions: this.certManager?.getAcmeOptions(),
|
||||
acmeState: this.certManager?.getState(),
|
||||
globalChallengeRouteActive: this.globalChallengeRouteActive,
|
||||
createCertificateManager: this.createCertificateManager.bind(this),
|
||||
verifyChallengeRouteRemoved: this.verifyChallengeRouteRemoved.bind(this)
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing routes that use NFTables and update them
|
||||
const oldNfTablesRoutes = this.settings.routes.filter(
|
||||
r => r.action.forwardingEngine === 'nftables'
|
||||
);
|
||||
|
||||
const newNfTablesRoutes = newRoutes.filter(
|
||||
r => r.action.forwardingEngine === 'nftables'
|
||||
);
|
||||
|
||||
// Update existing NFTables routes
|
||||
for (const oldRoute of oldNfTablesRoutes) {
|
||||
const newRoute = newNfTablesRoutes.find(r => r.name === oldRoute.name);
|
||||
|
||||
if (!newRoute) {
|
||||
// Route was removed
|
||||
await this.nftablesManager.deprovisionRoute(oldRoute);
|
||||
} else {
|
||||
// Route was updated
|
||||
await this.nftablesManager.updateRoute(oldRoute, newRoute);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new NFTables routes
|
||||
for (const newRoute of newNfTablesRoutes) {
|
||||
const oldRoute = oldNfTablesRoutes.find(r => r.name === newRoute.name);
|
||||
|
||||
if (!oldRoute) {
|
||||
// New route
|
||||
await this.nftablesManager.provisionRoute(newRoute);
|
||||
}
|
||||
}
|
||||
|
||||
// Update routes in RouteManager
|
||||
this.routeManager.updateRoutes(newRoutes);
|
||||
|
||||
// Release orphaned ports first to free resources
|
||||
if (orphanedPorts.length > 0) {
|
||||
try {
|
||||
logger.log('info', `Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`, {
|
||||
ports: orphanedPorts,
|
||||
component: 'smart-proxy'
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[INFO] Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`);
|
||||
}
|
||||
await this.portManager.removePorts(orphanedPorts);
|
||||
}
|
||||
|
||||
// Add new ports if needed
|
||||
if (newBindingPorts.length > 0) {
|
||||
try {
|
||||
logger.log('info', `Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`, {
|
||||
ports: newBindingPorts,
|
||||
component: 'smart-proxy'
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[INFO] Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`);
|
||||
}
|
||||
|
||||
// Handle port binding with improved error recovery
|
||||
try {
|
||||
await this.portManager.addPorts(newBindingPorts);
|
||||
} catch (error) {
|
||||
// Special handling for port binding errors
|
||||
// This provides better diagnostics for ACME challenge port conflicts
|
||||
if ((error as any).code === 'EADDRINUSE') {
|
||||
const port = (error as any).port || newBindingPorts[0];
|
||||
const isAcmePort = port === acmePort;
|
||||
|
||||
if (isAcmePort) {
|
||||
try {
|
||||
logger.log('warn', `Could not bind to ACME challenge port ${port}. It may be in use by another application.`, {
|
||||
port,
|
||||
component: 'smart-proxy'
|
||||
});
|
||||
} catch (logError) {
|
||||
console.log(`[WARN] Could not bind to ACME challenge port ${port}. It may be in use by another application.`);
|
||||
}
|
||||
|
||||
// Re-throw with more helpful message
|
||||
throw new Error(
|
||||
`ACME challenge port ${port} is already in use by another application. ` +
|
||||
`Configure a different port in settings.acme.port (e.g., 8080) or free up port ${port}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw the original error for other cases
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update settings with the new routes
|
||||
this.settings.routes = newRoutes;
|
||||
|
||||
// Save the new port usage map for future reference
|
||||
this.portUsageMap = newPortUsage;
|
||||
// Update global state from orchestrator results
|
||||
this.globalChallengeRouteActive = updateResult.newChallengeRouteActive;
|
||||
|
||||
// If HttpProxy is initialized, resync the configurations
|
||||
if (this.httpProxyBridge.getHttpProxy()) {
|
||||
await this.httpProxyBridge.syncRoutesToHttpProxy(newRoutes);
|
||||
}
|
||||
// Update port usage map from orchestrator
|
||||
this.portUsageMap = updateResult.portUsageMap;
|
||||
|
||||
// Update certificate manager with new routes
|
||||
if (this.certManager) {
|
||||
const existingAcmeOptions = this.certManager.getAcmeOptions();
|
||||
const existingState = this.certManager.getState();
|
||||
|
||||
// Store global state before stopping
|
||||
this.globalChallengeRouteActive = existingState.challengeRouteActive;
|
||||
|
||||
// Only stop the cert manager if absolutely necessary
|
||||
// First check if there's an ACME route on the same port already
|
||||
const acmePort = existingAcmeOptions?.port || 80;
|
||||
const acmePortInUse = newPortUsage.has(acmePort) && newPortUsage.get(acmePort)!.size > 0;
|
||||
|
||||
try {
|
||||
logger.log('debug', `ACME port ${acmePort} ${acmePortInUse ? 'is' : 'is not'} already in use by other routes`, {
|
||||
port: acmePort,
|
||||
inUse: acmePortInUse,
|
||||
component: 'smart-proxy'
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[DEBUG] ACME port ${acmePort} ${acmePortInUse ? 'is' : 'is not'} already in use by other routes`);
|
||||
}
|
||||
|
||||
await this.certManager.stop();
|
||||
|
||||
// Verify the challenge route has been properly removed
|
||||
await this.verifyChallengeRouteRemoved();
|
||||
|
||||
// Create new certificate manager with preserved state
|
||||
this.certManager = await this.createCertificateManager(
|
||||
newRoutes,
|
||||
'./certs',
|
||||
existingAcmeOptions,
|
||||
{ challengeRouteActive: this.globalChallengeRouteActive }
|
||||
);
|
||||
// If certificate manager was recreated, update our reference
|
||||
if (updateResult.newCertManager) {
|
||||
this.certManager = updateResult.newCertManager;
|
||||
// Update the orchestrator's reference too
|
||||
this.routeOrchestrator.setCertManager(this.certManager);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -822,87 +698,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
await this.certManager.provisionCertificate(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the port usage map based on the provided routes
|
||||
*
|
||||
* This tracks which ports are used by which routes, allowing us to
|
||||
* detect when a port is no longer needed and can be released.
|
||||
*/
|
||||
private updatePortUsageMap(routes: IRouteConfig[]): Map<number, Set<string>> {
|
||||
// Reset the usage map
|
||||
const portUsage = new Map<number, Set<string>>();
|
||||
|
||||
for (const route of routes) {
|
||||
// Get the ports for this route
|
||||
const portsConfig = Array.isArray(route.match.ports)
|
||||
? route.match.ports
|
||||
: [route.match.ports];
|
||||
|
||||
// Expand port range objects to individual port numbers
|
||||
const expandedPorts: number[] = [];
|
||||
for (const portConfig of portsConfig) {
|
||||
if (typeof portConfig === 'number') {
|
||||
expandedPorts.push(portConfig);
|
||||
} else if (typeof portConfig === 'object' && 'from' in portConfig && 'to' in portConfig) {
|
||||
// Expand the port range
|
||||
for (let p = portConfig.from; p <= portConfig.to; p++) {
|
||||
expandedPorts.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use route name if available, otherwise generate a unique ID
|
||||
const routeName = route.name || `unnamed_${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Add each port to the usage map
|
||||
for (const port of expandedPorts) {
|
||||
if (!portUsage.has(port)) {
|
||||
portUsage.set(port, new Set());
|
||||
}
|
||||
portUsage.get(port)!.add(routeName);
|
||||
}
|
||||
}
|
||||
|
||||
// Log port usage for debugging
|
||||
for (const [port, routes] of portUsage.entries()) {
|
||||
try {
|
||||
logger.log('debug', `Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`, {
|
||||
port,
|
||||
routeCount: routes.size,
|
||||
component: 'smart-proxy'
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[DEBUG] Port ${port} is used by ${routes.size} routes: ${Array.from(routes).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return portUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ports that have no routes in the new configuration
|
||||
*/
|
||||
private findOrphanedPorts(oldUsage: Map<number, Set<string>>, newUsage: Map<number, Set<string>>): number[] {
|
||||
const orphanedPorts: number[] = [];
|
||||
|
||||
for (const [port, routes] of oldUsage.entries()) {
|
||||
if (!newUsage.has(port) || newUsage.get(port)!.size === 0) {
|
||||
orphanedPorts.push(port);
|
||||
try {
|
||||
logger.log('info', `Port ${port} no longer has any associated routes, will be released`, {
|
||||
port,
|
||||
component: 'smart-proxy'
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently handle logging errors
|
||||
console.log(`[INFO] Port ${port} no longer has any associated routes, will be released`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return orphanedPorts;
|
||||
}
|
||||
// Port usage tracking methods moved to RouteOrchestrator
|
||||
|
||||
/**
|
||||
* Force renewal of a certificate
|
||||
@@ -1024,9 +820,9 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
terminationStats,
|
||||
acmeEnabled: !!this.certManager,
|
||||
port80HandlerPort: this.certManager ? 80 : null,
|
||||
routes: this.routeManager.getListeningPorts().length,
|
||||
listeningPorts: this.portManager.getListeningPorts(),
|
||||
activePorts: this.portManager.getListeningPorts().length
|
||||
routeCount: this.settings.routes.length,
|
||||
activePorts: this.portManager.getListeningPorts().length,
|
||||
listeningPorts: this.portManager.getListeningPorts()
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { SniHandler } from '../../tls/sni/sni-handler.js';
|
||||
import { ProtocolDetector, TlsDetector } from '../../detection/index.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
|
||||
/**
|
||||
|
@@ -21,6 +21,8 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../models/route-types.js';
|
||||
import { mergeRouteConfigs } from './route-utils.js';
|
||||
import { ProtocolDetector, HttpDetector } from '../../../detection/index.js';
|
||||
import { createSocketTracker } from '../../../core/utils/socket-tracker.js';
|
||||
|
||||
/**
|
||||
* Create an HTTP-only route configuration
|
||||
@@ -956,83 +958,109 @@ export const SocketHandlers = {
|
||||
|
||||
/**
|
||||
* HTTP redirect handler
|
||||
* Now uses the centralized detection module for HTTP parsing
|
||||
*/
|
||||
httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
let buffer = '';
|
||||
const tracker = createSocketTracker(socket);
|
||||
const connectionId = ProtocolDetector.createConnectionId({
|
||||
socketId: context.connectionId || `${Date.now()}-${Math.random()}`
|
||||
});
|
||||
|
||||
socket.once('data', (data) => {
|
||||
buffer += data.toString();
|
||||
const handleData = async (data: Buffer) => {
|
||||
// Use detection module for parsing
|
||||
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
|
||||
data,
|
||||
connectionId,
|
||||
{ extractFullHeaders: false } // We only need method and path
|
||||
);
|
||||
|
||||
const lines = buffer.split('\r\n');
|
||||
const requestLine = lines[0];
|
||||
const [method, path] = requestLine.split(' ');
|
||||
if (detectionResult.protocol === 'http' && detectionResult.connectionInfo.path) {
|
||||
const method = detectionResult.connectionInfo.method || 'GET';
|
||||
const path = detectionResult.connectionInfo.path || '/';
|
||||
|
||||
const domain = context.domain || 'localhost';
|
||||
const port = context.port;
|
||||
const domain = context.domain || 'localhost';
|
||||
const port = context.port;
|
||||
|
||||
let finalLocation = locationTemplate
|
||||
.replace('{domain}', domain)
|
||||
.replace('{port}', String(port))
|
||||
.replace('{path}', path)
|
||||
.replace('{clientIp}', context.clientIp);
|
||||
let finalLocation = locationTemplate
|
||||
.replace('{domain}', domain)
|
||||
.replace('{port}', String(port))
|
||||
.replace('{path}', path)
|
||||
.replace('{clientIp}', context.clientIp);
|
||||
|
||||
const message = `Redirecting to ${finalLocation}`;
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`,
|
||||
`Location: ${finalLocation}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${message.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
message
|
||||
].join('\r\n');
|
||||
const message = `Redirecting to ${finalLocation}`;
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`,
|
||||
`Location: ${finalLocation}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${message.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
message
|
||||
].join('\r\n');
|
||||
|
||||
socket.write(response);
|
||||
} else {
|
||||
// Not a valid HTTP request, close connection
|
||||
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
||||
}
|
||||
|
||||
socket.write(response);
|
||||
socket.end();
|
||||
// Clean up detection state
|
||||
ProtocolDetector.cleanupConnections();
|
||||
// Clean up all tracked resources
|
||||
tracker.cleanup();
|
||||
};
|
||||
|
||||
// Use tracker to manage the listener
|
||||
socket.once('data', handleData);
|
||||
|
||||
tracker.addListener('error', (err) => {
|
||||
tracker.safeDestroy(err);
|
||||
});
|
||||
|
||||
tracker.addListener('close', () => {
|
||||
tracker.cleanup();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* HTTP server handler for ACME challenges and other HTTP needs
|
||||
* Now uses the centralized detection module for HTTP parsing
|
||||
*/
|
||||
httpServer: (handler: (req: { method: string; url: string; headers: Record<string, string>; body?: string }, res: { status: (code: number) => void; header: (name: string, value: string) => void; send: (data: string) => void; end: () => void }) => void) => (socket: plugins.net.Socket, context: IRouteContext) => {
|
||||
let buffer = '';
|
||||
const tracker = createSocketTracker(socket);
|
||||
let requestParsed = false;
|
||||
let responseTimer: NodeJS.Timeout | null = null;
|
||||
const connectionId = ProtocolDetector.createConnectionId({
|
||||
socketId: context.connectionId || `${Date.now()}-${Math.random()}`
|
||||
});
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const processData = async (data: Buffer) => {
|
||||
if (requestParsed) return; // Only handle the first request
|
||||
|
||||
buffer += data.toString();
|
||||
// Use HttpDetector for parsing
|
||||
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
|
||||
data,
|
||||
connectionId,
|
||||
{ extractFullHeaders: true }
|
||||
);
|
||||
|
||||
// Check if we have a complete HTTP request
|
||||
const headerEndIndex = buffer.indexOf('\r\n\r\n');
|
||||
if (headerEndIndex === -1) return; // Need more data
|
||||
|
||||
requestParsed = true;
|
||||
|
||||
// Parse the HTTP request
|
||||
const headerPart = buffer.substring(0, headerEndIndex);
|
||||
const bodyPart = buffer.substring(headerEndIndex + 4);
|
||||
|
||||
const lines = headerPart.split('\r\n');
|
||||
const [method, url] = lines[0].split(' ');
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const colonIndex = lines[i].indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const name = lines[i].substring(0, colonIndex).trim().toLowerCase();
|
||||
const value = lines[i].substring(colonIndex + 1).trim();
|
||||
headers[name] = value;
|
||||
}
|
||||
if (detectionResult.protocol !== 'http' || !detectionResult.isComplete) {
|
||||
// Not a complete HTTP request yet
|
||||
return;
|
||||
}
|
||||
|
||||
// Create request object
|
||||
requestParsed = true;
|
||||
// Remove data listener after parsing request
|
||||
socket.removeListener('data', processData);
|
||||
const connInfo = detectionResult.connectionInfo;
|
||||
|
||||
// Create request object from detection result
|
||||
const req = {
|
||||
method: method || 'GET',
|
||||
url: url || '/',
|
||||
headers,
|
||||
body: bodyPart
|
||||
method: connInfo.method || 'GET',
|
||||
url: connInfo.path || '/',
|
||||
headers: connInfo.headers || {},
|
||||
body: detectionResult.remainingBuffer?.toString() || ''
|
||||
};
|
||||
|
||||
// Create response object
|
||||
@@ -1051,6 +1079,12 @@ export const SocketHandlers = {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
|
||||
// Clear response timer since we're sending now
|
||||
if (responseTimer) {
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = null;
|
||||
}
|
||||
|
||||
if (!responseHeaders['content-type']) {
|
||||
responseHeaders['content-type'] = 'text/plain';
|
||||
}
|
||||
@@ -1082,23 +1116,44 @@ export const SocketHandlers = {
|
||||
try {
|
||||
handler(req, res);
|
||||
// Ensure response is sent even if handler doesn't call send()
|
||||
setTimeout(() => {
|
||||
responseTimer = setTimeout(() => {
|
||||
if (!ended) {
|
||||
res.send('');
|
||||
}
|
||||
responseTimer = null;
|
||||
}, 1000);
|
||||
// Track and unref the timer
|
||||
tracker.addTimer(responseTimer);
|
||||
} catch (error) {
|
||||
if (!ended) {
|
||||
res.status(500);
|
||||
res.send('Internal Server Error');
|
||||
}
|
||||
// Use safeDestroy for error cases
|
||||
tracker.safeDestroy(error instanceof Error ? error : new Error('Handler error'));
|
||||
}
|
||||
};
|
||||
|
||||
// Use tracker to manage listeners
|
||||
tracker.addListener('data', processData);
|
||||
|
||||
tracker.addListener('error', (err) => {
|
||||
if (!requestParsed) {
|
||||
tracker.safeDestroy(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
if (!requestParsed) {
|
||||
socket.end();
|
||||
tracker.addListener('close', () => {
|
||||
// Cleanup is handled by tracker
|
||||
// Clear any pending response timer
|
||||
if (responseTimer) {
|
||||
clearTimeout(responseTimer);
|
||||
responseTimer = null;
|
||||
}
|
||||
// Clean up detection state
|
||||
ProtocolDetector.cleanupConnections();
|
||||
// Clean up all tracked resources
|
||||
tracker.cleanup();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
453
ts/proxies/smart-proxy/utils/route-validator.ts
Normal file
453
ts/proxies/smart-proxy/utils/route-validator.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { logger } from '../../../core/utils/logger.js';
|
||||
import type { IRouteConfig } from '../models/route-types.js';
|
||||
|
||||
/**
|
||||
* Validates route configurations for correctness and safety
|
||||
*/
|
||||
export class RouteValidator {
|
||||
private static readonly VALID_TLS_MODES = ['terminate', 'passthrough', 'terminate-and-reencrypt'];
|
||||
private static readonly VALID_ACTION_TYPES = ['forward', 'socket-handler'];
|
||||
private static readonly VALID_PROTOCOLS = ['tcp', 'http', 'https', 'ws', 'wss'];
|
||||
private static readonly MAX_PORTS = 100;
|
||||
private static readonly MAX_DOMAINS = 1000;
|
||||
private static readonly MAX_HEADER_SIZE = 8192;
|
||||
|
||||
/**
|
||||
* Validate a single route configuration
|
||||
*/
|
||||
public static validateRoute(route: IRouteConfig): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Validate route has a name
|
||||
if (!route.name || typeof route.name !== 'string') {
|
||||
errors.push('Route must have a valid name');
|
||||
}
|
||||
|
||||
// Validate match criteria
|
||||
if (!route.match) {
|
||||
errors.push('Route must have match criteria');
|
||||
} else {
|
||||
// Validate ports
|
||||
if (route.match.ports) {
|
||||
const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
|
||||
|
||||
if (ports.length > this.MAX_PORTS) {
|
||||
errors.push(`Too many ports specified (max ${this.MAX_PORTS})`);
|
||||
}
|
||||
|
||||
for (const port of ports) {
|
||||
if (typeof port === 'number') {
|
||||
if (!this.isValidPort(port)) {
|
||||
errors.push(`Invalid port: ${port}. Must be between 1 and 65535`);
|
||||
}
|
||||
} else if (typeof port === 'object' && 'from' in port && 'to' in port) {
|
||||
if (!this.isValidPort(port.from)) {
|
||||
errors.push(`Invalid port range start: ${port.from}. Must be between 1 and 65535`);
|
||||
}
|
||||
if (!this.isValidPort(port.to)) {
|
||||
errors.push(`Invalid port range end: ${port.to}. Must be between 1 and 65535`);
|
||||
}
|
||||
if (port.from > port.to) {
|
||||
errors.push(`Invalid port range: ${port.from}-${port.to} (start > end)`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`Invalid port configuration: ${JSON.stringify(port)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domains
|
||||
if (route.match.domains) {
|
||||
const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
|
||||
|
||||
if (domains.length > this.MAX_DOMAINS) {
|
||||
errors.push(`Too many domains specified (max ${this.MAX_DOMAINS})`);
|
||||
}
|
||||
|
||||
for (const domain of domains) {
|
||||
if (!this.isValidDomain(domain)) {
|
||||
errors.push(`Invalid domain pattern: ${domain}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate paths
|
||||
if (route.match.path) {
|
||||
const paths = Array.isArray(route.match.path) ? route.match.path : [route.match.path];
|
||||
|
||||
for (const path of paths) {
|
||||
if (!this.isValidPath(path)) {
|
||||
errors.push(`Invalid path pattern: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate client IPs
|
||||
if (route.match.clientIp) {
|
||||
const ips = Array.isArray(route.match.clientIp) ? route.match.clientIp : [route.match.clientIp];
|
||||
|
||||
for (const ip of ips) {
|
||||
if (!this.isValidIPPattern(ip)) {
|
||||
errors.push(`Invalid IP pattern: ${ip}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate headers
|
||||
if (route.match.headers) {
|
||||
for (const [key, value] of Object.entries(route.match.headers)) {
|
||||
if (key.length > 256) {
|
||||
errors.push(`Header name too long: ${key}`);
|
||||
}
|
||||
|
||||
const headerValue = String(value);
|
||||
if (headerValue.length > this.MAX_HEADER_SIZE) {
|
||||
errors.push(`Header value too long for ${key} (max ${this.MAX_HEADER_SIZE} bytes)`);
|
||||
}
|
||||
|
||||
if (!/^[\x20-\x7E]+$/.test(key)) {
|
||||
errors.push(`Invalid header name: ${key} (must be printable ASCII)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol validation removed - not part of IRouteMatch interface
|
||||
}
|
||||
|
||||
// Validate action
|
||||
if (!route.action) {
|
||||
errors.push('Route must have an action');
|
||||
} else {
|
||||
// Validate action type
|
||||
if (!route.action.type || !this.VALID_ACTION_TYPES.includes(route.action.type)) {
|
||||
errors.push(`Invalid action type: ${route.action.type}. Must be one of: ${this.VALID_ACTION_TYPES.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate socket-handler
|
||||
if (route.action.type === 'socket-handler') {
|
||||
if (typeof route.action.socketHandler !== 'function') {
|
||||
errors.push('socket-handler action requires a socketHandler function');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate forward target
|
||||
if (route.action.type === 'forward') {
|
||||
if (!route.action.targets || route.action.targets.length === 0) {
|
||||
errors.push('Forward action must have at least one target');
|
||||
} else {
|
||||
for (const target of route.action.targets) {
|
||||
if (!target.host) {
|
||||
errors.push('Target must have a host');
|
||||
} else if (typeof target.host !== 'string' && !Array.isArray(target.host) && typeof target.host !== 'function') {
|
||||
errors.push('Target host must be a string, array of strings, or function');
|
||||
}
|
||||
|
||||
if (target.port) {
|
||||
if (typeof target.port === 'number' && !this.isValidPort(target.port)) {
|
||||
errors.push(`Invalid target port: ${target.port}`);
|
||||
} else if (target.port !== 'preserve' && typeof target.port !== 'function' && typeof target.port !== 'number') {
|
||||
errors.push(`Invalid target port configuration: ${target.port}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate TLS settings
|
||||
if (route.action.tls) {
|
||||
if (route.action.tls.mode && !this.VALID_TLS_MODES.includes(route.action.tls.mode)) {
|
||||
errors.push(`Invalid TLS mode: ${route.action.tls.mode}. Must be one of: ${this.VALID_TLS_MODES.join(', ')}`);
|
||||
}
|
||||
|
||||
if (route.action.tls.certificate) {
|
||||
if (route.action.tls.certificate !== 'auto' && typeof route.action.tls.certificate !== 'object') {
|
||||
errors.push('TLS certificate must be "auto" or a certificate configuration object');
|
||||
}
|
||||
}
|
||||
|
||||
if (route.action.tls.versions) {
|
||||
for (const version of route.action.tls.versions) {
|
||||
if (!['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'].includes(version)) {
|
||||
errors.push(`Invalid TLS version: ${version}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate security settings
|
||||
if (route.security) {
|
||||
// Validate IP allow/block lists
|
||||
if (route.security.ipAllowList) {
|
||||
const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList];
|
||||
|
||||
for (const ip of allowList) {
|
||||
if (!this.isValidIPPattern(ip)) {
|
||||
errors.push(`Invalid IP pattern in allow list: ${ip}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (route.security.ipBlockList) {
|
||||
const blockList = Array.isArray(route.security.ipBlockList) ? route.security.ipBlockList : [route.security.ipBlockList];
|
||||
|
||||
for (const ip of blockList) {
|
||||
if (!this.isValidIPPattern(ip)) {
|
||||
errors.push(`Invalid IP pattern in block list: ${ip}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rate limits
|
||||
if (route.security.rateLimit) {
|
||||
if (route.security.rateLimit.maxRequests && route.security.rateLimit.maxRequests < 0) {
|
||||
errors.push('Rate limit maxRequests must be positive');
|
||||
}
|
||||
|
||||
if (route.security.rateLimit.window && route.security.rateLimit.window < 0) {
|
||||
errors.push('Rate limit window must be positive');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate connection limits
|
||||
if (route.security.maxConnections && route.security.maxConnections < 0) {
|
||||
errors.push('Max connections must be positive');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate priority
|
||||
if (route.priority !== undefined && (route.priority < 0 || route.priority > 10000)) {
|
||||
errors.push('Priority must be between 0 and 10000');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multiple route configurations
|
||||
*/
|
||||
public static validateRoutes(routes: IRouteConfig[]): { valid: boolean; errors: Map<string, string[]> } {
|
||||
const errorMap = new Map<string, string[]>();
|
||||
let valid = true;
|
||||
|
||||
// Check for duplicate route names
|
||||
const routeNames = new Set<string>();
|
||||
for (const route of routes) {
|
||||
if (route.name && routeNames.has(route.name)) {
|
||||
const existingErrors = errorMap.get(route.name) || [];
|
||||
existingErrors.push('Duplicate route name');
|
||||
errorMap.set(route.name, existingErrors);
|
||||
valid = false;
|
||||
}
|
||||
routeNames.add(route.name);
|
||||
}
|
||||
|
||||
// Validate each route
|
||||
for (const route of routes) {
|
||||
const result = this.validateRoute(route);
|
||||
if (!result.valid) {
|
||||
errorMap.set(route.name || 'unnamed', result.errors);
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for conflicting routes
|
||||
const conflicts = this.findRouteConflicts(routes);
|
||||
if (conflicts.length > 0) {
|
||||
for (const conflict of conflicts) {
|
||||
const existingErrors = errorMap.get(conflict.route) || [];
|
||||
existingErrors.push(conflict.message);
|
||||
errorMap.set(conflict.route, existingErrors);
|
||||
}
|
||||
valid = false;
|
||||
}
|
||||
|
||||
return { valid, errors: errorMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find potential conflicts between routes
|
||||
*/
|
||||
private static findRouteConflicts(routes: IRouteConfig[]): Array<{ route: string; message: string }> {
|
||||
const conflicts: Array<{ route: string; message: string }> = [];
|
||||
|
||||
// Group routes by port
|
||||
const portMap = new Map<number, IRouteConfig[]>();
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.match?.ports) {
|
||||
const ports = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports];
|
||||
|
||||
// Expand port ranges to individual ports
|
||||
const expandedPorts: number[] = [];
|
||||
for (const port of ports) {
|
||||
if (typeof port === 'number') {
|
||||
expandedPorts.push(port);
|
||||
} else if (typeof port === 'object' && 'from' in port && 'to' in port) {
|
||||
for (let p = port.from; p <= port.to; p++) {
|
||||
expandedPorts.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const port of expandedPorts) {
|
||||
const routesOnPort = portMap.get(port) || [];
|
||||
routesOnPort.push(route);
|
||||
portMap.set(port, routesOnPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for conflicting catch-all routes on the same port
|
||||
for (const [port, routesOnPort] of portMap) {
|
||||
const catchAllRoutes = routesOnPort.filter(r =>
|
||||
!r.match.domains ||
|
||||
(Array.isArray(r.match.domains) && r.match.domains.includes('*')) ||
|
||||
r.match.domains === '*'
|
||||
);
|
||||
|
||||
if (catchAllRoutes.length > 1) {
|
||||
for (const route of catchAllRoutes) {
|
||||
conflicts.push({
|
||||
route: route.name,
|
||||
message: `Multiple catch-all routes on port ${port}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate port number
|
||||
*/
|
||||
private static isValidPort(port: number): boolean {
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate domain pattern
|
||||
*/
|
||||
private static isValidDomain(domain: string): boolean {
|
||||
if (!domain || typeof domain !== 'string') return false;
|
||||
if (domain === '*') return true;
|
||||
|
||||
// Basic domain pattern validation
|
||||
const domainPattern = /^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
return domainPattern.test(domain) || domain === 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate path pattern
|
||||
*/
|
||||
private static isValidPath(path: string): boolean {
|
||||
if (!path || typeof path !== 'string') return false;
|
||||
if (!path.startsWith('/')) return false;
|
||||
|
||||
// Check for invalid characters
|
||||
if (!/^[a-zA-Z0-9/_*:{}.-]+$/.test(path)) return false;
|
||||
|
||||
// Validate parameter syntax
|
||||
const paramPattern = /\{[a-zA-Z_][a-zA-Z0-9_]*\}/g;
|
||||
const params = path.match(paramPattern) || [];
|
||||
|
||||
for (const param of params) {
|
||||
if (param.length > 32) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IP pattern
|
||||
*/
|
||||
private static isValidIPPattern(ip: string): boolean {
|
||||
if (!ip || typeof ip !== 'string') return false;
|
||||
if (ip === '*') return true;
|
||||
|
||||
// Check for CIDR notation
|
||||
if (ip.includes('/')) {
|
||||
const [addr, prefix] = ip.split('/');
|
||||
const prefixNum = parseInt(prefix, 10);
|
||||
|
||||
if (addr.includes(':')) {
|
||||
// IPv6 CIDR
|
||||
return this.isValidIPv6(addr) && prefixNum >= 0 && prefixNum <= 128;
|
||||
} else {
|
||||
// IPv4 CIDR
|
||||
return this.isValidIPv4(addr) && prefixNum >= 0 && prefixNum <= 32;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for range
|
||||
if (ip.includes('-')) {
|
||||
const [start, end] = ip.split('-');
|
||||
return (this.isValidIPv4(start) && this.isValidIPv4(end)) ||
|
||||
(this.isValidIPv6(start) && this.isValidIPv6(end));
|
||||
}
|
||||
|
||||
// Check for wildcards in IPv4
|
||||
if (ip.includes('*') && !ip.includes(':')) {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
|
||||
for (const part of parts) {
|
||||
if (part !== '*' && !/^\d{1,3}$/.test(part)) return false;
|
||||
if (part !== '*' && parseInt(part, 10) > 255) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Regular IP address
|
||||
return this.isValidIPv4(ip) || this.isValidIPv6(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IPv4 address
|
||||
*/
|
||||
private static isValidIPv4(ip: string): boolean {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
|
||||
for (const part of parts) {
|
||||
const num = parseInt(part, 10);
|
||||
if (isNaN(num) || num < 0 || num > 255) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IPv6 address
|
||||
*/
|
||||
private static isValidIPv6(ip: string): boolean {
|
||||
// Simple IPv6 validation
|
||||
const ipv6Pattern = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){0,6}|::1|::)$/;
|
||||
return ipv6Pattern.test(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log validation errors
|
||||
*/
|
||||
public static logValidationErrors(errors: Map<string, string[]>): void {
|
||||
for (const [routeName, routeErrors] of errors) {
|
||||
logger.log('error', `Route validation failed for ${routeName}:`, {
|
||||
route: routeName,
|
||||
errors: routeErrors,
|
||||
component: 'route-validator'
|
||||
});
|
||||
|
||||
for (const error of routeErrors) {
|
||||
logger.log('error', ` - ${error}`, {
|
||||
route: routeName,
|
||||
component: 'route-validator'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,22 +1,18 @@
|
||||
/**
|
||||
* TLS module providing SNI extraction, TLS alerts, and other TLS-related utilities
|
||||
* TLS module for smartproxy
|
||||
* Re-exports protocol components and provides smartproxy-specific functionality
|
||||
*/
|
||||
|
||||
// Export TLS alert functionality
|
||||
export * from './alerts/tls-alert.js';
|
||||
// Re-export all protocol components from protocols/tls
|
||||
export * from '../protocols/tls/index.js';
|
||||
|
||||
// Export SNI handling
|
||||
// Export smartproxy-specific SNI handler
|
||||
export * from './sni/sni-handler.js';
|
||||
export * from './sni/sni-extraction.js';
|
||||
export * from './sni/client-hello-parser.js';
|
||||
|
||||
// Export TLS utilities
|
||||
export * from './utils/tls-utils.js';
|
||||
|
||||
// Create a namespace for SNI utilities
|
||||
import { SniHandler } from './sni/sni-handler.js';
|
||||
import { SniExtraction } from './sni/sni-extraction.js';
|
||||
import { ClientHelloParser } from './sni/client-hello-parser.js';
|
||||
import { SniExtraction } from '../protocols/tls/sni/sni-extraction.js';
|
||||
import { ClientHelloParser } from '../protocols/tls/sni/client-hello-parser.js';
|
||||
|
||||
// Export utility objects for convenience
|
||||
export const SNI = {
|
||||
|
@@ -4,15 +4,15 @@ import {
|
||||
TlsHandshakeType,
|
||||
TlsExtensionType,
|
||||
TlsUtils
|
||||
} from '../utils/tls-utils.js';
|
||||
} from '../../protocols/tls/utils/tls-utils.js';
|
||||
import {
|
||||
ClientHelloParser,
|
||||
type LoggerFunction
|
||||
} from './client-hello-parser.js';
|
||||
} from '../../protocols/tls/sni/client-hello-parser.js';
|
||||
import {
|
||||
SniExtraction,
|
||||
type ConnectionInfo
|
||||
} from './sni-extraction.js';
|
||||
} from '../../protocols/tls/sni/sni-extraction.js';
|
||||
|
||||
/**
|
||||
* SNI (Server Name Indication) handler for TLS connections.
|
||||
|
Reference in New Issue
Block a user