Compare commits
5 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
36068a6d92 | ||
|
d47b048517 | ||
|
c84947068c | ||
|
26f7431111 | ||
|
aa6ddbc4a6 |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"expiryDate": "2025-10-01T02:31:27.435Z",
|
"expiryDate": "2025-10-19T22:36:33.093Z",
|
||||||
"issueDate": "2025-07-03T02:31:27.435Z",
|
"issueDate": "2025-07-21T22:36:33.093Z",
|
||||||
"savedAt": "2025-07-03T02:31:27.435Z"
|
"savedAt": "2025-07-21T22:36:33.094Z"
|
||||||
}
|
}
|
34
changelog.md
34
changelog.md
@@ -1,5 +1,39 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Removed the `forwarding` namespace export from main index
|
||||||
|
- Removed TForwardingType and all forwarding handlers
|
||||||
|
- Consolidated route helper functions into route-helpers.ts
|
||||||
|
- All functionality is now available through the route-based system
|
||||||
|
- MIGRATION: Replace `import { forwarding } from '@push.rocks/smartproxy'` with direct imports of route helpers
|
||||||
|
|
||||||
|
## 2025-07-21 - 20.0.2 - fix(docs)
|
||||||
|
Update documentation to improve clarity
|
||||||
|
|
||||||
|
- Enhanced readme with clearer breaking change warning for v20.0.0
|
||||||
|
- Fixed example email address from ssl@bleu.de to ssl@example.com
|
||||||
|
- Added load balancing and failover features to feature list
|
||||||
|
- Improved documentation structure and examples
|
||||||
|
|
||||||
|
## 2025-07-20 - 20.0.1 - BREAKING_CHANGE(routing)
|
||||||
|
Refactor route configuration to support multiple targets
|
||||||
|
|
||||||
|
- Changed route action configuration from single `target` to `targets` array
|
||||||
|
- Enables load balancing and failover capabilities with multiple upstream targets
|
||||||
|
- Updated all test files to use new `targets` array syntax
|
||||||
|
- Automatic certificate metadata refresh
|
||||||
|
|
||||||
## 2025-06-01 - 19.5.19 - fix(smartproxy)
|
## 2025-06-01 - 19.5.19 - fix(smartproxy)
|
||||||
Fix connection handling and improve route matching edge cases
|
Fix connection handling and improve route matching edge cases
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "19.6.17",
|
"version": "21.1.0",
|
||||||
"private": false,
|
"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.",
|
"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",
|
"main": "dist_ts/index.js",
|
||||||
@@ -51,7 +51,8 @@
|
|||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
"npmextra.json",
|
||||||
"readme.md"
|
"readme.md",
|
||||||
|
"changelog.md"
|
||||||
],
|
],
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 1 chrome versions"
|
"last 1 chrome versions"
|
||||||
|
@@ -32,14 +32,14 @@ tap.test('PathMatcher - wildcard matching', async () => {
|
|||||||
const result = PathMatcher.match('/api/*', '/api/users/123/profile');
|
const result = PathMatcher.match('/api/*', '/api/users/123/profile');
|
||||||
expect(result.matches).toEqual(true);
|
expect(result.matches).toEqual(true);
|
||||||
expect(result.pathMatch).toEqual('/api'); // Normalized without trailing slash
|
expect(result.pathMatch).toEqual('/api'); // Normalized without trailing slash
|
||||||
expect(result.pathRemainder).toEqual('users/123/profile');
|
expect(result.pathRemainder).toEqual('/users/123/profile');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('PathMatcher - mixed parameters and wildcards', async () => {
|
tap.test('PathMatcher - mixed parameters and wildcards', async () => {
|
||||||
const result = PathMatcher.match('/api/:version/*', '/api/v1/users/123');
|
const result = PathMatcher.match('/api/:version/*', '/api/v1/users/123');
|
||||||
expect(result.matches).toEqual(true);
|
expect(result.matches).toEqual(true);
|
||||||
expect(result.params).toEqual({ version: 'v1' });
|
expect(result.params).toEqual({ version: 'v1' });
|
||||||
expect(result.pathRemainder).toEqual('users/123');
|
expect(result.pathRemainder).toEqual('/users/123');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('PathMatcher - trailing slash normalization', async () => {
|
tap.test('PathMatcher - trailing slash normalization', async () => {
|
||||||
|
@@ -58,7 +58,7 @@ tap.test('Shared Security Manager', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'target.com', port: 443 }
|
targets: [{ host: 'target.com', port: 443 }]
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
ipAllowList: ['10.0.0.*', '192.168.1.*'],
|
ipAllowList: ['10.0.0.*', '192.168.1.*'],
|
||||||
@@ -113,7 +113,7 @@ tap.test('Shared Security Manager', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'target.com', port: 443 }
|
targets: [{ host: 'target.com', port: 443 }]
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
|
@@ -59,7 +59,7 @@ tap.test('should create ACME challenge route', async (tools) => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward' as const,
|
type: 'forward' as const,
|
||||||
target: { host: 'localhost', port: 8080 }
|
targets: [{ host: 'localhost', port: 8080 }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
challengeRoute
|
challengeRoute
|
||||||
|
@@ -18,7 +18,7 @@ tap.test('should defer certificate provisioning until ports are ready', async (t
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8181 },
|
targets: [{ host: 'localhost', port: 8181 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
|
@@ -30,7 +30,7 @@ tap.test('should defer certificate provisioning until after ports are listening'
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8181 },
|
targets: [{ host: 'localhost', port: 8181 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
@@ -126,7 +126,7 @@ tap.test('should have ACME challenge route ready before certificate provisioning
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8181 },
|
targets: [{ host: 'localhost', port: 8181 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto'
|
certificate: 'auto'
|
||||||
|
@@ -16,10 +16,10 @@ tap.test('SmartCertManager should call getCertificateForDomain with wildcard opt
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8080
|
port: 8080
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
|
@@ -59,10 +59,10 @@ tap.test('SmartProxy should support custom certificate provision function', asyn
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8080
|
port: 8080
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto'
|
certificate: 'auto'
|
||||||
@@ -109,10 +109,10 @@ tap.test('Custom certificate provision function should be called', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8080
|
port: 8080
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto'
|
certificate: 'auto'
|
||||||
@@ -172,10 +172,10 @@ tap.test('Should fallback to ACME when custom provision fails', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8080
|
port: 8080
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto'
|
certificate: 'auto'
|
||||||
@@ -231,10 +231,10 @@ tap.test('Should not fallback when certProvisionFallbackToAcme is false', async
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8080
|
port: 8080
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto'
|
certificate: 'auto'
|
||||||
@@ -310,10 +310,10 @@ tap.test('Should return http01 for unknown domains', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8080
|
port: 8080
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto'
|
certificate: 'auto'
|
||||||
|
@@ -7,7 +7,7 @@ const testProxy = new SmartProxy({
|
|||||||
match: { ports: 9443, domains: 'test.local' },
|
match: { ports: 9443, domains: 'test.local' },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8080 },
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
@@ -67,7 +67,7 @@ tap.test('should handle static certificates', async () => {
|
|||||||
match: { ports: 9444, domains: 'static.example.com' },
|
match: { ports: 9444, domains: 'static.example.com' },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8080 },
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: {
|
certificate: {
|
||||||
@@ -96,7 +96,7 @@ tap.test('should handle ACME challenge routes', async () => {
|
|||||||
match: { ports: 9445, domains: 'acme.local' },
|
match: { ports: 9445, domains: 'acme.local' },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8080 },
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
@@ -112,7 +112,7 @@ tap.test('should handle ACME challenge routes', async () => {
|
|||||||
match: { ports: 9081, domains: 'acme.local' },
|
match: { ports: 9081, domains: 'acme.local' },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8080 }
|
targets: [{ host: 'localhost', port: 8080 }]
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
acme: {
|
acme: {
|
||||||
@@ -167,7 +167,7 @@ tap.test('should renew certificates', async () => {
|
|||||||
match: { ports: 9446, domains: 'renew.local' },
|
match: { ports: 9446, domains: 'renew.local' },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8080 },
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
|
@@ -8,7 +8,7 @@ tap.test('should create SmartProxy with certificate routes', async () => {
|
|||||||
match: { ports: 8443, domains: 'test.example.com' },
|
match: { ports: 8443, domains: 'test.example.com' },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8080 },
|
targets: [{ host: 'localhost', port: 8080 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
|
@@ -13,7 +13,7 @@ tap.test('cleanup queue bug - verify queue processing handles more than batch si
|
|||||||
match: { ports: 8588 },
|
match: { ports: 8588 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 9996 }
|
targets: [{ host: 'localhost', port: 9996 }]
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
enableDetailedLogging: false,
|
enableDetailedLogging: false,
|
||||||
|
@@ -18,10 +18,10 @@ tap.test('should handle clients that connect and immediately disconnect without
|
|||||||
match: { ports: 8560 },
|
match: { ports: 8560 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9999 // Non-existent port
|
port: 9999 // Non-existent port
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -173,10 +173,10 @@ tap.test('should handle clients that error during connection', async () => {
|
|||||||
match: { ports: 8561 },
|
match: { ports: 8561 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9999
|
port: 9999
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -20,10 +20,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
|
|||||||
match: { ports: 8570 },
|
match: { ports: 8570 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9999 // Non-existent port
|
port: 9999 // Non-existent port
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,10 +31,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
|
|||||||
match: { ports: 8571 },
|
match: { ports: 8571 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9999 // Non-existent port
|
port: 9999 // Non-existent port
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'passthrough'
|
mode: 'passthrough'
|
||||||
}
|
}
|
||||||
@@ -215,10 +215,10 @@ tap.test('comprehensive connection cleanup test - all scenarios', async () => {
|
|||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
forwardingEngine: 'nftables',
|
forwardingEngine: 'nftables',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9999
|
port: 9999
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -65,10 +65,10 @@ tap.test('should forward TCP connections correctly', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 7001,
|
port: 7001,
|
||||||
},
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -118,10 +118,10 @@ tap.test('should handle TLS passthrough correctly', async () => {
|
|||||||
tls: {
|
tls: {
|
||||||
mode: 'passthrough',
|
mode: 'passthrough',
|
||||||
},
|
},
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 7002,
|
port: 7002,
|
||||||
},
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -179,10 +179,10 @@ tap.test('should handle SNI-based forwarding', async () => {
|
|||||||
tls: {
|
tls: {
|
||||||
mode: 'passthrough',
|
mode: 'passthrough',
|
||||||
},
|
},
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 7002,
|
port: 7002,
|
||||||
},
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -197,10 +197,10 @@ tap.test('should handle SNI-based forwarding', async () => {
|
|||||||
tls: {
|
tls: {
|
||||||
mode: 'passthrough',
|
mode: 'passthrough',
|
||||||
},
|
},
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 7002,
|
port: 7002,
|
||||||
},
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@@ -90,10 +90,10 @@ tap.test('Setup test environment', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: TEST_SERVER_PORT
|
port: TEST_SERVER_PORT
|
||||||
}
|
}]
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
maxConnections: 5 // Low limit for testing
|
maxConnections: 5 // Low limit for testing
|
||||||
@@ -198,10 +198,10 @@ tap.test('HttpProxy per-IP validation', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: TEST_SERVER_PORT
|
port: TEST_SERVER_PORT
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate'
|
mode: 'terminate'
|
||||||
}
|
}
|
||||||
|
131
test/test.detection.ts
Normal file
131
test/test.detection.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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 () => {
|
||||||
|
const connectionId = 'test-connection-1';
|
||||||
|
|
||||||
|
// First fragment
|
||||||
|
const fragment1 = Buffer.from('GET /test HT');
|
||||||
|
let result = await smartproxy.detection.ProtocolDetector.detectWithConnectionTracking(
|
||||||
|
fragment1,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
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.detectWithConnectionTracking(
|
||||||
|
fragment2,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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.start();
|
@@ -9,7 +9,7 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
|
|||||||
match: { ports: [18443], domains: ['test.local'] },
|
match: { ports: [18443], domains: ['test.local'] },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 3000 },
|
targets: [{ host: 'localhost', port: 3000 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
@@ -63,7 +63,7 @@ tap.test('should verify certificate manager callback is preserved on updateRoute
|
|||||||
match: { ports: [18444], domains: ['test2.local'] },
|
match: { ports: [18444], domains: ['test2.local'] },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 3001 },
|
targets: [{ host: 'localhost', port: 3001 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
|
@@ -37,7 +37,7 @@ tap.test('regular forward route should work correctly', async () => {
|
|||||||
match: { ports: 7890 },
|
match: { ports: 7890 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 6789 }
|
targets: [{ host: 'localhost', port: 6789 }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -106,7 +106,7 @@ tap.skip.test('NFTables forward route should not terminate connections (requires
|
|||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
forwardingEngine: 'nftables',
|
forwardingEngine: 'nftables',
|
||||||
target: { host: 'localhost', port: 6789 }
|
targets: [{ host: 'localhost', port: 6789 }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -39,10 +39,10 @@ tap.test('forward connections should not be immediately closed', async (t) => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 9090,
|
port: 9090,
|
||||||
},
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@@ -1,9 +1,6 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as plugins from '../ts/plugins.js';
|
import * as plugins from '../ts/plugins.js';
|
||||||
import type { IForwardConfig, TForwardingType } from '../ts/forwarding/config/forwarding-types.js';
|
|
||||||
|
|
||||||
// First, import the components directly to avoid issues with compiled modules
|
|
||||||
import { ForwardingHandlerFactory } from '../ts/forwarding/factory/forwarding-factory.js';
|
|
||||||
// Import route-based helpers
|
// Import route-based helpers
|
||||||
import {
|
import {
|
||||||
createHttpRoute,
|
createHttpRoute,
|
||||||
@@ -39,7 +36,7 @@ tap.test('Route Helpers - Create HTTP routes', async () => {
|
|||||||
const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 });
|
const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 });
|
||||||
expect(route.action.type).toEqual('forward');
|
expect(route.action.type).toEqual('forward');
|
||||||
expect(route.match.domains).toEqual('example.com');
|
expect(route.match.domains).toEqual('example.com');
|
||||||
expect(route.action.target).toEqual({ host: 'localhost', port: 3000 });
|
expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => {
|
tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => {
|
||||||
|
@@ -1,53 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as plugins from '../ts/plugins.js';
|
|
||||||
|
|
||||||
// First, import the components directly to avoid issues with compiled modules
|
|
||||||
import { ForwardingHandlerFactory } from '../ts/forwarding/factory/forwarding-factory.js';
|
|
||||||
// Import route-based helpers from the correct location
|
|
||||||
import {
|
|
||||||
createHttpRoute,
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createHttpsPassthroughRoute,
|
|
||||||
createHttpToHttpsRedirect,
|
|
||||||
createCompleteHttpsServer,
|
|
||||||
createLoadBalancerRoute
|
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-patterns.js';
|
|
||||||
|
|
||||||
// Create helper functions for building forwarding configs
|
|
||||||
const helpers = {
|
|
||||||
httpOnly: () => ({ type: 'http-only' as const }),
|
|
||||||
tlsTerminateToHttp: () => ({ type: 'https-terminate-to-http' as const }),
|
|
||||||
tlsTerminateToHttps: () => ({ type: 'https-terminate-to-https' as const }),
|
|
||||||
httpsPassthrough: () => ({ type: 'https-passthrough' as const })
|
|
||||||
};
|
|
||||||
|
|
||||||
tap.test('ForwardingHandlerFactory - apply defaults based on type', async () => {
|
|
||||||
// HTTP-only defaults
|
|
||||||
const httpConfig = {
|
|
||||||
type: 'http-only' as const,
|
|
||||||
target: { host: 'localhost', port: 3000 }
|
|
||||||
};
|
|
||||||
|
|
||||||
const httpWithDefaults = ForwardingHandlerFactory['applyDefaults'](httpConfig);
|
|
||||||
|
|
||||||
expect(httpWithDefaults.port).toEqual(80);
|
|
||||||
expect(httpWithDefaults.socket).toEqual('/tmp/forwarding-http-only-80.sock');
|
|
||||||
|
|
||||||
// HTTPS passthrough defaults
|
|
||||||
const httpsPassthroughConfig = {
|
|
||||||
type: 'https-passthrough' as const,
|
|
||||||
target: { host: 'localhost', port: 443 }
|
|
||||||
};
|
|
||||||
|
|
||||||
const httpsPassthroughWithDefaults = ForwardingHandlerFactory['applyDefaults'](httpsPassthroughConfig);
|
|
||||||
|
|
||||||
expect(httpsPassthroughWithDefaults.port).toEqual(443);
|
|
||||||
expect(httpsPassthroughWithDefaults.socket).toEqual('/tmp/forwarding-https-passthrough-443.sock');
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('ForwardingHandlerFactory - factory function for handlers', async () => {
|
|
||||||
// @todo Implement unit tests for ForwardingHandlerFactory
|
|
||||||
// These tests would need proper mocking of the handlers
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
@@ -20,7 +20,7 @@ tap.test('should forward non-TLS connections on HttpProxy ports', async (tapTest
|
|||||||
match: { ports: testPort },
|
match: { ports: testPort },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8181 }
|
targets: [{ host: 'localhost', port: 8181 }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
@@ -81,7 +81,7 @@ tap.test('should use direct connection for non-HttpProxy ports', async (tapTest)
|
|||||||
match: { ports: 8080 }, // Not in useHttpProxy
|
match: { ports: 8080 }, // Not in useHttpProxy
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8181 }
|
targets: [{ host: 'localhost', port: 8181 }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
@@ -142,7 +142,7 @@ tap.test('should handle ACME HTTP-01 challenges on port 80 with HttpProxy', asyn
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8080 }
|
targets: [{ host: 'localhost', port: 8080 }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
@@ -14,7 +14,7 @@ tap.test('should detect and forward non-TLS connections on useHttpProxy ports',
|
|||||||
match: { ports: 8080 },
|
match: { ports: 8080 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8181 }
|
targets: [{ host: 'localhost', port: 8181 }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
@@ -140,7 +140,7 @@ tap.test('should handle TLS connections normally', async (tapTest) => {
|
|||||||
match: { ports: 443 },
|
match: { ports: 443 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8443 },
|
targets: [{ host: 'localhost', port: 8443 }],
|
||||||
tls: { mode: 'terminate' }
|
tls: { mode: 'terminate' }
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
|
@@ -17,7 +17,7 @@ tap.test('should detect and forward non-TLS connections on HttpProxy ports', asy
|
|||||||
match: { ports: 8081 },
|
match: { ports: 8081 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8181 }
|
targets: [{ host: 'localhost', port: 8181 }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -120,7 +120,7 @@ tap.test('should properly detect non-TLS connections on HttpProxy ports', async
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: targetPort }
|
targets: [{ host: 'localhost', port: targetPort }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -42,7 +42,7 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: targetPort }
|
targets: [{ host: 'localhost', port: targetPort }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -131,7 +131,7 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: targetPort }
|
targets: [{ host: 'localhost', port: targetPort }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -67,7 +67,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: targetPort },
|
targets: [{ host: 'localhost', port: targetPort }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto' // Use ACME for certificate
|
certificate: 'auto' // Use ACME for certificate
|
||||||
@@ -83,7 +83,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: targetPort }
|
targets: [{ host: 'localhost', port: targetPort }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -191,7 +191,7 @@ tap.test('should handle ACME challenges on port 8080 with improved port binding
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward' as const,
|
type: 'forward' as const,
|
||||||
target: { host: 'localhost', port: targetPort }
|
targets: [{ host: 'localhost', port: targetPort }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
@@ -95,10 +95,10 @@ tap.test('should support static host/port routes', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: serverPort
|
port: serverPort
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -135,13 +135,13 @@ tap.test('should support function-based host', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: (context: IRouteContext) => {
|
host: (context: IRouteContext) => {
|
||||||
// Return localhost always in this test
|
// Return localhost always in this test
|
||||||
return 'localhost';
|
return 'localhost';
|
||||||
},
|
},
|
||||||
port: serverPort
|
port: serverPort
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -178,13 +178,13 @@ tap.test('should support function-based port', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: (context: IRouteContext) => {
|
port: (context: IRouteContext) => {
|
||||||
// Return test server port
|
// Return test server port
|
||||||
return serverPort;
|
return serverPort;
|
||||||
}
|
}
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -221,14 +221,14 @@ tap.test('should support function-based host AND port', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: (context: IRouteContext) => {
|
host: (context: IRouteContext) => {
|
||||||
return 'localhost';
|
return 'localhost';
|
||||||
},
|
},
|
||||||
port: (context: IRouteContext) => {
|
port: (context: IRouteContext) => {
|
||||||
return serverPort;
|
return serverPort;
|
||||||
}
|
}
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -265,7 +265,7 @@ tap.test('should support context-based routing with path', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: (context: IRouteContext) => {
|
host: (context: IRouteContext) => {
|
||||||
// Use path to determine host
|
// Use path to determine host
|
||||||
if (context.path?.startsWith('/api')) {
|
if (context.path?.startsWith('/api')) {
|
||||||
@@ -275,7 +275,7 @@ tap.test('should support context-based routing with path', async () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
port: serverPort
|
port: serverPort
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
@@ -40,7 +40,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
|||||||
match: { ports: 8590 },
|
match: { ports: 8590 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 9998 }
|
targets: [{ host: 'localhost', port: 9998 }]
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
@@ -117,7 +117,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
|||||||
match: { ports: 8591 },
|
match: { ports: 8591 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 9998 }
|
targets: [{ host: 'localhost', port: 9998 }]
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
@@ -178,7 +178,7 @@ tap.test('keepalive support - verify keepalive connections are properly handled'
|
|||||||
match: { ports: 8592 },
|
match: { ports: 8592 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 9998 }
|
targets: [{ host: 'localhost', port: 9998 }]
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
|
@@ -39,10 +39,10 @@ tap.test('setup test environment', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9876
|
port: 9876
|
||||||
}
|
}]
|
||||||
// No TLS configuration - just plain TCP forwarding
|
// No TLS configuration - just plain TCP forwarding
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
|
@@ -36,10 +36,10 @@ tap.test('should create SmartProxy instance with new metrics', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: echoServerPort
|
port: echoServerPort
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'passthrough'
|
mode: 'passthrough'
|
||||||
}
|
}
|
||||||
|
@@ -34,10 +34,10 @@ tap.skip.test('NFTables forwarding should not terminate connections (requires ro
|
|||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
forwardingEngine: 'nftables',
|
forwardingEngine: 'nftables',
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 8001,
|
port: 8001,
|
||||||
},
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Also add regular forwarding route for comparison
|
// Also add regular forwarding route for comparison
|
||||||
@@ -49,10 +49,10 @@ tap.skip.test('NFTables forwarding should not terminate connections (requires ro
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 8001,
|
port: 8001,
|
||||||
},
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@@ -42,10 +42,10 @@ const sampleRoute: IRouteConfig = {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8000
|
port: 8000
|
||||||
},
|
}],
|
||||||
forwardingEngine: 'nftables',
|
forwardingEngine: 'nftables',
|
||||||
nftables: {
|
nftables: {
|
||||||
protocol: 'tcp',
|
protocol: 'tcp',
|
||||||
@@ -115,10 +115,10 @@ tap.skip.test('NFTablesManager route updating test', async () => {
|
|||||||
...sampleRoute,
|
...sampleRoute,
|
||||||
action: {
|
action: {
|
||||||
...sampleRoute.action,
|
...sampleRoute.action,
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9000 // Different port
|
port: 9000 // Different port
|
||||||
},
|
}],
|
||||||
nftables: {
|
nftables: {
|
||||||
...sampleRoute.action.nftables,
|
...sampleRoute.action.nftables,
|
||||||
protocol: 'all' // Different protocol
|
protocol: 'all' // Different protocol
|
||||||
@@ -147,10 +147,10 @@ tap.skip.test('NFTablesManager route deprovisioning test', async () => {
|
|||||||
...sampleRoute,
|
...sampleRoute,
|
||||||
action: {
|
action: {
|
||||||
...sampleRoute.action,
|
...sampleRoute.action,
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9000 // Different port from original test
|
port: 9000 // Different port from original test
|
||||||
},
|
}],
|
||||||
nftables: {
|
nftables: {
|
||||||
...sampleRoute.action.nftables,
|
...sampleRoute.action.nftables,
|
||||||
protocol: 'all' // Different protocol from original test
|
protocol: 'all' // Different protocol from original test
|
||||||
|
@@ -91,7 +91,7 @@ testFn('SmartProxy getNfTablesStatus functionality', async () => {
|
|||||||
match: { ports: 3004 },
|
match: { ports: 3004 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 3005 }
|
targets: [{ host: 'localhost', port: 3005 }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@@ -29,7 +29,7 @@ tap.test('port forwarding should not immediately close connections', async (tool
|
|||||||
match: { ports: 9999 },
|
match: { ports: 9999 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 8888 }
|
targets: [{ host: 'localhost', port: 8888 }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -63,7 +63,7 @@ tap.test('TLS passthrough should work correctly', async () => {
|
|||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
tls: { mode: 'passthrough' },
|
tls: { mode: 'passthrough' },
|
||||||
target: { host: 'localhost', port: 443 }
|
targets: [{ host: 'localhost', port: 443 }]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -214,12 +214,12 @@ tap.test('should handle errors in port mapping functions', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: () => {
|
port: () => {
|
||||||
throw new Error('Test error in port mapping function');
|
throw new Error('Test error in port mapping function');
|
||||||
}
|
}
|
||||||
}
|
}]
|
||||||
},
|
},
|
||||||
name: 'Error Route'
|
name: 'Error Route'
|
||||||
};
|
};
|
||||||
|
@@ -21,7 +21,7 @@ tap.test('should not double-register port 80 when user route and ACME use same p
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward' as const,
|
type: 'forward' as const,
|
||||||
target: { host: 'localhost', port: 3000 }
|
targets: [{ host: 'localhost', port: 3000 }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,7 +31,7 @@ tap.test('should not double-register port 80 when user route and ACME use same p
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward' as const,
|
type: 'forward' as const,
|
||||||
target: { host: 'localhost', port: 3001 },
|
targets: [{ host: 'localhost', port: 3001 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate' as const,
|
mode: 'terminate' as const,
|
||||||
certificate: 'auto' as const
|
certificate: 'auto' as const
|
||||||
@@ -153,7 +153,7 @@ tap.test('should handle ACME on different port than user routes', async (tools)
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward' as const,
|
type: 'forward' as const,
|
||||||
target: { host: 'localhost', port: 3000 }
|
targets: [{ host: 'localhost', port: 3000 }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -163,7 +163,7 @@ tap.test('should handle ACME on different port than user routes', async (tools)
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward' as const,
|
type: 'forward' as const,
|
||||||
target: { host: 'localhost', port: 3001 },
|
targets: [{ host: 'localhost', port: 3001 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate' as const,
|
mode: 'terminate' as const,
|
||||||
certificate: 'auto' as const
|
certificate: 'auto' as const
|
||||||
|
@@ -15,10 +15,10 @@ tap.test('setup two smartproxies in a chain configuration', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'httpbin.org',
|
host: 'httpbin.org',
|
||||||
port: 443
|
port: 443
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -45,10 +45,10 @@ tap.test('setup two smartproxies in a chain configuration', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8002
|
port: 8002
|
||||||
},
|
}],
|
||||||
sendProxyProtocol: true
|
sendProxyProtocol: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -32,10 +32,10 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
|
|||||||
match: { ports: 8591 },
|
match: { ports: 8591 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9998 // Backend that closes immediately
|
port: 9998 // Backend that closes immediately
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -50,10 +50,10 @@ tap.test('simple proxy chain test - identify connection accumulation', async ()
|
|||||||
match: { ports: 8590 },
|
match: { ports: 8590 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8591 // Forward to proxy2
|
port: 8591 // Forward to proxy2
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -19,10 +19,10 @@ tap.test('should handle proxy chaining without connection accumulation', async (
|
|||||||
match: { ports: 8581 },
|
match: { ports: 8581 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9999 // Non-existent backend
|
port: 9999 // Non-existent backend
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -37,10 +37,10 @@ tap.test('should handle proxy chaining without connection accumulation', async (
|
|||||||
match: { ports: 8580 },
|
match: { ports: 8580 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8581 // Forward to proxy2
|
port: 8581 // Forward to proxy2
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -270,10 +270,10 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
|
|||||||
match: { ports: 8583 },
|
match: { ports: 8583 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9999 // Non-existent backend
|
port: 9999 // Non-existent backend
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -289,10 +289,10 @@ tap.test('should handle proxy chain with HTTP traffic', async () => {
|
|||||||
match: { ports: 8582 },
|
match: { ports: 8582 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8583 // Forward to proxy2
|
port: 8583 // Forward to proxy2
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -19,10 +19,10 @@ tap.test('should handle rapid connection retries without leaking connections', a
|
|||||||
match: { ports: 8550 },
|
match: { ports: 8550 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9999 // Non-existent port to force connection failures
|
port: 9999 // Non-existent port to force connection failures
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -17,7 +17,7 @@ tap.test('should set update routes callback on certificate manager', async () =>
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 3000 },
|
targets: [{ host: 'localhost', port: 3000 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
@@ -95,7 +95,7 @@ tap.test('should set update routes callback on certificate manager', async () =>
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 3001 },
|
targets: [{ host: 'localhost', port: 3001 }],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
|
@@ -28,10 +28,10 @@ tap.test('route security should block connections from unauthorized IPs', async
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 9990
|
port: 9990
|
||||||
}
|
}]
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
// Only allow a non-existent IP
|
// Only allow a non-existent IP
|
||||||
@@ -142,10 +142,10 @@ tap.test('route security with block list should work', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 9992
|
port: 9992
|
||||||
}
|
}]
|
||||||
},
|
},
|
||||||
security: { // Security at route level, not action level
|
security: { // Security at route level, not action level
|
||||||
ipBlockList: ['127.0.0.1', '::1', '::ffff:127.0.0.1']
|
ipBlockList: ['127.0.0.1', '::1', '::ffff:127.0.0.1']
|
||||||
@@ -234,10 +234,10 @@ tap.test('route without security should allow all connections', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 9994
|
port: 9994
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
// No security defined
|
// No security defined
|
||||||
}];
|
}];
|
||||||
|
@@ -10,10 +10,10 @@ tap.test('route security should be correctly configured', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward' as const,
|
type: 'forward' as const,
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 8991
|
port: 8991
|
||||||
},
|
}],
|
||||||
security: {
|
security: {
|
||||||
ipAllowList: ['192.168.1.1'],
|
ipAllowList: ['192.168.1.1'],
|
||||||
ipBlockList: ['10.0.0.1']
|
ipBlockList: ['10.0.0.1']
|
||||||
|
@@ -26,10 +26,10 @@ tap.test('route-specific security should be enforced', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 8877
|
port: 8877
|
||||||
}
|
}]
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1']
|
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1']
|
||||||
@@ -108,10 +108,10 @@ tap.test('route-specific IP block list should be enforced', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 8879
|
port: 8879
|
||||||
}
|
}]
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
ipAllowList: ['0.0.0.0/0', '::/0'], // Allow all IPs
|
ipAllowList: ['0.0.0.0/0', '::/0'], // Allow all IPs
|
||||||
@@ -215,10 +215,10 @@ tap.test('routes without security should allow all connections', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 8881
|
port: 8881
|
||||||
}
|
}]
|
||||||
// No security section - should allow all
|
// No security section - should allow all
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
|
@@ -13,10 +13,10 @@ const createRoute = (id: number, domain: string, port: number = 8443) => ({
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward' as const,
|
type: 'forward' as const,
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 3000 + id
|
port: 3000 + id
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate' as const,
|
mode: 'terminate' as const,
|
||||||
certificate: 'auto' as const,
|
certificate: 'auto' as const,
|
||||||
@@ -209,10 +209,10 @@ tap.test('should handle route updates when cert manager is not initialized', asy
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward' as const,
|
type: 'forward' as const,
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 3000
|
port: 3000
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -47,7 +47,7 @@ import {
|
|||||||
addRateLimiting,
|
addRateLimiting,
|
||||||
addBasicAuth,
|
addBasicAuth,
|
||||||
addJwtAuth
|
addJwtAuth
|
||||||
} from '../ts/proxies/smart-proxy/utils/route-patterns.js';
|
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
IRouteConfig,
|
IRouteConfig,
|
||||||
|
@@ -37,10 +37,10 @@ function createRouteConfig(
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: destinationIp,
|
host: destinationIp,
|
||||||
port: destinationPort
|
port: destinationPort
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -15,10 +15,10 @@ tap.test('should create a SmartCertManager instance', async () => {
|
|||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 3000
|
port: 3000
|
||||||
},
|
}],
|
||||||
tls: {
|
tls: {
|
||||||
mode: 'terminate',
|
mode: 'terminate',
|
||||||
certificate: 'auto',
|
certificate: 'auto',
|
||||||
|
@@ -30,7 +30,7 @@ tap.test('stuck connection cleanup - verify connections to hanging backends are
|
|||||||
match: { ports: 8589 },
|
match: { ports: 8589 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 9997 }
|
targets: [{ host: 'localhost', port: 9997 }]
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
|
@@ -17,7 +17,7 @@ tap.test('websocket keep-alive settings for SNI passthrough', async (tools) => {
|
|||||||
match: { ports: 8443, domains: 'test.local' },
|
match: { ports: 8443, domains: 'test.local' },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 9443 },
|
targets: [{ host: 'localhost', port: 9443 }],
|
||||||
tls: { mode: 'passthrough' }
|
tls: { mode: 'passthrough' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ tap.test('long-lived connection survival test', async (tools) => {
|
|||||||
match: { ports: 8444 },
|
match: { ports: 8444 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: { host: 'localhost', port: 9444 }
|
targets: [{ host: 'localhost', port: 9444 }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@@ -52,10 +52,10 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
|
|||||||
match: { ports: 8591 },
|
match: { ports: 8591 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 9998
|
port: 9998
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
@@ -71,10 +71,10 @@ tap.test('zombie connection cleanup - verify inactivity check detects and cleans
|
|||||||
match: { ports: 8590 },
|
match: { ports: 8590 },
|
||||||
action: {
|
action: {
|
||||||
type: 'forward',
|
type: 'forward',
|
||||||
target: {
|
targets: [{
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 8591
|
port: 8591
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
@@ -1,161 +1,44 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { ProxyProtocolParser as ProtocolParser, type IProxyInfo, type IProxyParseResult } from '../../protocols/proxy/index.js';
|
||||||
|
|
||||||
/**
|
// Re-export types from protocols for backward compatibility
|
||||||
* Interface representing parsed PROXY protocol information
|
export type { IProxyInfo, IProxyParseResult } from '../../protocols/proxy/index.js';
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parser for PROXY protocol v1 (text format)
|
* Parser for PROXY protocol v1 (text format)
|
||||||
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
|
* 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 {
|
export class ProxyProtocolParser {
|
||||||
static readonly PROXY_V1_SIGNATURE = 'PROXY ';
|
static readonly PROXY_V1_SIGNATURE = ProtocolParser.PROXY_V1_SIGNATURE;
|
||||||
static readonly MAX_HEADER_LENGTH = 107; // Max length for v1 header
|
static readonly MAX_HEADER_LENGTH = ProtocolParser.MAX_HEADER_LENGTH;
|
||||||
static readonly HEADER_TERMINATOR = '\r\n';
|
static readonly HEADER_TERMINATOR = ProtocolParser.HEADER_TERMINATOR;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse PROXY protocol v1 header from buffer
|
* Parse PROXY protocol v1 header from buffer
|
||||||
* Returns proxy info and remaining data after header
|
* Returns proxy info and remaining data after header
|
||||||
*/
|
*/
|
||||||
static parse(data: Buffer): IProxyParseResult {
|
static parse(data: Buffer): IProxyParseResult {
|
||||||
// Check if buffer starts with PROXY signature
|
// Delegate to protocol parser
|
||||||
if (!data.toString('ascii', 0, 6).startsWith(this.PROXY_V1_SIGNATURE)) {
|
return ProtocolParser.parse(data);
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate PROXY protocol v1 header
|
* Generate PROXY protocol v1 header
|
||||||
*/
|
*/
|
||||||
static generate(info: IProxyInfo): Buffer {
|
static generate(info: IProxyInfo): Buffer {
|
||||||
if (info.protocol === 'UNKNOWN') {
|
// Delegate to protocol parser
|
||||||
return Buffer.from(`PROXY UNKNOWN\r\n`, 'ascii');
|
return ProtocolParser.generate(info);
|
||||||
}
|
|
||||||
|
|
||||||
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
|
* Validate IP address format
|
||||||
*/
|
*/
|
||||||
private static isValidIP(ip: string, protocol: 'TCP4' | 'TCP6' | 'UNKNOWN'): boolean {
|
private static isValidIP(ip: string, protocol: 'TCP4' | 'TCP6' | 'UNKNOWN'): boolean {
|
||||||
if (protocol === 'TCP4') {
|
return ProtocolParser.isValidIP(ip, protocol);
|
||||||
return plugins.net.isIPv4(ip);
|
|
||||||
} else if (protocol === 'TCP6') {
|
|
||||||
return plugins.net.isIPv6(ip);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* WebSocket utility functions
|
* WebSocket utility functions
|
||||||
|
*
|
||||||
|
* This module provides smartproxy-specific WebSocket utilities
|
||||||
|
* and re-exports protocol utilities from the protocols module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
// Import and re-export from protocols
|
||||||
* Type for WebSocket RawData that can be different types in different environments
|
import { getMessageSize as protocolGetMessageSize, toBuffer as protocolToBuffer } from '../../protocols/websocket/index.js';
|
||||||
* This matches the ws library's type definition
|
export type { RawData } from '../../protocols/websocket/index.js';
|
||||||
*/
|
|
||||||
export type RawData = Buffer | ArrayBuffer | Buffer[] | any;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the length of a WebSocket message regardless of its type
|
* 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)
|
* @param data - The data message from WebSocket (could be any RawData type)
|
||||||
* @returns The length of the data in bytes
|
* @returns The length of the data in bytes
|
||||||
*/
|
*/
|
||||||
export function getMessageSize(data: RawData): number {
|
export function getMessageSize(data: import('../../protocols/websocket/index.js').RawData): number {
|
||||||
if (typeof data === 'string') {
|
// Delegate to protocol implementation
|
||||||
// For string data, get the byte length
|
return protocolGetMessageSize(data);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,30 +27,7 @@ export function getMessageSize(data: RawData): number {
|
|||||||
* @param data - The data message from WebSocket (could be any RawData type)
|
* @param data - The data message from WebSocket (could be any RawData type)
|
||||||
* @returns A Buffer containing the data
|
* @returns A Buffer containing the data
|
||||||
*/
|
*/
|
||||||
export function toBuffer(data: RawData): Buffer {
|
export function toBuffer(data: import('../../protocols/websocket/index.js').RawData): Buffer {
|
||||||
if (typeof data === 'string') {
|
// Delegate to protocol implementation
|
||||||
return Buffer.from(data, 'utf8');
|
return protocolToBuffer(data);
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
281
ts/detection/detectors/http-detector.ts
Normal file
281
ts/detection/detectors/http-detector.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* HTTP protocol detector
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IProtocolDetector } from '../models/interfaces.js';
|
||||||
|
import type { IDetectionResult, IDetectionOptions, IConnectionInfo, THttpMethod } from '../models/detection-types.js';
|
||||||
|
import { extractLine, isPrintableAscii, BufferAccumulator } from '../utils/buffer-utils.js';
|
||||||
|
import { parseHttpRequestLine, parseHttpHeaders, extractDomainFromHost, isHttpMethod } from '../utils/parser-utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP detector implementation
|
||||||
|
*/
|
||||||
|
export class HttpDetector implements IProtocolDetector {
|
||||||
|
/**
|
||||||
|
* Minimum bytes needed to identify HTTP method
|
||||||
|
*/
|
||||||
|
private static readonly MIN_HTTP_METHOD_SIZE = 3; // GET
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum reasonable HTTP header size
|
||||||
|
*/
|
||||||
|
private static readonly MAX_HEADER_SIZE = 8192;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment tracking for incomplete headers
|
||||||
|
*/
|
||||||
|
private static fragmentedBuffers = new Map<string, BufferAccumulator>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect HTTP protocol from buffer
|
||||||
|
*/
|
||||||
|
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null {
|
||||||
|
// Check if buffer is too small
|
||||||
|
if (buffer.length < HttpDetector.MIN_HTTP_METHOD_SIZE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick check: first bytes should be printable ASCII
|
||||||
|
if (!isPrintableAscii(buffer, Math.min(20, buffer.length))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract the first line
|
||||||
|
const firstLineResult = extractLine(buffer, 0);
|
||||||
|
if (!firstLineResult) {
|
||||||
|
// No complete line yet
|
||||||
|
return {
|
||||||
|
protocol: 'http',
|
||||||
|
connectionInfo: { protocol: 'http' },
|
||||||
|
isComplete: false,
|
||||||
|
bytesNeeded: buffer.length + 100 // Estimate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the request line
|
||||||
|
const requestLine = parseHttpRequestLine(firstLineResult.line);
|
||||||
|
if (!requestLine) {
|
||||||
|
// Not a valid HTTP request line
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize connection info
|
||||||
|
const connectionInfo: IConnectionInfo = {
|
||||||
|
protocol: 'http',
|
||||||
|
method: requestLine.method,
|
||||||
|
path: requestLine.path,
|
||||||
|
httpVersion: requestLine.version
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if we want to extract headers
|
||||||
|
if (options?.extractFullHeaders !== false) {
|
||||||
|
// Look for the end of headers (double CRLF)
|
||||||
|
const headerEndSequence = Buffer.from('\r\n\r\n');
|
||||||
|
const headerEndIndex = buffer.indexOf(headerEndSequence);
|
||||||
|
|
||||||
|
if (headerEndIndex === -1) {
|
||||||
|
// Headers not complete yet
|
||||||
|
const maxSize = options?.maxBufferSize || HttpDetector.MAX_HEADER_SIZE;
|
||||||
|
if (buffer.length >= maxSize) {
|
||||||
|
// Headers too large, reject
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: 'http',
|
||||||
|
connectionInfo,
|
||||||
|
isComplete: false,
|
||||||
|
bytesNeeded: buffer.length + 200 // Estimate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract all header lines
|
||||||
|
const headerLines: string[] = [];
|
||||||
|
let currentOffset = firstLineResult.nextOffset;
|
||||||
|
|
||||||
|
while (currentOffset < headerEndIndex) {
|
||||||
|
const lineResult = extractLine(buffer, currentOffset);
|
||||||
|
if (!lineResult) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineResult.line.length === 0) {
|
||||||
|
// Empty line marks end of headers
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
headerLines.push(lineResult.line);
|
||||||
|
currentOffset = lineResult.nextOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse headers
|
||||||
|
const headers = parseHttpHeaders(headerLines);
|
||||||
|
connectionInfo.headers = headers;
|
||||||
|
|
||||||
|
// Extract domain from Host header
|
||||||
|
const hostHeader = headers['host'];
|
||||||
|
if (hostHeader) {
|
||||||
|
connectionInfo.domain = extractDomainFromHost(hostHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate remaining buffer
|
||||||
|
const bodyStartIndex = headerEndIndex + 4; // After \r\n\r\n
|
||||||
|
const remainingBuffer = buffer.length > bodyStartIndex
|
||||||
|
? buffer.slice(bodyStartIndex)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: 'http',
|
||||||
|
connectionInfo,
|
||||||
|
remainingBuffer,
|
||||||
|
isComplete: true
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Just extract Host header for domain
|
||||||
|
let currentOffset = firstLineResult.nextOffset;
|
||||||
|
const maxLines = 50; // Reasonable limit
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLines && currentOffset < buffer.length; i++) {
|
||||||
|
const lineResult = extractLine(buffer, currentOffset);
|
||||||
|
if (!lineResult) {
|
||||||
|
// Need more data
|
||||||
|
return {
|
||||||
|
protocol: 'http',
|
||||||
|
connectionInfo,
|
||||||
|
isComplete: false,
|
||||||
|
bytesNeeded: buffer.length + 50
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineResult.line.length === 0) {
|
||||||
|
// End of headers
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick check for Host header
|
||||||
|
if (lineResult.line.toLowerCase().startsWith('host:')) {
|
||||||
|
const colonIndex = lineResult.line.indexOf(':');
|
||||||
|
const hostValue = lineResult.line.slice(colonIndex + 1).trim();
|
||||||
|
connectionInfo.domain = extractDomainFromHost(hostValue);
|
||||||
|
|
||||||
|
// If we only needed the domain, we can return early
|
||||||
|
return {
|
||||||
|
protocol: 'http',
|
||||||
|
connectionInfo,
|
||||||
|
isComplete: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOffset = lineResult.nextOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, no Host header found yet
|
||||||
|
return {
|
||||||
|
protocol: 'http',
|
||||||
|
connectionInfo,
|
||||||
|
isComplete: false,
|
||||||
|
bytesNeeded: buffer.length + 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if buffer can be handled by this detector
|
||||||
|
*/
|
||||||
|
canHandle(buffer: Buffer): boolean {
|
||||||
|
if (buffer.length < HttpDetector.MIN_HTTP_METHOD_SIZE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first bytes could be an HTTP method
|
||||||
|
const firstWord = buffer.slice(0, Math.min(10, buffer.length)).toString('ascii').split(' ')[0];
|
||||||
|
return isHttpMethod(firstWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get minimum bytes needed for detection
|
||||||
|
*/
|
||||||
|
getMinimumBytes(): number {
|
||||||
|
return HttpDetector.MIN_HTTP_METHOD_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle fragmented HTTP detection with connection tracking
|
||||||
|
*/
|
||||||
|
static detectWithFragments(
|
||||||
|
buffer: Buffer,
|
||||||
|
connectionId: string,
|
||||||
|
options?: IDetectionOptions
|
||||||
|
): IDetectionResult | null {
|
||||||
|
const detector = new HttpDetector();
|
||||||
|
|
||||||
|
// Try direct detection first
|
||||||
|
const directResult = detector.detect(buffer, options);
|
||||||
|
if (directResult && directResult.isComplete) {
|
||||||
|
// Clean up any tracked fragments for this connection
|
||||||
|
this.fragmentedBuffers.delete(connectionId);
|
||||||
|
return directResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle fragmentation
|
||||||
|
let accumulator = this.fragmentedBuffers.get(connectionId);
|
||||||
|
if (!accumulator) {
|
||||||
|
accumulator = new BufferAccumulator();
|
||||||
|
this.fragmentedBuffers.set(connectionId, accumulator);
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulator.append(buffer);
|
||||||
|
const fullBuffer = accumulator.getBuffer();
|
||||||
|
|
||||||
|
// Check size limit
|
||||||
|
const maxSize = options?.maxBufferSize || this.MAX_HEADER_SIZE;
|
||||||
|
if (fullBuffer.length > maxSize) {
|
||||||
|
// Too large, clean up and reject
|
||||||
|
this.fragmentedBuffers.delete(connectionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try detection on accumulated buffer
|
||||||
|
const result = detector.detect(fullBuffer, options);
|
||||||
|
|
||||||
|
if (result && result.isComplete) {
|
||||||
|
// Success - clean up
|
||||||
|
this.fragmentedBuffers.delete(connectionId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old fragment buffers
|
||||||
|
*/
|
||||||
|
static cleanupFragments(maxAge: number = 5000): void {
|
||||||
|
// TODO: Add timestamp tracking to BufferAccumulator for cleanup
|
||||||
|
// For now, just clear if too many connections
|
||||||
|
if (this.fragmentedBuffers.size > 1000) {
|
||||||
|
this.fragmentedBuffers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
259
ts/detection/detectors/tls-detector.ts
Normal file
259
ts/detection/detectors/tls-detector.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* 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, readUInt24BE, BufferAccumulator } 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment tracking for incomplete handshakes
|
||||||
|
*/
|
||||||
|
private static fragmentedBuffers = new Map<string, BufferAccumulator>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(data: Buffer): number[] {
|
||||||
|
const suites: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i + 1 < data.length; i += 2) {
|
||||||
|
const suite = readUInt16BE(data, i);
|
||||||
|
suites.push(suite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return suites;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle fragmented TLS detection with connection tracking
|
||||||
|
*/
|
||||||
|
static detectWithFragments(
|
||||||
|
buffer: Buffer,
|
||||||
|
connectionId: string,
|
||||||
|
options?: IDetectionOptions
|
||||||
|
): IDetectionResult | null {
|
||||||
|
const detector = new TlsDetector();
|
||||||
|
|
||||||
|
// Try direct detection first
|
||||||
|
const directResult = detector.detect(buffer, options);
|
||||||
|
if (directResult && directResult.isComplete) {
|
||||||
|
// Clean up any tracked fragments for this connection
|
||||||
|
this.fragmentedBuffers.delete(connectionId);
|
||||||
|
return directResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle fragmentation
|
||||||
|
let accumulator = this.fragmentedBuffers.get(connectionId);
|
||||||
|
if (!accumulator) {
|
||||||
|
accumulator = new BufferAccumulator();
|
||||||
|
this.fragmentedBuffers.set(connectionId, accumulator);
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulator.append(buffer);
|
||||||
|
const fullBuffer = accumulator.getBuffer();
|
||||||
|
|
||||||
|
// Try detection on accumulated buffer
|
||||||
|
const result = detector.detect(fullBuffer, options);
|
||||||
|
|
||||||
|
if (result && result.isComplete) {
|
||||||
|
// Success - clean up
|
||||||
|
this.fragmentedBuffers.delete(connectionId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check timeout
|
||||||
|
if (options?.timeout) {
|
||||||
|
// TODO: Implement timeout handling
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
22
ts/detection/index.ts
Normal file
22
ts/detection/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
export * from './utils/buffer-utils.js';
|
||||||
|
export * from './utils/parser-utils.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;
|
||||||
|
}
|
222
ts/detection/protocol-detector.ts
Normal file
222
ts/detection/protocol-detector.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Main protocol detector that orchestrates detection across different protocols
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IDetectionResult, IDetectionOptions, IConnectionInfo } from './models/detection-types.js';
|
||||||
|
import { TlsDetector } from './detectors/tls-detector.js';
|
||||||
|
import { HttpDetector } from './detectors/http-detector.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main protocol detector class
|
||||||
|
*/
|
||||||
|
export class ProtocolDetector {
|
||||||
|
/**
|
||||||
|
* Connection tracking for fragmented detection
|
||||||
|
*/
|
||||||
|
private static connectionTracking = new Map<string, {
|
||||||
|
startTime: number;
|
||||||
|
protocol?: 'tls' | 'http' | 'unknown';
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect protocol from buffer data
|
||||||
|
*
|
||||||
|
* @param buffer The buffer to analyze
|
||||||
|
* @param options Detection options
|
||||||
|
* @returns Detection result with protocol information
|
||||||
|
*/
|
||||||
|
static async detect(
|
||||||
|
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)
|
||||||
|
const tlsDetector = new TlsDetector();
|
||||||
|
if (tlsDetector.canHandle(buffer)) {
|
||||||
|
const tlsResult = tlsDetector.detect(buffer, options);
|
||||||
|
if (tlsResult) {
|
||||||
|
return tlsResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try HTTP detection
|
||||||
|
const httpDetector = new HttpDetector();
|
||||||
|
if (httpDetector.canHandle(buffer)) {
|
||||||
|
const httpResult = 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
|
||||||
|
*
|
||||||
|
* @param buffer The buffer to analyze
|
||||||
|
* @param connectionId Unique connection identifier
|
||||||
|
* @param options Detection options
|
||||||
|
* @returns Detection result with protocol information
|
||||||
|
*/
|
||||||
|
static async detectWithConnectionTracking(
|
||||||
|
buffer: Buffer,
|
||||||
|
connectionId: string,
|
||||||
|
options?: IDetectionOptions
|
||||||
|
): Promise<IDetectionResult> {
|
||||||
|
// Initialize or get connection tracking
|
||||||
|
let tracking = this.connectionTracking.get(connectionId);
|
||||||
|
if (!tracking) {
|
||||||
|
tracking = { startTime: Date.now() };
|
||||||
|
this.connectionTracking.set(connectionId, tracking);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check timeout
|
||||||
|
if (options?.timeout) {
|
||||||
|
const elapsed = Date.now() - tracking.startTime;
|
||||||
|
if (elapsed > options.timeout) {
|
||||||
|
// Timeout - clean up and return unknown
|
||||||
|
this.connectionTracking.delete(connectionId);
|
||||||
|
TlsDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
|
||||||
|
HttpDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: 'unknown',
|
||||||
|
connectionInfo: { protocol: 'unknown' },
|
||||||
|
isComplete: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we already know the protocol, use the appropriate detector
|
||||||
|
if (tracking.protocol === 'tls') {
|
||||||
|
const result = TlsDetector.detectWithFragments(buffer, connectionId, options);
|
||||||
|
if (result && result.isComplete) {
|
||||||
|
this.connectionTracking.delete(connectionId);
|
||||||
|
}
|
||||||
|
return result || {
|
||||||
|
protocol: 'unknown',
|
||||||
|
connectionInfo: { protocol: 'unknown' },
|
||||||
|
isComplete: true
|
||||||
|
};
|
||||||
|
} else if (tracking.protocol === 'http') {
|
||||||
|
const result = HttpDetector.detectWithFragments(buffer, connectionId, options);
|
||||||
|
if (result && result.isComplete) {
|
||||||
|
this.connectionTracking.delete(connectionId);
|
||||||
|
}
|
||||||
|
return result || {
|
||||||
|
protocol: 'unknown',
|
||||||
|
connectionInfo: { protocol: 'unknown' },
|
||||||
|
isComplete: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// First time detection - try to determine protocol
|
||||||
|
// Quick checks first
|
||||||
|
if (buffer.length > 0) {
|
||||||
|
// TLS always starts with specific byte values
|
||||||
|
if (buffer[0] >= 0x14 && buffer[0] <= 0x18) {
|
||||||
|
tracking.protocol = 'tls';
|
||||||
|
const result = TlsDetector.detectWithFragments(buffer, connectionId, options);
|
||||||
|
if (result) {
|
||||||
|
if (result.isComplete) {
|
||||||
|
this.connectionTracking.delete(connectionId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HTTP starts with ASCII text
|
||||||
|
else if (HttpDetector.quickCheck(buffer)) {
|
||||||
|
tracking.protocol = 'http';
|
||||||
|
const result = HttpDetector.detectWithFragments(buffer, connectionId, options);
|
||||||
|
if (result) {
|
||||||
|
if (result.isComplete) {
|
||||||
|
this.connectionTracking.delete(connectionId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't determine protocol yet
|
||||||
|
return {
|
||||||
|
protocol: 'unknown',
|
||||||
|
connectionInfo: { protocol: 'unknown' },
|
||||||
|
isComplete: false,
|
||||||
|
bytesNeeded: 10 // Need more data to determine protocol
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old connection tracking entries
|
||||||
|
*
|
||||||
|
* @param maxAge Maximum age in milliseconds (default: 30 seconds)
|
||||||
|
*/
|
||||||
|
static cleanupConnections(maxAge: number = 30000): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
|
||||||
|
for (const [connectionId, tracking] of this.connectionTracking.entries()) {
|
||||||
|
if (now - tracking.startTime > maxAge) {
|
||||||
|
toDelete.push(connectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const connectionId of toDelete) {
|
||||||
|
this.connectionTracking.delete(connectionId);
|
||||||
|
// Also clean up detector-specific buffers
|
||||||
|
TlsDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
|
||||||
|
HttpDetector.detectWithFragments(Buffer.alloc(0), connectionId); // Force cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also trigger cleanup in detectors
|
||||||
|
HttpDetector.cleanupFragments(maxAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract domain from connection info
|
||||||
|
*
|
||||||
|
* @param connectionInfo Connection information from detection
|
||||||
|
* @returns The domain/hostname if found
|
||||||
|
*/
|
||||||
|
static extractDomain(connectionInfo: IConnectionInfo): string | undefined {
|
||||||
|
// For both TLS and HTTP, domain is stored in the domain field
|
||||||
|
return connectionInfo.domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a connection ID from connection parameters
|
||||||
|
*
|
||||||
|
* @param params Connection parameters
|
||||||
|
* @returns A unique connection identifier
|
||||||
|
*/
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
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);
|
||||||
|
}
|
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;
|
||||||
|
}
|
||||||
|
|
@@ -1,76 +0,0 @@
|
|||||||
import type * as plugins from '../../plugins.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The primary forwarding types supported by SmartProxy
|
|
||||||
* Used for configuration compatibility
|
|
||||||
*/
|
|
||||||
export type TForwardingType =
|
|
||||||
| 'http-only' // HTTP forwarding only (no HTTPS)
|
|
||||||
| 'https-passthrough' // Pass-through TLS traffic (SNI forwarding)
|
|
||||||
| 'https-terminate-to-http' // Terminate TLS and forward to HTTP backend
|
|
||||||
| 'https-terminate-to-https'; // Terminate TLS and forward to HTTPS backend
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event types emitted by forwarding handlers
|
|
||||||
*/
|
|
||||||
export enum ForwardingHandlerEvents {
|
|
||||||
CONNECTED = 'connected',
|
|
||||||
DISCONNECTED = 'disconnected',
|
|
||||||
ERROR = 'error',
|
|
||||||
DATA_FORWARDED = 'data-forwarded',
|
|
||||||
HTTP_REQUEST = 'http-request',
|
|
||||||
HTTP_RESPONSE = 'http-response',
|
|
||||||
CERTIFICATE_NEEDED = 'certificate-needed',
|
|
||||||
CERTIFICATE_LOADED = 'certificate-loaded'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base interface for forwarding handlers
|
|
||||||
*/
|
|
||||||
export interface IForwardingHandler extends plugins.EventEmitter {
|
|
||||||
initialize(): Promise<void>;
|
|
||||||
handleConnection(socket: plugins.net.Socket): void;
|
|
||||||
handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route-based helpers are now available directly from route-patterns.ts
|
|
||||||
import {
|
|
||||||
createHttpRoute,
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createHttpsPassthroughRoute,
|
|
||||||
createHttpToHttpsRedirect,
|
|
||||||
createCompleteHttpsServer,
|
|
||||||
createLoadBalancerRoute
|
|
||||||
} from '../../proxies/smart-proxy/utils/route-patterns.js';
|
|
||||||
|
|
||||||
export {
|
|
||||||
createHttpRoute,
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createHttpsPassthroughRoute,
|
|
||||||
createHttpToHttpsRedirect,
|
|
||||||
createCompleteHttpsServer,
|
|
||||||
createLoadBalancerRoute
|
|
||||||
};
|
|
||||||
|
|
||||||
// Note: Legacy helper functions have been removed
|
|
||||||
// Please use the route-based helpers instead:
|
|
||||||
// - createHttpRoute
|
|
||||||
// - createHttpsTerminateRoute
|
|
||||||
// - createHttpsPassthroughRoute
|
|
||||||
// - createHttpToHttpsRedirect
|
|
||||||
import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js';
|
|
||||||
|
|
||||||
// For backward compatibility, kept only the basic configuration interface
|
|
||||||
export interface IForwardConfig {
|
|
||||||
type: TForwardingType;
|
|
||||||
target: {
|
|
||||||
host: string | string[];
|
|
||||||
port: number | 'preserve' | ((ctx: any) => number);
|
|
||||||
};
|
|
||||||
http?: any;
|
|
||||||
https?: any;
|
|
||||||
acme?: any;
|
|
||||||
security?: any;
|
|
||||||
advanced?: any;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Forwarding configuration exports
|
|
||||||
*
|
|
||||||
* Note: The legacy domain-based configuration has been replaced by route-based configuration.
|
|
||||||
* See /ts/proxies/smart-proxy/models/route-types.ts for the new route-based configuration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type {
|
|
||||||
TForwardingType,
|
|
||||||
IForwardConfig,
|
|
||||||
IForwardingHandler
|
|
||||||
} from './forwarding-types.js';
|
|
||||||
|
|
||||||
export {
|
|
||||||
ForwardingHandlerEvents
|
|
||||||
} from './forwarding-types.js';
|
|
||||||
|
|
||||||
// Import route helpers from route-patterns instead of deleted route-helpers
|
|
||||||
export {
|
|
||||||
createHttpRoute,
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createHttpsPassthroughRoute,
|
|
||||||
createHttpToHttpsRedirect,
|
|
||||||
createCompleteHttpsServer,
|
|
||||||
createLoadBalancerRoute
|
|
||||||
} from '../../proxies/smart-proxy/utils/route-patterns.js';
|
|
@@ -1,189 +0,0 @@
|
|||||||
import type { IForwardConfig } from '../config/forwarding-types.js';
|
|
||||||
import { ForwardingHandler } from '../handlers/base-handler.js';
|
|
||||||
import { HttpForwardingHandler } from '../handlers/http-handler.js';
|
|
||||||
import { HttpsPassthroughHandler } from '../handlers/https-passthrough-handler.js';
|
|
||||||
import { HttpsTerminateToHttpHandler } from '../handlers/https-terminate-to-http-handler.js';
|
|
||||||
import { HttpsTerminateToHttpsHandler } from '../handlers/https-terminate-to-https-handler.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory for creating forwarding handlers based on the configuration type
|
|
||||||
*/
|
|
||||||
export class ForwardingHandlerFactory {
|
|
||||||
/**
|
|
||||||
* Create a forwarding handler based on the configuration
|
|
||||||
* @param config The forwarding configuration
|
|
||||||
* @returns The appropriate forwarding handler
|
|
||||||
*/
|
|
||||||
public static createHandler(config: IForwardConfig): ForwardingHandler {
|
|
||||||
// Create the appropriate handler based on the forwarding type
|
|
||||||
switch (config.type) {
|
|
||||||
case 'http-only':
|
|
||||||
return new HttpForwardingHandler(config);
|
|
||||||
|
|
||||||
case 'https-passthrough':
|
|
||||||
return new HttpsPassthroughHandler(config);
|
|
||||||
|
|
||||||
case 'https-terminate-to-http':
|
|
||||||
return new HttpsTerminateToHttpHandler(config);
|
|
||||||
|
|
||||||
case 'https-terminate-to-https':
|
|
||||||
return new HttpsTerminateToHttpsHandler(config);
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Type system should prevent this, but just in case:
|
|
||||||
throw new Error(`Unknown forwarding type: ${(config as any).type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply default values to a forwarding configuration based on its type
|
|
||||||
* @param config The original forwarding configuration
|
|
||||||
* @returns A configuration with defaults applied
|
|
||||||
*/
|
|
||||||
public static applyDefaults(config: IForwardConfig): IForwardConfig {
|
|
||||||
// Create a deep copy of the configuration
|
|
||||||
const result: IForwardConfig = JSON.parse(JSON.stringify(config));
|
|
||||||
|
|
||||||
// Apply defaults based on forwarding type
|
|
||||||
switch (config.type) {
|
|
||||||
case 'http-only':
|
|
||||||
// Set defaults for HTTP-only mode
|
|
||||||
result.http = {
|
|
||||||
enabled: true,
|
|
||||||
...config.http
|
|
||||||
};
|
|
||||||
// Set default port and socket if not provided
|
|
||||||
if (!result.port) {
|
|
||||||
result.port = 80;
|
|
||||||
}
|
|
||||||
if (!result.socket) {
|
|
||||||
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'https-passthrough':
|
|
||||||
// Set defaults for HTTPS passthrough
|
|
||||||
result.https = {
|
|
||||||
forwardSni: true,
|
|
||||||
...config.https
|
|
||||||
};
|
|
||||||
// SNI forwarding doesn't do HTTP
|
|
||||||
result.http = {
|
|
||||||
enabled: false,
|
|
||||||
...config.http
|
|
||||||
};
|
|
||||||
// Set default port and socket if not provided
|
|
||||||
if (!result.port) {
|
|
||||||
result.port = 443;
|
|
||||||
}
|
|
||||||
if (!result.socket) {
|
|
||||||
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'https-terminate-to-http':
|
|
||||||
// Set defaults for HTTPS termination to HTTP
|
|
||||||
result.https = {
|
|
||||||
...config.https
|
|
||||||
};
|
|
||||||
// Support HTTP access by default in this mode
|
|
||||||
result.http = {
|
|
||||||
enabled: true,
|
|
||||||
redirectToHttps: true,
|
|
||||||
...config.http
|
|
||||||
};
|
|
||||||
// Enable ACME by default
|
|
||||||
result.acme = {
|
|
||||||
enabled: true,
|
|
||||||
maintenance: true,
|
|
||||||
...config.acme
|
|
||||||
};
|
|
||||||
// Set default port and socket if not provided
|
|
||||||
if (!result.port) {
|
|
||||||
result.port = 443;
|
|
||||||
}
|
|
||||||
if (!result.socket) {
|
|
||||||
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'https-terminate-to-https':
|
|
||||||
// Similar to terminate-to-http but with different target handling
|
|
||||||
result.https = {
|
|
||||||
...config.https
|
|
||||||
};
|
|
||||||
result.http = {
|
|
||||||
enabled: true,
|
|
||||||
redirectToHttps: true,
|
|
||||||
...config.http
|
|
||||||
};
|
|
||||||
result.acme = {
|
|
||||||
enabled: true,
|
|
||||||
maintenance: true,
|
|
||||||
...config.acme
|
|
||||||
};
|
|
||||||
// Set default port and socket if not provided
|
|
||||||
if (!result.port) {
|
|
||||||
result.port = 443;
|
|
||||||
}
|
|
||||||
if (!result.socket) {
|
|
||||||
result.socket = `/tmp/forwarding-${config.type}-${result.port}.sock`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a forwarding configuration
|
|
||||||
* @param config The configuration to validate
|
|
||||||
* @throws Error if the configuration is invalid
|
|
||||||
*/
|
|
||||||
public static validateConfig(config: IForwardConfig): void {
|
|
||||||
// Validate common properties
|
|
||||||
if (!config.target) {
|
|
||||||
throw new Error('Forwarding configuration must include a target');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.target.host || (Array.isArray(config.target.host) && config.target.host.length === 0)) {
|
|
||||||
throw new Error('Target must include a host or array of hosts');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate port if it's a number
|
|
||||||
if (typeof config.target.port === 'number') {
|
|
||||||
if (config.target.port <= 0 || config.target.port > 65535) {
|
|
||||||
throw new Error('Target must include a valid port (1-65535)');
|
|
||||||
}
|
|
||||||
} else if (config.target.port !== 'preserve' && typeof config.target.port !== 'function') {
|
|
||||||
throw new Error('Target port must be a number, "preserve", or a function');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type-specific validation
|
|
||||||
switch (config.type) {
|
|
||||||
case 'http-only':
|
|
||||||
// HTTP-only needs http.enabled to be true
|
|
||||||
if (config.http?.enabled === false) {
|
|
||||||
throw new Error('HTTP-only forwarding must have HTTP enabled');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'https-passthrough':
|
|
||||||
// HTTPS passthrough doesn't support HTTP
|
|
||||||
if (config.http?.enabled === true) {
|
|
||||||
throw new Error('HTTPS passthrough does not support HTTP');
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPS passthrough doesn't work with ACME
|
|
||||||
if (config.acme?.enabled === true) {
|
|
||||||
throw new Error('HTTPS passthrough does not support ACME');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'https-terminate-to-http':
|
|
||||||
case 'https-terminate-to-https':
|
|
||||||
// These modes support all options, nothing specific to validate
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
/**
|
|
||||||
* Forwarding factory implementations
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { ForwardingHandlerFactory } from './forwarding-factory.js';
|
|
@@ -1,155 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import type {
|
|
||||||
IForwardConfig,
|
|
||||||
IForwardingHandler
|
|
||||||
} from '../config/forwarding-types.js';
|
|
||||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for all forwarding handlers
|
|
||||||
*/
|
|
||||||
export abstract class ForwardingHandler extends plugins.EventEmitter implements IForwardingHandler {
|
|
||||||
/**
|
|
||||||
* Create a new ForwardingHandler
|
|
||||||
* @param config The forwarding configuration
|
|
||||||
*/
|
|
||||||
constructor(protected config: IForwardConfig) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the handler
|
|
||||||
* Base implementation does nothing, subclasses should override as needed
|
|
||||||
*/
|
|
||||||
public async initialize(): Promise<void> {
|
|
||||||
// Base implementation - no initialization needed
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a new socket connection
|
|
||||||
* @param socket The incoming socket connection
|
|
||||||
*/
|
|
||||||
public abstract handleConnection(socket: plugins.net.Socket): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an HTTP request
|
|
||||||
* @param req The HTTP request
|
|
||||||
* @param res The HTTP response
|
|
||||||
*/
|
|
||||||
public abstract handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a target from the configuration, supporting round-robin selection
|
|
||||||
* @param incomingPort Optional incoming port for 'preserve' mode
|
|
||||||
* @returns A resolved target object with host and port
|
|
||||||
*/
|
|
||||||
protected getTargetFromConfig(incomingPort: number = 80): { host: string, port: number } {
|
|
||||||
const { target } = this.config;
|
|
||||||
|
|
||||||
// Handle round-robin host selection
|
|
||||||
if (Array.isArray(target.host)) {
|
|
||||||
if (target.host.length === 0) {
|
|
||||||
throw new Error('No target hosts specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple round-robin selection
|
|
||||||
const randomIndex = Math.floor(Math.random() * target.host.length);
|
|
||||||
return {
|
|
||||||
host: target.host[randomIndex],
|
|
||||||
port: this.resolvePort(target.port, incomingPort)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single host
|
|
||||||
return {
|
|
||||||
host: target.host,
|
|
||||||
port: this.resolvePort(target.port, incomingPort)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a port value, handling 'preserve' and function ports
|
|
||||||
* @param port The port value to resolve
|
|
||||||
* @param incomingPort Optional incoming port to use for 'preserve' mode
|
|
||||||
*/
|
|
||||||
protected resolvePort(
|
|
||||||
port: number | 'preserve' | ((ctx: any) => number),
|
|
||||||
incomingPort: number = 80
|
|
||||||
): number {
|
|
||||||
if (typeof port === 'function') {
|
|
||||||
try {
|
|
||||||
// Create a minimal context for the function that includes the incoming port
|
|
||||||
const ctx = { port: incomingPort };
|
|
||||||
return port(ctx);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error resolving port function:', err);
|
|
||||||
return incomingPort; // Fall back to incoming port
|
|
||||||
}
|
|
||||||
} else if (port === 'preserve') {
|
|
||||||
return incomingPort; // Use the actual incoming port for 'preserve'
|
|
||||||
} else {
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirect an HTTP request to HTTPS
|
|
||||||
* @param req The HTTP request
|
|
||||||
* @param res The HTTP response
|
|
||||||
*/
|
|
||||||
protected redirectToHttps(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
|
||||||
const host = req.headers.host || '';
|
|
||||||
const path = req.url || '/';
|
|
||||||
const redirectUrl = `https://${host}${path}`;
|
|
||||||
|
|
||||||
res.writeHead(301, {
|
|
||||||
'Location': redirectUrl,
|
|
||||||
'Cache-Control': 'no-cache'
|
|
||||||
});
|
|
||||||
res.end(`Redirecting to ${redirectUrl}`);
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
|
|
||||||
statusCode: 301,
|
|
||||||
headers: { 'Location': redirectUrl },
|
|
||||||
size: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply custom headers from configuration
|
|
||||||
* @param headers The original headers
|
|
||||||
* @param variables Variables to replace in the headers
|
|
||||||
* @returns The headers with custom values applied
|
|
||||||
*/
|
|
||||||
protected applyCustomHeaders(
|
|
||||||
headers: Record<string, string | string[] | undefined>,
|
|
||||||
variables: Record<string, string>
|
|
||||||
): Record<string, string | string[] | undefined> {
|
|
||||||
const customHeaders = this.config.advanced?.headers || {};
|
|
||||||
const result = { ...headers };
|
|
||||||
|
|
||||||
// Apply custom headers with variable substitution
|
|
||||||
for (const [key, value] of Object.entries(customHeaders)) {
|
|
||||||
if (typeof value !== 'string') continue;
|
|
||||||
|
|
||||||
let processedValue = value;
|
|
||||||
|
|
||||||
// Replace variables in the header value
|
|
||||||
for (const [varName, varValue] of Object.entries(variables)) {
|
|
||||||
processedValue = processedValue.replace(`{${varName}}`, varValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
result[key] = processedValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the timeout for this connection from configuration
|
|
||||||
* @returns Timeout in milliseconds
|
|
||||||
*/
|
|
||||||
protected getTimeout(): number {
|
|
||||||
return this.config.advanced?.timeout || 60000; // Default: 60 seconds
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,163 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { ForwardingHandler } from './base-handler.js';
|
|
||||||
import type { IForwardConfig } from '../config/forwarding-types.js';
|
|
||||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
|
||||||
import { setupSocketHandlers } from '../../core/utils/socket-utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler for HTTP-only forwarding
|
|
||||||
*/
|
|
||||||
export class HttpForwardingHandler extends ForwardingHandler {
|
|
||||||
/**
|
|
||||||
* Create a new HTTP forwarding handler
|
|
||||||
* @param config The forwarding configuration
|
|
||||||
*/
|
|
||||||
constructor(config: IForwardConfig) {
|
|
||||||
super(config);
|
|
||||||
|
|
||||||
// Validate that this is an HTTP-only configuration
|
|
||||||
if (config.type !== 'http-only') {
|
|
||||||
throw new Error(`Invalid configuration type for HttpForwardingHandler: ${config.type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the handler
|
|
||||||
* HTTP handler doesn't need special initialization
|
|
||||||
*/
|
|
||||||
public async initialize(): Promise<void> {
|
|
||||||
// Basic initialization from parent class
|
|
||||||
await super.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a raw socket connection
|
|
||||||
* HTTP handler doesn't do much with raw sockets as it mainly processes
|
|
||||||
* parsed HTTP requests
|
|
||||||
*/
|
|
||||||
public handleConnection(socket: plugins.net.Socket): void {
|
|
||||||
// For HTTP, we mainly handle parsed requests, but we can still set up
|
|
||||||
// some basic connection tracking
|
|
||||||
const remoteAddress = socket.remoteAddress || 'unknown';
|
|
||||||
const localPort = socket.localPort || 80;
|
|
||||||
|
|
||||||
// Set up socket handlers with proper cleanup
|
|
||||||
const handleClose = (reason: string) => {
|
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
reason
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use custom timeout handler that doesn't close the socket
|
|
||||||
setupSocketHandlers(socket, handleClose, () => {
|
|
||||||
// For HTTP, we can be more aggressive with timeouts since connections are shorter
|
|
||||||
// But still don't close immediately - let the connection finish naturally
|
|
||||||
console.warn(`HTTP socket timeout from ${remoteAddress}`);
|
|
||||||
}, 'http');
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
remoteAddress,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.CONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
localPort
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an HTTP request
|
|
||||||
* @param req The HTTP request
|
|
||||||
* @param res The HTTP response
|
|
||||||
*/
|
|
||||||
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
|
||||||
// Get the local port from the request (for 'preserve' port handling)
|
|
||||||
const localPort = req.socket.localPort || 80;
|
|
||||||
|
|
||||||
// Get the target from configuration, passing the incoming port
|
|
||||||
const target = this.getTargetFromConfig(localPort);
|
|
||||||
|
|
||||||
// Create a custom headers object with variables for substitution
|
|
||||||
const variables = {
|
|
||||||
clientIp: req.socket.remoteAddress || 'unknown'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepare headers, merging with any custom headers from config
|
|
||||||
const headers = this.applyCustomHeaders(req.headers, variables);
|
|
||||||
|
|
||||||
// Create the proxy request options
|
|
||||||
const options = {
|
|
||||||
hostname: target.host,
|
|
||||||
port: target.port,
|
|
||||||
path: req.url,
|
|
||||||
method: req.method,
|
|
||||||
headers
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the proxy request
|
|
||||||
const proxyReq = plugins.http.request(options, (proxyRes) => {
|
|
||||||
// Copy status code and headers from the proxied response
|
|
||||||
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
|
||||||
|
|
||||||
// Pipe the proxy response to the client response
|
|
||||||
proxyRes.pipe(res);
|
|
||||||
|
|
||||||
// Track bytes for logging
|
|
||||||
let responseSize = 0;
|
|
||||||
proxyRes.on('data', (chunk) => {
|
|
||||||
responseSize += chunk.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
proxyRes.on('end', () => {
|
|
||||||
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
|
|
||||||
statusCode: proxyRes.statusCode,
|
|
||||||
headers: proxyRes.headers,
|
|
||||||
size: responseSize
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle errors in the proxy request
|
|
||||||
proxyReq.on('error', (error) => {
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
remoteAddress: req.socket.remoteAddress,
|
|
||||||
error: `Proxy request error: ${error.message}`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send an error response if headers haven't been sent yet
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end(`Error forwarding request: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
// Just end the response if headers have already been sent
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track request details for logging
|
|
||||||
let requestSize = 0;
|
|
||||||
req.on('data', (chunk) => {
|
|
||||||
requestSize += chunk.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log the request
|
|
||||||
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
|
|
||||||
method: req.method,
|
|
||||||
url: req.url,
|
|
||||||
headers: req.headers,
|
|
||||||
remoteAddress: req.socket.remoteAddress,
|
|
||||||
target: `${target.host}:${target.port}`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pipe the client request to the proxy request
|
|
||||||
if (req.readable) {
|
|
||||||
req.pipe(proxyReq);
|
|
||||||
} else {
|
|
||||||
proxyReq.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,185 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { ForwardingHandler } from './base-handler.js';
|
|
||||||
import type { IForwardConfig } from '../config/forwarding-types.js';
|
|
||||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
|
||||||
import { createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler for HTTPS passthrough (SNI forwarding without termination)
|
|
||||||
*/
|
|
||||||
export class HttpsPassthroughHandler extends ForwardingHandler {
|
|
||||||
/**
|
|
||||||
* Create a new HTTPS passthrough handler
|
|
||||||
* @param config The forwarding configuration
|
|
||||||
*/
|
|
||||||
constructor(config: IForwardConfig) {
|
|
||||||
super(config);
|
|
||||||
|
|
||||||
// Validate that this is an HTTPS passthrough configuration
|
|
||||||
if (config.type !== 'https-passthrough') {
|
|
||||||
throw new Error(`Invalid configuration type for HttpsPassthroughHandler: ${config.type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the handler
|
|
||||||
* HTTPS passthrough handler doesn't need special initialization
|
|
||||||
*/
|
|
||||||
public async initialize(): Promise<void> {
|
|
||||||
// Basic initialization from parent class
|
|
||||||
await super.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a TLS/SSL socket connection by forwarding it without termination
|
|
||||||
* @param clientSocket The incoming socket from the client
|
|
||||||
*/
|
|
||||||
public handleConnection(clientSocket: plugins.net.Socket): void {
|
|
||||||
// Get the target from configuration
|
|
||||||
const target = this.getTargetFromConfig();
|
|
||||||
|
|
||||||
// Log the connection
|
|
||||||
const remoteAddress = clientSocket.remoteAddress || 'unknown';
|
|
||||||
const remotePort = clientSocket.remotePort || 0;
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.CONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
remotePort,
|
|
||||||
target: `${target.host}:${target.port}`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track data transfer for logging
|
|
||||||
let bytesSent = 0;
|
|
||||||
let bytesReceived = 0;
|
|
||||||
let serverSocket: plugins.net.Socket | null = null;
|
|
||||||
let cleanupClient: ((reason: string) => Promise<void>) | null = null;
|
|
||||||
let cleanupServer: ((reason: string) => Promise<void>) | null = null;
|
|
||||||
|
|
||||||
// Create a connection to the target server with immediate error handling
|
|
||||||
serverSocket = createSocketWithErrorHandler({
|
|
||||||
port: target.port,
|
|
||||||
host: target.host,
|
|
||||||
onError: async (error) => {
|
|
||||||
// Server connection failed - clean up client socket immediately
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
error: error.message,
|
|
||||||
code: (error as any).code || 'UNKNOWN',
|
|
||||||
remoteAddress,
|
|
||||||
target: `${target.host}:${target.port}`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up the client socket since we can't forward
|
|
||||||
if (!clientSocket.destroyed) {
|
|
||||||
clientSocket.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
bytesSent: 0,
|
|
||||||
bytesReceived: 0,
|
|
||||||
reason: `server_connection_failed: ${error.message}`
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onConnect: () => {
|
|
||||||
// Connection successful - set up forwarding handlers
|
|
||||||
const handlers = createIndependentSocketHandlers(
|
|
||||||
clientSocket,
|
|
||||||
serverSocket!,
|
|
||||||
(reason) => {
|
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
bytesSent,
|
|
||||||
bytesReceived,
|
|
||||||
reason
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
cleanupClient = handlers.cleanupClient;
|
|
||||||
cleanupServer = handlers.cleanupServer;
|
|
||||||
|
|
||||||
// Setup handlers with custom timeout handling that doesn't close connections
|
|
||||||
const timeout = this.getTimeout();
|
|
||||||
|
|
||||||
setupSocketHandlers(clientSocket, cleanupClient, (socket) => {
|
|
||||||
// Just reset timeout, don't close
|
|
||||||
socket.setTimeout(timeout);
|
|
||||||
}, 'client');
|
|
||||||
|
|
||||||
setupSocketHandlers(serverSocket!, cleanupServer, (socket) => {
|
|
||||||
// Just reset timeout, don't close
|
|
||||||
socket.setTimeout(timeout);
|
|
||||||
}, 'server');
|
|
||||||
|
|
||||||
// Forward data from client to server
|
|
||||||
clientSocket.on('data', (data) => {
|
|
||||||
bytesSent += data.length;
|
|
||||||
|
|
||||||
// Check if server socket is writable
|
|
||||||
if (serverSocket && serverSocket.writable) {
|
|
||||||
const flushed = serverSocket.write(data);
|
|
||||||
|
|
||||||
// Handle backpressure
|
|
||||||
if (!flushed) {
|
|
||||||
clientSocket.pause();
|
|
||||||
serverSocket.once('drain', () => {
|
|
||||||
clientSocket.resume();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
|
||||||
direction: 'outbound',
|
|
||||||
bytes: data.length,
|
|
||||||
total: bytesSent
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forward data from server to client
|
|
||||||
serverSocket!.on('data', (data) => {
|
|
||||||
bytesReceived += data.length;
|
|
||||||
|
|
||||||
// Check if client socket is writable
|
|
||||||
if (clientSocket.writable) {
|
|
||||||
const flushed = clientSocket.write(data);
|
|
||||||
|
|
||||||
// Handle backpressure
|
|
||||||
if (!flushed) {
|
|
||||||
serverSocket!.pause();
|
|
||||||
clientSocket.once('drain', () => {
|
|
||||||
serverSocket!.resume();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
|
||||||
direction: 'inbound',
|
|
||||||
bytes: data.length,
|
|
||||||
total: bytesReceived
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set initial timeouts - they will be reset on each timeout event
|
|
||||||
clientSocket.setTimeout(timeout);
|
|
||||||
serverSocket!.setTimeout(timeout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an HTTP request - HTTPS passthrough doesn't support HTTP
|
|
||||||
* @param req The HTTP request
|
|
||||||
* @param res The HTTP response
|
|
||||||
*/
|
|
||||||
public handleHttpRequest(_req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
|
||||||
// HTTPS passthrough doesn't support HTTP requests
|
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end('HTTP not supported for this domain');
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
|
|
||||||
statusCode: 404,
|
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
|
||||||
size: 'HTTP not supported for this domain'.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,312 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { ForwardingHandler } from './base-handler.js';
|
|
||||||
import type { IForwardConfig } from '../config/forwarding-types.js';
|
|
||||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
|
||||||
import { setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler for HTTPS termination with HTTP backend
|
|
||||||
*/
|
|
||||||
export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
|
||||||
private tlsServer: plugins.tls.Server | null = null;
|
|
||||||
private secureContext: plugins.tls.SecureContext | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new HTTPS termination with HTTP backend handler
|
|
||||||
* @param config The forwarding configuration
|
|
||||||
*/
|
|
||||||
constructor(config: IForwardConfig) {
|
|
||||||
super(config);
|
|
||||||
|
|
||||||
// Validate that this is an HTTPS terminate to HTTP configuration
|
|
||||||
if (config.type !== 'https-terminate-to-http') {
|
|
||||||
throw new Error(`Invalid configuration type for HttpsTerminateToHttpHandler: ${config.type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the handler, setting up TLS context
|
|
||||||
*/
|
|
||||||
public async initialize(): Promise<void> {
|
|
||||||
// We need to load or create TLS certificates
|
|
||||||
if (this.config.https?.customCert) {
|
|
||||||
// Use custom certificate from configuration
|
|
||||||
this.secureContext = plugins.tls.createSecureContext({
|
|
||||||
key: this.config.https.customCert.key,
|
|
||||||
cert: this.config.https.customCert.cert
|
|
||||||
});
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, {
|
|
||||||
source: 'config',
|
|
||||||
domain: this.config.target.host
|
|
||||||
});
|
|
||||||
} else if (this.config.acme?.enabled) {
|
|
||||||
// Request certificate through ACME if needed
|
|
||||||
this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, {
|
|
||||||
domain: Array.isArray(this.config.target.host)
|
|
||||||
? this.config.target.host[0]
|
|
||||||
: this.config.target.host,
|
|
||||||
useProduction: this.config.acme.production || false
|
|
||||||
});
|
|
||||||
|
|
||||||
// In a real implementation, we would wait for the certificate to be issued
|
|
||||||
// For now, we'll use a dummy context
|
|
||||||
this.secureContext = plugins.tls.createSecureContext({
|
|
||||||
key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----',
|
|
||||||
cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error('HTTPS termination requires either a custom certificate or ACME enabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the secure context for TLS termination
|
|
||||||
* Called when a certificate is available
|
|
||||||
* @param context The secure context
|
|
||||||
*/
|
|
||||||
public setSecureContext(context: plugins.tls.SecureContext): void {
|
|
||||||
this.secureContext = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a TLS/SSL socket connection by terminating TLS and forwarding to HTTP backend
|
|
||||||
* @param clientSocket The incoming socket from the client
|
|
||||||
*/
|
|
||||||
public handleConnection(clientSocket: plugins.net.Socket): void {
|
|
||||||
// Make sure we have a secure context
|
|
||||||
if (!this.secureContext) {
|
|
||||||
clientSocket.destroy(new Error('TLS secure context not initialized'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteAddress = clientSocket.remoteAddress || 'unknown';
|
|
||||||
const remotePort = clientSocket.remotePort || 0;
|
|
||||||
|
|
||||||
// Create a TLS socket using our secure context
|
|
||||||
const tlsSocket = new plugins.tls.TLSSocket(clientSocket, {
|
|
||||||
secureContext: this.secureContext,
|
|
||||||
isServer: true,
|
|
||||||
server: this.tlsServer || undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.CONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
remotePort,
|
|
||||||
tls: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Variables to track connections
|
|
||||||
let backendSocket: plugins.net.Socket | null = null;
|
|
||||||
let dataBuffer = Buffer.alloc(0);
|
|
||||||
let connectionEstablished = false;
|
|
||||||
let forwardingSetup = false;
|
|
||||||
|
|
||||||
// Set up initial error handling for TLS socket
|
|
||||||
const tlsCleanupHandler = (reason: string) => {
|
|
||||||
if (!forwardingSetup) {
|
|
||||||
// If forwarding not set up yet, emit disconnected and cleanup
|
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
reason
|
|
||||||
});
|
|
||||||
dataBuffer = Buffer.alloc(0);
|
|
||||||
connectionEstablished = false;
|
|
||||||
|
|
||||||
if (!tlsSocket.destroyed) {
|
|
||||||
tlsSocket.destroy();
|
|
||||||
}
|
|
||||||
if (backendSocket && !backendSocket.destroyed) {
|
|
||||||
backendSocket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If forwarding is setup, setupBidirectionalForwarding will handle cleanup
|
|
||||||
};
|
|
||||||
|
|
||||||
setupSocketHandlers(tlsSocket, tlsCleanupHandler, undefined, 'tls');
|
|
||||||
|
|
||||||
// Set timeout
|
|
||||||
const timeout = this.getTimeout();
|
|
||||||
tlsSocket.setTimeout(timeout);
|
|
||||||
|
|
||||||
tlsSocket.on('timeout', () => {
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
remoteAddress,
|
|
||||||
error: 'TLS connection timeout'
|
|
||||||
});
|
|
||||||
tlsCleanupHandler('timeout');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle TLS data
|
|
||||||
tlsSocket.on('data', (data) => {
|
|
||||||
// If backend connection already established, just forward the data
|
|
||||||
if (connectionEstablished && backendSocket && !backendSocket.destroyed) {
|
|
||||||
backendSocket.write(data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append to buffer
|
|
||||||
dataBuffer = Buffer.concat([dataBuffer, data]);
|
|
||||||
|
|
||||||
// Very basic HTTP parsing - in a real implementation, use http-parser
|
|
||||||
if (dataBuffer.includes(Buffer.from('\r\n\r\n')) && !connectionEstablished) {
|
|
||||||
const target = this.getTargetFromConfig();
|
|
||||||
|
|
||||||
// Create backend connection with immediate error handling
|
|
||||||
backendSocket = createSocketWithErrorHandler({
|
|
||||||
port: target.port,
|
|
||||||
host: target.host,
|
|
||||||
onError: (error) => {
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
error: error.message,
|
|
||||||
code: (error as any).code || 'UNKNOWN',
|
|
||||||
remoteAddress,
|
|
||||||
target: `${target.host}:${target.port}`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up the TLS socket since we can't forward
|
|
||||||
if (!tlsSocket.destroyed) {
|
|
||||||
tlsSocket.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
reason: `backend_connection_failed: ${error.message}`
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onConnect: () => {
|
|
||||||
connectionEstablished = true;
|
|
||||||
|
|
||||||
// Send buffered data
|
|
||||||
if (dataBuffer.length > 0) {
|
|
||||||
backendSocket!.write(dataBuffer);
|
|
||||||
dataBuffer = Buffer.alloc(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now set up bidirectional forwarding with proper cleanup
|
|
||||||
forwardingSetup = true;
|
|
||||||
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
|
|
||||||
onCleanup: (reason) => {
|
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
reason
|
|
||||||
});
|
|
||||||
dataBuffer = Buffer.alloc(0);
|
|
||||||
connectionEstablished = false;
|
|
||||||
forwardingSetup = false;
|
|
||||||
},
|
|
||||||
enableHalfOpen: false // Close both when one closes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Additional error logging for backend socket
|
|
||||||
backendSocket.on('error', (error) => {
|
|
||||||
if (!connectionEstablished) {
|
|
||||||
// Connection failed during setup
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
remoteAddress,
|
|
||||||
error: `Target connection error: ${error.message}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// If connected, setupBidirectionalForwarding handles cleanup
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an HTTP request by forwarding to the HTTP backend
|
|
||||||
* @param req The HTTP request
|
|
||||||
* @param res The HTTP response
|
|
||||||
*/
|
|
||||||
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
|
||||||
// Check if we should redirect to HTTPS
|
|
||||||
if (this.config.http?.redirectToHttps) {
|
|
||||||
this.redirectToHttps(req, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the target from configuration
|
|
||||||
const target = this.getTargetFromConfig();
|
|
||||||
|
|
||||||
// Create custom headers with variable substitution
|
|
||||||
const variables = {
|
|
||||||
clientIp: req.socket.remoteAddress || 'unknown'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepare headers, merging with any custom headers from config
|
|
||||||
const headers = this.applyCustomHeaders(req.headers, variables);
|
|
||||||
|
|
||||||
// Create the proxy request options
|
|
||||||
const options = {
|
|
||||||
hostname: target.host,
|
|
||||||
port: target.port,
|
|
||||||
path: req.url,
|
|
||||||
method: req.method,
|
|
||||||
headers
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the proxy request
|
|
||||||
const proxyReq = plugins.http.request(options, (proxyRes) => {
|
|
||||||
// Copy status code and headers from the proxied response
|
|
||||||
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
|
||||||
|
|
||||||
// Pipe the proxy response to the client response
|
|
||||||
proxyRes.pipe(res);
|
|
||||||
|
|
||||||
// Track response size for logging
|
|
||||||
let responseSize = 0;
|
|
||||||
proxyRes.on('data', (chunk) => {
|
|
||||||
responseSize += chunk.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
proxyRes.on('end', () => {
|
|
||||||
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
|
|
||||||
statusCode: proxyRes.statusCode,
|
|
||||||
headers: proxyRes.headers,
|
|
||||||
size: responseSize
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle errors in the proxy request
|
|
||||||
proxyReq.on('error', (error) => {
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
remoteAddress: req.socket.remoteAddress,
|
|
||||||
error: `Proxy request error: ${error.message}`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send an error response if headers haven't been sent yet
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end(`Error forwarding request: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
// Just end the response if headers have already been sent
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track request details for logging
|
|
||||||
let requestSize = 0;
|
|
||||||
req.on('data', (chunk) => {
|
|
||||||
requestSize += chunk.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log the request
|
|
||||||
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
|
|
||||||
method: req.method,
|
|
||||||
url: req.url,
|
|
||||||
headers: req.headers,
|
|
||||||
remoteAddress: req.socket.remoteAddress,
|
|
||||||
target: `${target.host}:${target.port}`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pipe the client request to the proxy request
|
|
||||||
if (req.readable) {
|
|
||||||
req.pipe(proxyReq);
|
|
||||||
} else {
|
|
||||||
proxyReq.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,297 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { ForwardingHandler } from './base-handler.js';
|
|
||||||
import type { IForwardConfig } from '../config/forwarding-types.js';
|
|
||||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
|
||||||
import { setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler for HTTPS termination with HTTPS backend
|
|
||||||
*/
|
|
||||||
export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
|
||||||
private secureContext: plugins.tls.SecureContext | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new HTTPS termination with HTTPS backend handler
|
|
||||||
* @param config The forwarding configuration
|
|
||||||
*/
|
|
||||||
constructor(config: IForwardConfig) {
|
|
||||||
super(config);
|
|
||||||
|
|
||||||
// Validate that this is an HTTPS terminate to HTTPS configuration
|
|
||||||
if (config.type !== 'https-terminate-to-https') {
|
|
||||||
throw new Error(`Invalid configuration type for HttpsTerminateToHttpsHandler: ${config.type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the handler, setting up TLS context
|
|
||||||
*/
|
|
||||||
public async initialize(): Promise<void> {
|
|
||||||
// We need to load or create TLS certificates for termination
|
|
||||||
if (this.config.https?.customCert) {
|
|
||||||
// Use custom certificate from configuration
|
|
||||||
this.secureContext = plugins.tls.createSecureContext({
|
|
||||||
key: this.config.https.customCert.key,
|
|
||||||
cert: this.config.https.customCert.cert
|
|
||||||
});
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, {
|
|
||||||
source: 'config',
|
|
||||||
domain: this.config.target.host
|
|
||||||
});
|
|
||||||
} else if (this.config.acme?.enabled) {
|
|
||||||
// Request certificate through ACME if needed
|
|
||||||
this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, {
|
|
||||||
domain: Array.isArray(this.config.target.host)
|
|
||||||
? this.config.target.host[0]
|
|
||||||
: this.config.target.host,
|
|
||||||
useProduction: this.config.acme.production || false
|
|
||||||
});
|
|
||||||
|
|
||||||
// In a real implementation, we would wait for the certificate to be issued
|
|
||||||
// For now, we'll use a dummy context
|
|
||||||
this.secureContext = plugins.tls.createSecureContext({
|
|
||||||
key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----',
|
|
||||||
cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error('HTTPS termination requires either a custom certificate or ACME enabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the secure context for TLS termination
|
|
||||||
* Called when a certificate is available
|
|
||||||
* @param context The secure context
|
|
||||||
*/
|
|
||||||
public setSecureContext(context: plugins.tls.SecureContext): void {
|
|
||||||
this.secureContext = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a TLS/SSL socket connection by terminating TLS and creating a new TLS connection to backend
|
|
||||||
* @param clientSocket The incoming socket from the client
|
|
||||||
*/
|
|
||||||
public handleConnection(clientSocket: plugins.net.Socket): void {
|
|
||||||
// Make sure we have a secure context
|
|
||||||
if (!this.secureContext) {
|
|
||||||
clientSocket.destroy(new Error('TLS secure context not initialized'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteAddress = clientSocket.remoteAddress || 'unknown';
|
|
||||||
const remotePort = clientSocket.remotePort || 0;
|
|
||||||
|
|
||||||
// Create a TLS socket using our secure context
|
|
||||||
const tlsSocket = new plugins.tls.TLSSocket(clientSocket, {
|
|
||||||
secureContext: this.secureContext,
|
|
||||||
isServer: true
|
|
||||||
});
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.CONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
remotePort,
|
|
||||||
tls: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Variable to track backend socket
|
|
||||||
let backendSocket: plugins.tls.TLSSocket | null = null;
|
|
||||||
let isConnectedToBackend = false;
|
|
||||||
|
|
||||||
// Set up initial error handling for TLS socket
|
|
||||||
const tlsCleanupHandler = (reason: string) => {
|
|
||||||
if (!isConnectedToBackend) {
|
|
||||||
// If backend not connected yet, just emit disconnected event
|
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
reason
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup TLS socket if needed
|
|
||||||
if (!tlsSocket.destroyed) {
|
|
||||||
tlsSocket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If connected to backend, setupBidirectionalForwarding will handle cleanup
|
|
||||||
};
|
|
||||||
|
|
||||||
setupSocketHandlers(tlsSocket, tlsCleanupHandler, undefined, 'tls');
|
|
||||||
|
|
||||||
// Set timeout
|
|
||||||
const timeout = this.getTimeout();
|
|
||||||
tlsSocket.setTimeout(timeout);
|
|
||||||
|
|
||||||
tlsSocket.on('timeout', () => {
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
remoteAddress,
|
|
||||||
error: 'TLS connection timeout'
|
|
||||||
});
|
|
||||||
tlsCleanupHandler('timeout');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the target from configuration
|
|
||||||
const target = this.getTargetFromConfig();
|
|
||||||
|
|
||||||
// Set up the connection to the HTTPS backend
|
|
||||||
const connectToBackend = () => {
|
|
||||||
backendSocket = plugins.tls.connect({
|
|
||||||
host: target.host,
|
|
||||||
port: target.port,
|
|
||||||
// In a real implementation, we would configure TLS options
|
|
||||||
rejectUnauthorized: false // For testing only, never use in production
|
|
||||||
}, () => {
|
|
||||||
isConnectedToBackend = true;
|
|
||||||
|
|
||||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
|
||||||
direction: 'outbound',
|
|
||||||
target: `${target.host}:${target.port}`,
|
|
||||||
tls: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up bidirectional forwarding with proper cleanup
|
|
||||||
setupBidirectionalForwarding(tlsSocket, backendSocket!, {
|
|
||||||
onCleanup: (reason) => {
|
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
reason
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enableHalfOpen: false // Close both when one closes
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set timeout for backend socket
|
|
||||||
backendSocket!.setTimeout(timeout);
|
|
||||||
|
|
||||||
backendSocket!.on('timeout', () => {
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
remoteAddress,
|
|
||||||
error: 'Backend connection timeout'
|
|
||||||
});
|
|
||||||
// Let setupBidirectionalForwarding handle the cleanup
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle backend connection errors
|
|
||||||
backendSocket.on('error', (error) => {
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
remoteAddress,
|
|
||||||
error: `Backend connection error: ${error.message}`
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isConnectedToBackend) {
|
|
||||||
// Connection failed, clean up TLS socket
|
|
||||||
if (!tlsSocket.destroyed) {
|
|
||||||
tlsSocket.destroy();
|
|
||||||
}
|
|
||||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
||||||
remoteAddress,
|
|
||||||
reason: `backend_connection_failed: ${error.message}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// If connected, let setupBidirectionalForwarding handle cleanup
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wait for the TLS handshake to complete before connecting to backend
|
|
||||||
tlsSocket.on('secure', () => {
|
|
||||||
connectToBackend();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an HTTP request by forwarding to the HTTPS backend
|
|
||||||
* @param req The HTTP request
|
|
||||||
* @param res The HTTP response
|
|
||||||
*/
|
|
||||||
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
|
||||||
// Check if we should redirect to HTTPS
|
|
||||||
if (this.config.http?.redirectToHttps) {
|
|
||||||
this.redirectToHttps(req, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the target from configuration
|
|
||||||
const target = this.getTargetFromConfig();
|
|
||||||
|
|
||||||
// Create custom headers with variable substitution
|
|
||||||
const variables = {
|
|
||||||
clientIp: req.socket.remoteAddress || 'unknown'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepare headers, merging with any custom headers from config
|
|
||||||
const headers = this.applyCustomHeaders(req.headers, variables);
|
|
||||||
|
|
||||||
// Create the proxy request options
|
|
||||||
const options = {
|
|
||||||
hostname: target.host,
|
|
||||||
port: target.port,
|
|
||||||
path: req.url,
|
|
||||||
method: req.method,
|
|
||||||
headers,
|
|
||||||
// In a real implementation, we would configure TLS options
|
|
||||||
rejectUnauthorized: false // For testing only, never use in production
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the proxy request using HTTPS
|
|
||||||
const proxyReq = plugins.https.request(options, (proxyRes) => {
|
|
||||||
// Copy status code and headers from the proxied response
|
|
||||||
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
|
||||||
|
|
||||||
// Pipe the proxy response to the client response
|
|
||||||
proxyRes.pipe(res);
|
|
||||||
|
|
||||||
// Track response size for logging
|
|
||||||
let responseSize = 0;
|
|
||||||
proxyRes.on('data', (chunk) => {
|
|
||||||
responseSize += chunk.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
proxyRes.on('end', () => {
|
|
||||||
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
|
|
||||||
statusCode: proxyRes.statusCode,
|
|
||||||
headers: proxyRes.headers,
|
|
||||||
size: responseSize
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle errors in the proxy request
|
|
||||||
proxyReq.on('error', (error) => {
|
|
||||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
||||||
remoteAddress: req.socket.remoteAddress,
|
|
||||||
error: `Proxy request error: ${error.message}`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send an error response if headers haven't been sent yet
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end(`Error forwarding request: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
// Just end the response if headers have already been sent
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track request details for logging
|
|
||||||
let requestSize = 0;
|
|
||||||
req.on('data', (chunk) => {
|
|
||||||
requestSize += chunk.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log the request
|
|
||||||
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
|
|
||||||
method: req.method,
|
|
||||||
url: req.url,
|
|
||||||
headers: req.headers,
|
|
||||||
remoteAddress: req.socket.remoteAddress,
|
|
||||||
target: `${target.host}:${target.port}`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pipe the client request to the proxy request
|
|
||||||
if (req.readable) {
|
|
||||||
req.pipe(proxyReq);
|
|
||||||
} else {
|
|
||||||
proxyReq.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* Forwarding handler implementations
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { ForwardingHandler } from './base-handler.js';
|
|
||||||
export { HttpForwardingHandler } from './http-handler.js';
|
|
||||||
export { HttpsPassthroughHandler } from './https-passthrough-handler.js';
|
|
||||||
export { HttpsTerminateToHttpHandler } from './https-terminate-to-http-handler.js';
|
|
||||||
export { HttpsTerminateToHttpsHandler } from './https-terminate-to-https-handler.js';
|
|
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Forwarding system module
|
|
||||||
* Provides a flexible and type-safe way to configure and manage various forwarding strategies
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Export handlers
|
|
||||||
export { ForwardingHandler } from './handlers/base-handler.js';
|
|
||||||
export * from './handlers/http-handler.js';
|
|
||||||
export * from './handlers/https-passthrough-handler.js';
|
|
||||||
export * from './handlers/https-terminate-to-http-handler.js';
|
|
||||||
export * from './handlers/https-terminate-to-https-handler.js';
|
|
||||||
|
|
||||||
// Export factory
|
|
||||||
export * from './factory/forwarding-factory.js';
|
|
||||||
|
|
||||||
// Export types - these include TForwardingType and IForwardConfig
|
|
||||||
export type {
|
|
||||||
TForwardingType,
|
|
||||||
IForwardConfig,
|
|
||||||
IForwardingHandler
|
|
||||||
} from './config/forwarding-types.js';
|
|
||||||
|
|
||||||
export {
|
|
||||||
ForwardingHandlerEvents
|
|
||||||
} from './config/forwarding-types.js';
|
|
||||||
|
|
||||||
// Export route helpers directly from route-patterns
|
|
||||||
export {
|
|
||||||
createHttpRoute,
|
|
||||||
createHttpsTerminateRoute,
|
|
||||||
createHttpsPassthroughRoute,
|
|
||||||
createHttpToHttpsRedirect,
|
|
||||||
createCompleteHttpsServer,
|
|
||||||
createLoadBalancerRoute
|
|
||||||
} from '../proxies/smart-proxy/utils/route-patterns.js';
|
|
@@ -32,7 +32,8 @@ export * from './core/models/common-types.js';
|
|||||||
export type { IAcmeOptions } from './proxies/smart-proxy/models/interfaces.js';
|
export type { IAcmeOptions } from './proxies/smart-proxy/models/interfaces.js';
|
||||||
|
|
||||||
// Modular exports for new architecture
|
// Modular exports for new architecture
|
||||||
export * as forwarding from './forwarding/index.js';
|
|
||||||
// Certificate module has been removed - use SmartCertManager instead
|
// Certificate module has been removed - use SmartCertManager instead
|
||||||
export * as tls from './tls/index.js';
|
export * as tls from './tls/index.js';
|
||||||
export * as routing from './routing/index.js';
|
export * as routing from './routing/index.js';
|
||||||
|
export * as detection from './detection/index.js';
|
||||||
|
export * as protocols from './protocols/index.js';
|
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;
|
||||||
|
}
|
11
ts/protocols/index.ts
Normal file
11
ts/protocols/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Protocol-specific modules for smartproxy
|
||||||
|
*
|
||||||
|
* This directory contains generic protocol knowledge separated from
|
||||||
|
* smartproxy-specific implementation details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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';
|
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
|
* 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');
|
||||||
|
}
|
@@ -1,4 +1,6 @@
|
|||||||
import * as plugins from '../../../plugins.js';
|
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
|
* HTTP-specific event types
|
||||||
@@ -10,34 +12,33 @@ export enum HttpEvents {
|
|||||||
REQUEST_ERROR = 'request-error',
|
REQUEST_ERROR = 'request-error',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP status codes as an enum for better type safety
|
// Re-export for backward compatibility with subset of commonly used codes
|
||||||
*/
|
export const HttpStatus = {
|
||||||
export enum HttpStatus {
|
OK: ProtocolHttpStatus.OK,
|
||||||
OK = 200,
|
MOVED_PERMANENTLY: ProtocolHttpStatus.MOVED_PERMANENTLY,
|
||||||
MOVED_PERMANENTLY = 301,
|
FOUND: ProtocolHttpStatus.FOUND,
|
||||||
FOUND = 302,
|
TEMPORARY_REDIRECT: ProtocolHttpStatus.TEMPORARY_REDIRECT,
|
||||||
TEMPORARY_REDIRECT = 307,
|
PERMANENT_REDIRECT: ProtocolHttpStatus.PERMANENT_REDIRECT,
|
||||||
PERMANENT_REDIRECT = 308,
|
BAD_REQUEST: ProtocolHttpStatus.BAD_REQUEST,
|
||||||
BAD_REQUEST = 400,
|
UNAUTHORIZED: ProtocolHttpStatus.UNAUTHORIZED,
|
||||||
UNAUTHORIZED = 401,
|
FORBIDDEN: ProtocolHttpStatus.FORBIDDEN,
|
||||||
FORBIDDEN = 403,
|
NOT_FOUND: ProtocolHttpStatus.NOT_FOUND,
|
||||||
NOT_FOUND = 404,
|
METHOD_NOT_ALLOWED: ProtocolHttpStatus.METHOD_NOT_ALLOWED,
|
||||||
METHOD_NOT_ALLOWED = 405,
|
REQUEST_TIMEOUT: ProtocolHttpStatus.REQUEST_TIMEOUT,
|
||||||
REQUEST_TIMEOUT = 408,
|
TOO_MANY_REQUESTS: ProtocolHttpStatus.TOO_MANY_REQUESTS,
|
||||||
TOO_MANY_REQUESTS = 429,
|
INTERNAL_SERVER_ERROR: ProtocolHttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
INTERNAL_SERVER_ERROR = 500,
|
NOT_IMPLEMENTED: ProtocolHttpStatus.NOT_IMPLEMENTED,
|
||||||
NOT_IMPLEMENTED = 501,
|
BAD_GATEWAY: ProtocolHttpStatus.BAD_GATEWAY,
|
||||||
BAD_GATEWAY = 502,
|
SERVICE_UNAVAILABLE: ProtocolHttpStatus.SERVICE_UNAVAILABLE,
|
||||||
SERVICE_UNAVAILABLE = 503,
|
GATEWAY_TIMEOUT: ProtocolHttpStatus.GATEWAY_TIMEOUT,
|
||||||
GATEWAY_TIMEOUT = 504,
|
} as const;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base error class for HTTP-related errors
|
* Base error class for HTTP-related errors
|
||||||
*/
|
*/
|
||||||
export class HttpError extends Error {
|
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);
|
super(message);
|
||||||
this.name = 'HttpError';
|
this.name = 'HttpError';
|
||||||
}
|
}
|
||||||
@@ -61,7 +62,7 @@ export class CertificateError extends HttpError {
|
|||||||
* Error related to server operations
|
* Error related to server operations
|
||||||
*/
|
*/
|
||||||
export class ServerError extends HttpError {
|
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);
|
super(message, statusCode);
|
||||||
this.name = 'ServerError';
|
this.name = 'ServerError';
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ export class NotFoundError extends HttpError {
|
|||||||
export interface IRedirectConfig {
|
export interface IRedirectConfig {
|
||||||
source: string; // Source path or pattern
|
source: string; // Source path or pattern
|
||||||
destination: string; // Destination URL
|
destination: string; // Destination URL
|
||||||
type: HttpStatus; // Redirect status code
|
type: number; // Redirect status code
|
||||||
preserveQuery?: boolean; // Whether to preserve query parameters
|
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';
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to get HTTP status text
|
* Helper function to get HTTP status text
|
||||||
*/
|
*/
|
||||||
export function getStatusText(status: HttpStatus): string {
|
export function getStatusText(status: number): string {
|
||||||
const statusTexts: Record<HttpStatus, string> = {
|
return getProtocolStatusText(status as ProtocolHttpStatus);
|
||||||
[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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy interfaces for backward compatibility
|
// Legacy interfaces for backward compatibility
|
||||||
|
@@ -16,7 +16,6 @@ export interface IAcmeOptions {
|
|||||||
routeForwards?: any[];
|
routeForwards?: any[];
|
||||||
}
|
}
|
||||||
import type { IRouteConfig } from './route-types.js';
|
import type { IRouteConfig } from './route-types.js';
|
||||||
import type { TForwardingType } from '../../../forwarding/config/forwarding-types.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provision object for static or HTTP-01 certificate
|
* Provision object for static or HTTP-01 certificate
|
||||||
@@ -196,4 +195,11 @@ export interface IConnectionRecord {
|
|||||||
|
|
||||||
// NFTables tracking
|
// NFTables tracking
|
||||||
nftablesHandled?: boolean; // Whether this connection is being handled by NFTables at kernel level
|
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>;
|
||||||
|
};
|
||||||
}
|
}
|
@@ -1,6 +1,5 @@
|
|||||||
import * as plugins from '../../../plugins.js';
|
import * as plugins from '../../../plugins.js';
|
||||||
// Certificate types removed - use local definition
|
// Certificate types removed - use local definition
|
||||||
import type { TForwardingType } from '../../../forwarding/config/forwarding-types.js';
|
|
||||||
import type { PortRange } from '../../../proxies/nftables-proxy/models/interfaces.js';
|
import type { PortRange } from '../../../proxies/nftables-proxy/models/interfaces.js';
|
||||||
import type { IRouteContext } from '../../../core/models/route-context.js';
|
import type { IRouteContext } from '../../../core/models/route-context.js';
|
||||||
|
|
||||||
|
@@ -10,6 +10,7 @@ import { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
|||||||
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
|
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
|
||||||
import { ProxyProtocolParser } from '../../core/utils/proxy-protocol.js';
|
import { ProxyProtocolParser } from '../../core/utils/proxy-protocol.js';
|
||||||
import type { SmartProxy } from './smart-proxy.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
|
* 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)
|
// Handler for processing initial data (after potential PROXY protocol)
|
||||||
const processInitialData = (chunk: Buffer) => {
|
const processInitialData = async (chunk: Buffer) => {
|
||||||
|
// Use ProtocolDetector to identify protocol
|
||||||
|
const connectionId = ProtocolDetector.createConnectionId({
|
||||||
|
sourceIp: record.remoteIP,
|
||||||
|
sourcePort: socket.remotePort,
|
||||||
|
destIp: socket.localAddress,
|
||||||
|
destPort: socket.localPort,
|
||||||
|
socketId: record.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
|
||||||
|
chunk,
|
||||||
|
connectionId,
|
||||||
|
{ extractFullHeaders: false } // Only extract essential info for routing
|
||||||
|
);
|
||||||
|
|
||||||
// Block non-TLS connections on port 443
|
// Block non-TLS connections on port 443
|
||||||
if (!this.smartProxy.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
|
if (localPort === 443 && detectionResult.protocol !== 'tls') {
|
||||||
logger.log('warn', `Non-TLS connection ${connectionId} detected on port 443. Terminating connection - only TLS traffic is allowed on standard HTTPS port.`, {
|
logger.log('warn', `Non-TLS connection ${record.id} detected on port 443. Terminating connection - only TLS traffic is allowed on standard HTTPS port.`, {
|
||||||
connectionId,
|
connectionId: record.id,
|
||||||
|
detectedProtocol: detectionResult.protocol,
|
||||||
message: 'Terminating connection - only TLS traffic is allowed on standard HTTPS port.',
|
message: 'Terminating connection - only TLS traffic is allowed on standard HTTPS port.',
|
||||||
component: 'route-handler'
|
component: 'route-handler'
|
||||||
});
|
});
|
||||||
@@ -318,71 +335,78 @@ export class RouteConnectionHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this looks like a TLS handshake
|
// Extract domain and protocol info
|
||||||
let serverName = '';
|
let serverName = '';
|
||||||
if (this.smartProxy.tlsManager.isTlsHandshake(chunk)) {
|
if (detectionResult.protocol === 'tls') {
|
||||||
record.isTLS = true;
|
record.isTLS = true;
|
||||||
|
serverName = detectionResult.connectionInfo.domain || '';
|
||||||
|
|
||||||
// Check for ClientHello to extract SNI
|
// Lock the connection to the negotiated SNI
|
||||||
if (this.smartProxy.tlsManager.isClientHello(chunk)) {
|
record.lockedDomain = serverName;
|
||||||
// Create connection info for SNI extraction
|
|
||||||
const connInfo = {
|
|
||||||
sourceIp: record.remoteIP,
|
|
||||||
sourcePort: socket.remotePort || 0,
|
|
||||||
destIp: socket.localAddress || '',
|
|
||||||
destPort: socket.localPort || 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract SNI
|
// Check if we should reject connections without SNI
|
||||||
serverName = this.smartProxy.tlsManager.extractSNI(chunk, connInfo) || '';
|
if (!serverName && this.smartProxy.settings.allowSessionTicket === false) {
|
||||||
|
logger.log('warn', `No SNI detected in TLS ClientHello for connection ${record.id}; sending TLS alert`, {
|
||||||
// Lock the connection to the negotiated SNI
|
connectionId: record.id,
|
||||||
record.lockedDomain = serverName;
|
component: 'route-handler'
|
||||||
|
});
|
||||||
// Check if we should reject connections without SNI
|
if (record.incomingTerminationReason === null) {
|
||||||
if (!serverName && this.smartProxy.settings.allowSessionTicket === false) {
|
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
|
||||||
logger.log('warn', `No SNI detected in TLS ClientHello for connection ${connectionId}; sending TLS alert`, {
|
this.smartProxy.connectionManager.incrementTerminationStat(
|
||||||
connectionId,
|
'incoming',
|
||||||
component: 'route-handler'
|
'session_ticket_blocked_no_sni'
|
||||||
});
|
);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
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) {
|
socket.cork();
|
||||||
logger.log('info', `TLS connection with SNI`, {
|
socket.write(alert);
|
||||||
connectionId,
|
socket.uncork();
|
||||||
serverName: serverName || '(empty)',
|
socket.end();
|
||||||
component: 'route-handler'
|
} 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
|
// 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
|
// First data handler to capture initial TLS handshake or PROXY protocol
|
||||||
@@ -454,7 +478,8 @@ export class RouteConnectionHandler {
|
|||||||
socket: plugins.net.Socket | WrappedSocket,
|
socket: plugins.net.Socket | WrappedSocket,
|
||||||
record: IConnectionRecord,
|
record: IConnectionRecord,
|
||||||
serverName: string,
|
serverName: string,
|
||||||
initialChunk?: Buffer
|
initialChunk?: Buffer,
|
||||||
|
detectionResult?: any // Using any temporarily to avoid circular dependency issues
|
||||||
): void {
|
): void {
|
||||||
const connectionId = record.id;
|
const connectionId = record.id;
|
||||||
const localPort = record.localPort;
|
const localPort = record.localPort;
|
||||||
@@ -635,7 +660,7 @@ export class RouteConnectionHandler {
|
|||||||
// Handle the route based on its action type
|
// Handle the route based on its action type
|
||||||
switch (route.action.type) {
|
switch (route.action.type) {
|
||||||
case 'forward':
|
case 'forward':
|
||||||
return this.handleForwardAction(socket, record, route, initialChunk);
|
return this.handleForwardAction(socket, record, route, initialChunk, detectionResult);
|
||||||
|
|
||||||
case 'socket-handler':
|
case 'socket-handler':
|
||||||
logger.log('info', `Handling socket-handler action for route ${route.name}`, {
|
logger.log('info', `Handling socket-handler action for route ${route.name}`, {
|
||||||
@@ -738,7 +763,8 @@ export class RouteConnectionHandler {
|
|||||||
socket: plugins.net.Socket | WrappedSocket,
|
socket: plugins.net.Socket | WrappedSocket,
|
||||||
record: IConnectionRecord,
|
record: IConnectionRecord,
|
||||||
route: IRouteConfig,
|
route: IRouteConfig,
|
||||||
initialChunk?: Buffer
|
initialChunk?: Buffer,
|
||||||
|
detectionResult?: any // Using any temporarily to avoid circular dependency issues
|
||||||
): void {
|
): void {
|
||||||
const connectionId = record.id;
|
const connectionId = record.id;
|
||||||
const action = route.action as IRouteAction;
|
const action = route.action as IRouteAction;
|
||||||
@@ -819,14 +845,11 @@ export class RouteConnectionHandler {
|
|||||||
// Create context for target selection
|
// Create context for target selection
|
||||||
const targetSelectionContext = {
|
const targetSelectionContext = {
|
||||||
port: record.localPort,
|
port: record.localPort,
|
||||||
path: undefined, // Will be populated from HTTP headers if available
|
path: record.httpInfo?.path,
|
||||||
headers: undefined, // Will be populated from HTTP headers if available
|
headers: record.httpInfo?.headers,
|
||||||
method: undefined // Will be populated from HTTP headers if available
|
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);
|
const selectedTarget = this.selectTarget(action.targets, targetSelectionContext);
|
||||||
if (!selectedTarget) {
|
if (!selectedTarget) {
|
||||||
logger.log('error', `No matching target found for connection ${connectionId}`, {
|
logger.log('error', `No matching target found for connection ${connectionId}`, {
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user