Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
6d1a3802ca | |||
5a3bf2cae6 | |||
f1c0b8bfb7 | |||
4a72d9f3bf | |||
88b4df18b8 | |||
fb2354146e | |||
ec88e9a5b2 | |||
cf1c41b27c | |||
2482c8ae6b |
18
changelog.md
18
changelog.md
@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-05-09 - 12.1.0 - feat(smartproxy)
|
||||
Migrate internal module paths and update HTTP/ACME components for SmartProxy
|
||||
|
||||
- Mark migration tasks as complete in readme.plan.md (checkboxes updated to ✅)
|
||||
- Moved Port80Handler from ts/port80handler to ts/http/port80 (and extracted challenge responder)
|
||||
- Migrated redirect handlers and router components to ts/http/redirects and ts/http/router respectively
|
||||
- Updated re-exports in ts/index.ts and ts/plugins.ts to expose new module paths and additional exports
|
||||
- Refactored CertificateEvents to include deprecation notes on Port80HandlerEvents
|
||||
- Adjusted internal module organization for TLS, ACME, and forwarding (SNI extraction, client-hello parsing, etc.)
|
||||
- Added minor logging and formatting improvements in several modules
|
||||
|
||||
## 2025-05-09 - 12.0.0 - BREAKING CHANGE(forwarding)
|
||||
Rename 'sniPassthrough' export to 'httpsPassthrough' for consistent naming and remove outdated forwarding example
|
||||
|
||||
- Updated test files (test.forwarding.ts and test.forwarding.unit.ts) to reference 'httpsPassthrough' instead of the old alias 'sniPassthrough'
|
||||
- Modified ts/smartproxy/forwarding/index.ts to export 'httpsPassthrough' without the legacy alias
|
||||
- Removed ts/examples/forwarding-example.ts to clean up redundant example code
|
||||
|
||||
## 2025-05-09 - 11.0.0 - BREAKING CHANGE(forwarding)
|
||||
Refactor unified forwarding API and remove redundant documentation. Removed docs/forwarding-system.md (its content is migrated into readme.md) and updated helper functions (e.g. replacing sniPassthrough with httpsPassthrough) to accept configuration objects. Legacy fields in domain configurations (allowedIPs, blockedIPs, useNetworkProxy, networkProxyPort, connectionTimeout) have been removed in favor of forwarding.security and advanced options. Tests and examples have been updated accordingly.
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "11.0.0",
|
||||
"version": "12.1.0",
|
||||
"private": false,
|
||||
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
|
||||
"main": "dist_ts/index.js",
|
||||
|
773
readme.plan.md
773
readme.plan.md
@ -1,471 +1,378 @@
|
||||
# SmartProxy Unified Forwarding Configuration Plan
|
||||
# SmartProxy Project Restructuring Plan
|
||||
|
||||
## Project Goal
|
||||
Create a clean, use-case driven forwarding configuration interface for SmartProxy that elegantly handles all forwarding scenarios: SNI-based forwarding, termination-based forwarding (NetworkProxy), HTTP forwarding, and ACME challenge forwarding.
|
||||
Reorganize the SmartProxy codebase to improve maintainability, readability, and developer experience through:
|
||||
1. Standardized naming conventions
|
||||
2. Consistent directory structure
|
||||
3. Modern TypeScript patterns
|
||||
4. Clear separation of concerns
|
||||
|
||||
## Current State
|
||||
Currently, SmartProxy has several different forwarding mechanisms configured separately:
|
||||
1. **HTTPS/SNI forwarding** via `IDomainConfig` properties
|
||||
2. **NetworkProxy forwarding** via `useNetworkProxy` in domain configs
|
||||
3. **HTTP forwarding** via Port80Handler's `forward` configuration
|
||||
4. **ACME challenge forwarding** via `acmeForward` configuration
|
||||
## Current Architecture Analysis
|
||||
|
||||
This separation creates configuration complexity and reduced cohesion between related settings.
|
||||
Based on code analysis, SmartProxy has several well-defined but inconsistently named modules:
|
||||
|
||||
## Proposed Solution: Clean Use-Case Driven Forwarding Interface
|
||||
1. **SmartProxy** - Primary TCP/SNI-based proxy with configurable routing
|
||||
2. **NetworkProxy** - HTTP/HTTPS reverse proxy with TLS termination
|
||||
3. **Port80Handler** - HTTP port 80 handling for ACME and redirects
|
||||
4. **NfTablesProxy** - Low-level port forwarding via nftables
|
||||
5. **Forwarding System** - New unified configuration for all forwarding types
|
||||
|
||||
### Phase 1: Design Streamlined Forwarding Interface
|
||||
The codebase employs several strong design patterns:
|
||||
- **Factory Pattern** for creating forwarding handlers
|
||||
- **Strategy Pattern** for implementing different forwarding methods
|
||||
- **Manager Pattern** for encapsulating domain, connection, and security logic
|
||||
- **Event-Driven Architecture** for loose coupling between components
|
||||
|
||||
- [ ] Create a use-case driven `IForwardConfig` interface that simplifies configuration:
|
||||
## Target Directory Structure
|
||||
|
||||
```typescript
|
||||
export interface IForwardConfig {
|
||||
// Define the primary forwarding type - use-case driven approach
|
||||
type: 'http-only' | 'https-passthrough' | 'https-terminate-to-http' | 'https-terminate-to-https';
|
||||
|
||||
// Target configuration
|
||||
target: {
|
||||
host: string | string[]; // Support single host or round-robin
|
||||
port: number;
|
||||
};
|
||||
|
||||
// HTTP-specific options
|
||||
http?: {
|
||||
enabled?: boolean; // Defaults to true for http-only, optional for others
|
||||
redirectToHttps?: boolean; // Redirect HTTP to HTTPS
|
||||
headers?: Record<string, string>; // Custom headers for HTTP responses
|
||||
};
|
||||
|
||||
// HTTPS-specific options
|
||||
https?: {
|
||||
customCert?: { // Use custom cert instead of auto-provisioned
|
||||
key: string;
|
||||
cert: string;
|
||||
};
|
||||
forwardSni?: boolean; // Forward SNI info in passthrough mode
|
||||
};
|
||||
|
||||
// ACME certificate handling
|
||||
acme?: {
|
||||
enabled?: boolean; // Enable ACME certificate provisioning
|
||||
maintenance?: boolean; // Auto-renew certificates
|
||||
production?: boolean; // Use production ACME servers
|
||||
forwardChallenges?: { // Forward ACME challenges
|
||||
host: string;
|
||||
port: number;
|
||||
useTls?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// Security options
|
||||
security?: {
|
||||
allowedIps?: string[]; // IPs allowed to connect
|
||||
blockedIps?: string[]; // IPs blocked from connecting
|
||||
maxConnections?: number; // Max simultaneous connections
|
||||
};
|
||||
|
||||
// Advanced options
|
||||
advanced?: {
|
||||
portRanges?: Array<{ from: number; to: number }>; // Allowed port ranges
|
||||
networkProxyPort?: number; // Custom NetworkProxy port if using terminate mode
|
||||
keepAlive?: boolean; // Enable TCP keepalive
|
||||
timeout?: number; // Connection timeout in ms
|
||||
headers?: Record<string, string>; // Custom headers with support for variables like {sni}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Create New Domain Configuration Interface
|
||||
|
||||
- [ ] Replace existing `IDomainConfig` interface with a new one using the forwarding pattern:
|
||||
|
||||
```typescript
|
||||
export interface IDomainConfig {
|
||||
// Core properties
|
||||
domains: string[]; // Domain patterns to match
|
||||
|
||||
// Unified forwarding configuration
|
||||
forwarding: IForwardConfig;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Implement Forwarding Handler System
|
||||
|
||||
- [ ] Create an implementation strategy focused on the new forwarding types:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Base class for all forwarding handlers
|
||||
*/
|
||||
abstract class ForwardingHandler {
|
||||
constructor(protected config: IForwardConfig) {}
|
||||
|
||||
abstract handleConnection(socket: Socket): void;
|
||||
abstract handleHttpRequest(req: IncomingMessage, res: ServerResponse): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating the appropriate handler based on forwarding type
|
||||
*/
|
||||
class ForwardingHandlerFactory {
|
||||
public static createHandler(config: IForwardConfig): ForwardingHandler {
|
||||
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:
|
||||
throw new Error(`Unknown forwarding type: ${config.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples for Common Scenarios
|
||||
|
||||
### 1. Basic HTTP Server
|
||||
|
||||
```typescript
|
||||
{
|
||||
domains: ['example.com'],
|
||||
forwarding: {
|
||||
type: 'http-only',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. HTTPS Termination with HTTP Backend
|
||||
|
||||
```typescript
|
||||
{
|
||||
domains: ['secure.example.com'],
|
||||
forwarding: {
|
||||
type: 'https-terminate-to-http',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
},
|
||||
acme: {
|
||||
production: true // Use production Let's Encrypt
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. HTTPS Termination with HTTPS Backend
|
||||
|
||||
```typescript
|
||||
{
|
||||
domains: ['secure-backend.example.com'],
|
||||
forwarding: {
|
||||
type: 'https-terminate-to-https',
|
||||
target: {
|
||||
host: 'internal-api',
|
||||
port: 8443
|
||||
},
|
||||
http: {
|
||||
redirectToHttps: true // Redirect HTTP requests to HTTPS
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. SNI Passthrough
|
||||
|
||||
```typescript
|
||||
{
|
||||
domains: ['passthrough.example.com'],
|
||||
forwarding: {
|
||||
type: 'https-passthrough',
|
||||
target: {
|
||||
host: '10.0.0.5',
|
||||
port: 443
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Mixed HTTP/HTTPS with Custom ACME Forwarding
|
||||
|
||||
```typescript
|
||||
{
|
||||
domains: ['mixed.example.com'],
|
||||
forwarding: {
|
||||
type: 'https-terminate-to-http',
|
||||
target: {
|
||||
host: 'localhost',
|
||||
port: 3000
|
||||
},
|
||||
http: {
|
||||
redirectToHttps: false // Allow both HTTP and HTTPS access
|
||||
},
|
||||
acme: {
|
||||
enabled: true,
|
||||
maintenance: true,
|
||||
forwardChallenges: {
|
||||
host: '192.168.1.100',
|
||||
port: 8080
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Load-Balanced Backend
|
||||
|
||||
```typescript
|
||||
{
|
||||
domains: ['api.example.com'],
|
||||
forwarding: {
|
||||
type: 'https-terminate-to-https',
|
||||
target: {
|
||||
host: ['10.0.0.10', '10.0.0.11', '10.0.0.12'], // Round-robin
|
||||
port: 8443
|
||||
},
|
||||
security: {
|
||||
allowedIps: ['10.0.0.*', '192.168.1.*'] // Restrict access
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Advanced Proxy Chain with Custom Headers
|
||||
|
||||
```typescript
|
||||
{
|
||||
domains: ['secure-chain.example.com'],
|
||||
forwarding: {
|
||||
type: 'https-terminate-to-https',
|
||||
target: {
|
||||
host: 'backend-gateway.internal',
|
||||
port: 443
|
||||
},
|
||||
advanced: {
|
||||
// Pass original client info to backend
|
||||
headers: {
|
||||
'X-Original-SNI': '{sni}',
|
||||
'X-Client-IP': '{clientIp}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/ts
|
||||
├── /core # Core functionality
|
||||
│ ├── /models # Data models and interfaces
|
||||
│ ├── /utils # Shared utilities (IP validation, logging, etc.)
|
||||
│ └── /events # Common event definitions
|
||||
├── /certificate # Certificate management
|
||||
│ ├── /acme # ACME-specific functionality
|
||||
│ ├── /providers # Certificate providers (static, ACME)
|
||||
│ └── /storage # Certificate storage mechanisms
|
||||
├── /forwarding # Forwarding system
|
||||
│ ├── /handlers # Various forwarding handlers
|
||||
│ │ ├── base-handler.ts # Abstract base handler
|
||||
│ │ ├── http-handler.ts # HTTP-only handler
|
||||
│ │ └── ... # Other handlers
|
||||
│ ├── /config # Configuration models
|
||||
│ │ ├── forwarding-types.ts # Type definitions
|
||||
│ │ ├── domain-config.ts # Domain config utilities
|
||||
│ │ └── domain-manager.ts # Domain routing manager
|
||||
│ └── /factory # Factory for creating handlers
|
||||
├── /proxies # Different proxy implementations
|
||||
│ ├── /smart-proxy # SmartProxy implementation
|
||||
│ │ ├── /models # SmartProxy-specific interfaces
|
||||
│ │ ├── smart-proxy.ts # Main SmartProxy class
|
||||
│ │ └── ... # Supporting classes
|
||||
│ ├── /network-proxy # NetworkProxy implementation
|
||||
│ │ ├── /models # NetworkProxy-specific interfaces
|
||||
│ │ ├── network-proxy.ts # Main NetworkProxy class
|
||||
│ │ └── ... # Supporting classes
|
||||
│ └── /nftables-proxy # NfTablesProxy implementation
|
||||
├── /tls # TLS-specific functionality
|
||||
│ ├── /sni # SNI handling components
|
||||
│ └── /alerts # TLS alerts system
|
||||
└── /http # HTTP-specific functionality
|
||||
├── /port80 # Port80Handler components
|
||||
├── /router # HTTP routing system
|
||||
└── /redirects # Redirect handlers
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Task 1: Core Types and Interfaces (Week 1)
|
||||
- [ ] Create the new `IForwardConfig` interface in `classes.pp.interfaces.ts`
|
||||
- [ ] Design the new `IDomainConfig` interface using the forwarding property
|
||||
- [ ] Define the internal data types for expanded configuration
|
||||
### Phase 1: Project Setup & Core Structure (Week 1)
|
||||
|
||||
### Task 2: Forwarding Handlers (Week 1-2)
|
||||
- [ ] Create abstract `ForwardingHandler` base class
|
||||
- [ ] Implement concrete handlers for each forwarding type:
|
||||
- [ ] `HttpForwardingHandler` - For HTTP-only configurations
|
||||
- [ ] `HttpsPassthroughHandler` - For SNI passthrough
|
||||
- [ ] `HttpsTerminateToHttpHandler` - For TLS termination to HTTP backends
|
||||
- [ ] `HttpsTerminateToHttpsHandler` - For TLS termination to HTTPS backends
|
||||
- [ ] Implement `ForwardingHandlerFactory` to create the appropriate handler
|
||||
- [x] Create new directory structure
|
||||
- [x] Create core subdirectories within `ts` directory
|
||||
- [x] Set up barrel files (`index.ts`) in each directory
|
||||
|
||||
### Task 3: SmartProxy Integration (Week 2-3)
|
||||
- [ ] Update `SmartProxy` class to use the new forwarding system
|
||||
- [ ] Modify `ConnectionHandler` to delegate to forwarding handlers
|
||||
- [ ] Refactor domain configuration processing to use forwarding types
|
||||
- [ ] Update `Port80Handler` integration to work with the new system
|
||||
- [x] Migrate core utilities
|
||||
- [x] Keep `ts/plugins.ts` in its current location per project requirements
|
||||
- [x] Move `ts/common/types.ts` → `ts/core/models/common-types.ts`
|
||||
- [x] Move `ts/common/eventUtils.ts` → `ts/core/utils/event-utils.ts`
|
||||
- [x] Extract `ValidationUtils` → `ts/core/utils/validation-utils.ts`
|
||||
- [x] Extract `IpUtils` → `ts/core/utils/ip-utils.ts`
|
||||
|
||||
### Task 4: Certificate Management (Week 3)
|
||||
- [ ] Create a certificate management system that works with forwarding types
|
||||
- [ ] Implement automatic ACME provisioning based on forwarding type
|
||||
- [ ] Add custom certificate support
|
||||
- [x] Update build and test scripts
|
||||
- [x] Modify `package.json` build script for new structure
|
||||
- [x] Create parallel test structure
|
||||
|
||||
### Task 5: Testing & Helper Functions (Week 4)
|
||||
- [ ] Create helper functions for common forwarding patterns
|
||||
- [ ] Implement comprehensive test suite for each forwarding handler
|
||||
- [ ] Add validation for forwarding configurations
|
||||
### Phase 2: Forwarding System Migration (Weeks 1-2) ✅
|
||||
|
||||
### Task 6: Documentation (Week 4)
|
||||
- [ ] Create detailed documentation for the new forwarding system
|
||||
- [ ] Document the forwarding types and their use cases
|
||||
- [ ] Update README with the new configuration examples
|
||||
This component has the cleanest design, so we'll start migration here:
|
||||
|
||||
## Detailed Type Documentation
|
||||
- [x] Migrate forwarding types and interfaces
|
||||
- [x] Move `ts/smartproxy/types/forwarding.types.ts` → `ts/forwarding/config/forwarding-types.ts`
|
||||
- [x] Normalize interface names (remove 'I' prefix where appropriate)
|
||||
|
||||
### Core Forwarding Types
|
||||
- [x] Migrate domain configuration
|
||||
- [x] Move `ts/smartproxy/forwarding/domain-config.ts` → `ts/forwarding/config/domain-config.ts`
|
||||
- [x] Move `ts/smartproxy/forwarding/domain-manager.ts` → `ts/forwarding/config/domain-manager.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* The primary forwarding types supported by SmartProxy
|
||||
*/
|
||||
export type ForwardingType =
|
||||
| '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
|
||||
```
|
||||
- [ ] Migrate handler implementations
|
||||
- [x] Move base handler: `forwarding.handler.ts` → `ts/forwarding/handlers/base-handler.ts`
|
||||
- [x] Move HTTP handler: `http.handler.ts` → `ts/forwarding/handlers/http-handler.ts`
|
||||
- [x] Move passthrough handler: `https-passthrough.handler.ts` → `ts/forwarding/handlers/https-passthrough-handler.ts`
|
||||
- [x] Move TLS termination handlers to respective files in `ts/forwarding/handlers/`
|
||||
- [x] Move `https-terminate-to-http.handler.ts` → `ts/forwarding/handlers/https-terminate-to-http-handler.ts`
|
||||
- [x] Move `https-terminate-to-https.handler.ts` → `ts/forwarding/handlers/https-terminate-to-https-handler.ts`
|
||||
- [x] Move factory: `forwarding.factory.ts` → `ts/forwarding/factory/forwarding-factory.ts`
|
||||
|
||||
### Type-Specific Behavior
|
||||
- [x] Create proper forwarding system exports
|
||||
- [x] Update all imports in forwarding components using relative paths
|
||||
- [x] Create comprehensive barrel file in `ts/forwarding/index.ts`
|
||||
- [x] Test forwarding system in isolation
|
||||
|
||||
Each forwarding type has specific default behavior:
|
||||
### Phase 3: Certificate Management Migration (Week 2) ✅
|
||||
|
||||
#### HTTP-Only
|
||||
- Handles only HTTP traffic
|
||||
- No TLS/HTTPS support
|
||||
- No certificate management
|
||||
- [x] Create certificate management structure
|
||||
- [x] Create `ts/certificate/models/certificate-types.ts` for interfaces
|
||||
- [x] Extract certificate events to `ts/certificate/events/certificate-events.ts`
|
||||
|
||||
#### HTTPS Passthrough
|
||||
- Forwards raw TLS traffic to backend (no termination)
|
||||
- Passes SNI information through
|
||||
- No HTTP support (TLS only)
|
||||
- No certificate management
|
||||
- [x] Migrate certificate providers
|
||||
- [x] Move `ts/smartproxy/classes.pp.certprovisioner.ts` → `ts/certificate/providers/cert-provisioner.ts`
|
||||
- [x] Move `ts/common/acmeFactory.ts` → `ts/certificate/acme/acme-factory.ts`
|
||||
- [x] Extract ACME challenge handling to `ts/certificate/acme/challenge-handler.ts`
|
||||
|
||||
#### HTTPS Terminate to HTTP
|
||||
- Terminates TLS at SmartProxy
|
||||
- Connects to backend using HTTP (non-TLS)
|
||||
- Manages certificates automatically (ACME)
|
||||
- Supports HTTP requests with option to redirect to HTTPS
|
||||
- [x] Update certificate utilities
|
||||
- [x] Move `ts/helpers.certificates.ts` → `ts/certificate/utils/certificate-helpers.ts`
|
||||
- [x] Create certificate storage in `ts/certificate/storage/file-storage.ts`
|
||||
- [x] Create proper exports in `ts/certificate/index.ts`
|
||||
|
||||
#### HTTPS Terminate to HTTPS
|
||||
- Terminates client TLS at SmartProxy
|
||||
- Creates new TLS connection to backend
|
||||
- Manages certificates automatically (ACME)
|
||||
- Supports HTTP requests with option to redirect to HTTPS
|
||||
### Phase 4: TLS & SNI Handling Migration (Week 2-3) ✅
|
||||
|
||||
## Handler Implementation Strategy
|
||||
- [x] Migrate TLS alert system
|
||||
- [x] Move `ts/smartproxy/classes.pp.tlsalert.ts` → `ts/tls/alerts/tls-alert.ts`
|
||||
- [x] Extract common TLS utilities to `ts/tls/utils/tls-utils.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Handler for HTTP-only forwarding
|
||||
*/
|
||||
class HttpForwardingHandler extends ForwardingHandler {
|
||||
public handleConnection(socket: Socket): void {
|
||||
// Process HTTP connection
|
||||
// For HTTP-only, we'll mostly defer to handleHttpRequest
|
||||
}
|
||||
|
||||
public handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
// Forward HTTP request to target
|
||||
const target = this.getTargetFromConfig();
|
||||
this.proxyRequest(req, res, target);
|
||||
}
|
||||
}
|
||||
- [x] Migrate SNI handling
|
||||
- [x] Move `ts/smartproxy/classes.pp.snihandler.ts` → `ts/tls/sni/sni-handler.ts`
|
||||
- [x] Extract SNI extraction to `ts/tls/sni/sni-extraction.ts`
|
||||
- [x] Extract ClientHello parsing to `ts/tls/sni/client-hello-parser.ts`
|
||||
|
||||
/**
|
||||
* Handler for HTTPS passthrough (SNI forwarding)
|
||||
*/
|
||||
class HttpsPassthroughHandler extends ForwardingHandler {
|
||||
public handleConnection(socket: Socket): void {
|
||||
// Extract SNI from TLS ClientHello if needed
|
||||
// Forward raw TLS traffic to target without termination
|
||||
const target = this.getTargetFromConfig();
|
||||
this.forwardTlsConnection(socket, target);
|
||||
}
|
||||
|
||||
public handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
// HTTP not supported in SNI passthrough mode
|
||||
res.statusCode = 404;
|
||||
res.end('HTTP not supported for this domain');
|
||||
}
|
||||
}
|
||||
### Phase 5: HTTP Component Migration (Week 3) ✅
|
||||
|
||||
/**
|
||||
* Handler for HTTPS termination with HTTP backend
|
||||
*/
|
||||
class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
||||
private tlsContext: SecureContext;
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
// Set up TLS termination context
|
||||
this.tlsContext = await this.createTlsContext();
|
||||
}
|
||||
|
||||
public handleConnection(socket: Socket): void {
|
||||
// Terminate TLS
|
||||
const tlsSocket = this.createTlsSocket(socket, this.tlsContext);
|
||||
|
||||
// Forward to HTTP backend after TLS termination
|
||||
tlsSocket.on('data', (data) => {
|
||||
this.forwardToHttpBackend(data);
|
||||
});
|
||||
}
|
||||
|
||||
public handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
if (this.config.http?.redirectToHttps) {
|
||||
// Redirect to HTTPS if configured
|
||||
this.redirectToHttps(req, res);
|
||||
} else {
|
||||
// Handle HTTP request
|
||||
const target = this.getTargetFromConfig();
|
||||
this.proxyRequest(req, res, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
- [x] Migrate Port80Handler
|
||||
- [x] Move `ts/port80handler/classes.port80handler.ts` → `ts/http/port80/port80-handler.ts`
|
||||
- [x] Extract ACME challenge handling to `ts/http/port80/challenge-responder.ts`
|
||||
|
||||
/**
|
||||
* Handler for HTTPS termination with HTTPS backend
|
||||
*/
|
||||
class HttpsTerminateToHttpsHandler extends ForwardingHandler {
|
||||
private tlsContext: SecureContext;
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
// Set up TLS termination context
|
||||
this.tlsContext = await this.createTlsContext();
|
||||
}
|
||||
|
||||
public handleConnection(socket: Socket): void {
|
||||
// Terminate client TLS
|
||||
const tlsSocket = this.createTlsSocket(socket, this.tlsContext);
|
||||
|
||||
// Create new TLS connection to backend
|
||||
tlsSocket.on('data', (data) => {
|
||||
this.forwardToHttpsBackend(data);
|
||||
});
|
||||
}
|
||||
|
||||
public handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
if (this.config.http?.redirectToHttps) {
|
||||
// Redirect to HTTPS if configured
|
||||
this.redirectToHttps(req, res);
|
||||
} else {
|
||||
// Handle HTTP request via HTTPS to backend
|
||||
const target = this.getTargetFromConfig();
|
||||
this.proxyRequestOverHttps(req, res, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [x] Migrate redirect handlers
|
||||
- [x] Move `ts/redirect/classes.redirect.ts` → `ts/http/redirects/redirect-handler.ts`
|
||||
- [x] Create `ts/http/redirects/ssl-redirect.ts` for specialized redirects
|
||||
|
||||
## Benefits of This Approach
|
||||
- [x] Migrate router components
|
||||
- [x] Move `ts/classes.router.ts` → `ts/http/router/proxy-router.ts`
|
||||
- [x] Extract route matching to `ts/http/router/route-matcher.ts`
|
||||
|
||||
1. **Clean, Type-Driven Design**
|
||||
- Forwarding types clearly express intent
|
||||
- No backward compatibility compromises
|
||||
- Code structure follows the domain model
|
||||
### Phase 6: Proxy Implementation Migration (Weeks 3-4)
|
||||
|
||||
2. **Explicit Configuration**
|
||||
- Configuration directly maps to behavior
|
||||
- Reduced chance of unexpected behavior
|
||||
- [ ] Migrate SmartProxy components
|
||||
- [ ] First, migrate interfaces to `ts/proxies/smart-proxy/models/`
|
||||
- [ ] Move core class: `ts/smartproxy/classes.smartproxy.ts` → `ts/proxies/smart-proxy/smart-proxy.ts`
|
||||
- [ ] Move supporting classes using consistent naming
|
||||
- [ ] Normalize interface names (SmartProxyOptions instead of IPortProxySettings)
|
||||
|
||||
3. **Modular Implementation**
|
||||
- Each forwarding type handled by dedicated class
|
||||
- Clear separation of concerns
|
||||
- Easier to test and extend
|
||||
- [ ] Migrate NetworkProxy components
|
||||
- [ ] First, migrate interfaces to `ts/proxies/network-proxy/models/`
|
||||
- [ ] Move core class: `ts/networkproxy/classes.np.networkproxy.ts` → `ts/proxies/network-proxy/network-proxy.ts`
|
||||
- [ ] Move supporting classes using consistent naming
|
||||
|
||||
4. **Simplified Mental Model**
|
||||
- Users think in terms of use cases, not low-level settings
|
||||
- Configuration matches mental model
|
||||
- [ ] Migrate NfTablesProxy
|
||||
- [ ] Move `ts/nfttablesproxy/classes.nftablesproxy.ts` → `ts/proxies/nftables-proxy/nftables-proxy.ts`
|
||||
|
||||
5. **Future-Proof**
|
||||
- Easy to add new forwarding types
|
||||
- Clean extension points for new features
|
||||
### Phase 7: Integration & Main Module (Week 4-5)
|
||||
|
||||
- [ ] Create main entry points
|
||||
- [ ] Update `ts/index.ts` with all public exports
|
||||
- [ ] Ensure backward compatibility with type aliases
|
||||
- [ ] Implement proper namespace exports
|
||||
|
||||
- [ ] Update module dependencies
|
||||
- [ ] Update relative import paths in all modules
|
||||
- [ ] Resolve circular dependencies if found
|
||||
- [ ] Test cross-module integration
|
||||
|
||||
### Phase 8: Interface Normalization (Week 5)
|
||||
|
||||
- [ ] Standardize interface naming
|
||||
- [ ] Rename `IPortProxySettings` → `SmartProxyOptions`
|
||||
- [ ] Rename `IDomainConfig` → `DomainConfig`
|
||||
- [ ] Rename `IConnectionRecord` → `ConnectionRecord`
|
||||
- [ ] Rename `INetworkProxyOptions` → `NetworkProxyOptions`
|
||||
- [ ] Rename other interfaces for consistency
|
||||
|
||||
- [ ] Provide backward compatibility
|
||||
- [ ] Add type aliases for renamed interfaces
|
||||
- [ ] Ensure all exports are compatible with existing code
|
||||
|
||||
### Phase 9: Testing & Validation (Weeks 5-6)
|
||||
|
||||
- [ ] Reorganize test structure
|
||||
- [ ] Create test directories matching source structure
|
||||
- [ ] Move tests to appropriate directories
|
||||
- [ ] Update test imports and references
|
||||
|
||||
- [ ] Add test coverage for new components
|
||||
- [ ] Create unit tests for extracted utilities
|
||||
- [ ] Ensure integration tests cover all scenarios
|
||||
- [ ] Validate backward compatibility
|
||||
|
||||
### Phase 10: Documentation (Weeks 6-7)
|
||||
|
||||
- [ ] Update core documentation
|
||||
- [ ] Update README.md with new structure and examples
|
||||
- [ ] Create architecture diagram showing component relationships
|
||||
- [ ] Document import patterns and best practices
|
||||
|
||||
- [ ] Create specialized documentation
|
||||
- [ ] `ARCHITECTURE.md` for system overview
|
||||
- [ ] `FORWARDING.md` for forwarding system specifics
|
||||
- [ ] `CERTIFICATE.md` for certificate management details
|
||||
- [ ] `DEVELOPMENT.md` for contributor guidelines
|
||||
|
||||
- [ ] Update example files
|
||||
- [ ] Update existing examples to use new structure
|
||||
- [ ] Add new examples demonstrating key scenarios
|
||||
|
||||
### Phase 11: Release & Migration Guide (Week 8)
|
||||
|
||||
- [ ] Prepare for release
|
||||
- [ ] Final testing and validation
|
||||
- [ ] Performance comparison with previous version
|
||||
- [ ] Create detailed changelog
|
||||
|
||||
- [ ] Create migration guide
|
||||
- [ ] Document breaking changes
|
||||
- [ ] Provide upgrade instructions
|
||||
- [ ] Include code examples for common scenarios
|
||||
|
||||
## Detailed File Migration Table
|
||||
|
||||
| Current File | New File | Status |
|
||||
|--------------|----------|--------|
|
||||
| **Core/Common Files** | | |
|
||||
| ts/common/types.ts | ts/core/models/common-types.ts | ✅ |
|
||||
| ts/common/eventUtils.ts | ts/core/utils/event-utils.ts | ✅ |
|
||||
| ts/common/acmeFactory.ts | ts/certificate/acme/acme-factory.ts | ❌ |
|
||||
| ts/plugins.ts | ts/plugins.ts (stays in original location) | ✅ |
|
||||
| ts/00_commitinfo_data.ts | ts/00_commitinfo_data.ts (stays in original location) | ✅ |
|
||||
| (new) | ts/core/utils/validation-utils.ts | ✅ |
|
||||
| (new) | ts/core/utils/ip-utils.ts | ✅ |
|
||||
| **Certificate Management** | | |
|
||||
| ts/helpers.certificates.ts | ts/certificate/utils/certificate-helpers.ts | ✅ |
|
||||
| ts/smartproxy/classes.pp.certprovisioner.ts | ts/certificate/providers/cert-provisioner.ts | ✅ |
|
||||
| ts/common/acmeFactory.ts | ts/certificate/acme/acme-factory.ts | ✅ |
|
||||
| (new) | ts/certificate/acme/challenge-handler.ts | ✅ |
|
||||
| (new) | ts/certificate/models/certificate-types.ts | ✅ |
|
||||
| (new) | ts/certificate/events/certificate-events.ts | ✅ |
|
||||
| (new) | ts/certificate/storage/file-storage.ts | ✅ |
|
||||
| **TLS and SNI Handling** | | |
|
||||
| ts/smartproxy/classes.pp.tlsalert.ts | ts/tls/alerts/tls-alert.ts | ✅ |
|
||||
| ts/smartproxy/classes.pp.snihandler.ts | ts/tls/sni/sni-handler.ts | ✅ |
|
||||
| (new) | ts/tls/utils/tls-utils.ts | ✅ |
|
||||
| (new) | ts/tls/sni/sni-extraction.ts | ✅ |
|
||||
| (new) | ts/tls/sni/client-hello-parser.ts | ✅ |
|
||||
| **HTTP Components** | | |
|
||||
| ts/port80handler/classes.port80handler.ts | ts/http/port80/port80-handler.ts | ✅ |
|
||||
| ts/redirect/classes.redirect.ts | ts/http/redirects/redirect-handler.ts | ✅ |
|
||||
| ts/classes.router.ts | ts/http/router/proxy-router.ts | ✅ |
|
||||
| **SmartProxy Components** | | |
|
||||
| ts/smartproxy/classes.smartproxy.ts | ts/proxies/smart-proxy/smart-proxy.ts | ❌ |
|
||||
| ts/smartproxy/classes.pp.interfaces.ts | ts/proxies/smart-proxy/models/interfaces.ts | ❌ |
|
||||
| ts/smartproxy/classes.pp.connectionhandler.ts | ts/proxies/smart-proxy/connection-handler.ts | ❌ |
|
||||
| ts/smartproxy/classes.pp.connectionmanager.ts | ts/proxies/smart-proxy/connection-manager.ts | ❌ |
|
||||
| ts/smartproxy/classes.pp.domainconfigmanager.ts | ts/proxies/smart-proxy/domain-config-manager.ts | ❌ |
|
||||
| ts/smartproxy/classes.pp.portrangemanager.ts | ts/proxies/smart-proxy/port-range-manager.ts | ❌ |
|
||||
| ts/smartproxy/classes.pp.securitymanager.ts | ts/proxies/smart-proxy/security-manager.ts | ❌ |
|
||||
| ts/smartproxy/classes.pp.timeoutmanager.ts | ts/proxies/smart-proxy/timeout-manager.ts | ❌ |
|
||||
| ts/smartproxy/classes.pp.networkproxybridge.ts | ts/proxies/smart-proxy/network-proxy-bridge.ts | ❌ |
|
||||
| **NetworkProxy Components** | | |
|
||||
| ts/networkproxy/classes.np.networkproxy.ts | ts/proxies/network-proxy/network-proxy.ts | ❌ |
|
||||
| ts/networkproxy/classes.np.certificatemanager.ts | ts/proxies/network-proxy/certificate-manager.ts | ❌ |
|
||||
| ts/networkproxy/classes.np.connectionpool.ts | ts/proxies/network-proxy/connection-pool.ts | ❌ |
|
||||
| ts/networkproxy/classes.np.requesthandler.ts | ts/proxies/network-proxy/request-handler.ts | ❌ |
|
||||
| ts/networkproxy/classes.np.websockethandler.ts | ts/proxies/network-proxy/websocket-handler.ts | ❌ |
|
||||
| ts/networkproxy/classes.np.types.ts | ts/proxies/network-proxy/models/types.ts | ❌ |
|
||||
| **NFTablesProxy Components** | | |
|
||||
| ts/nfttablesproxy/classes.nftablesproxy.ts | ts/proxies/nftables-proxy/nftables-proxy.ts | ❌ |
|
||||
| **Forwarding System** | | |
|
||||
| ts/smartproxy/types/forwarding.types.ts | ts/forwarding/config/forwarding-types.ts | ✅ |
|
||||
| ts/smartproxy/forwarding/domain-config.ts | ts/forwarding/config/domain-config.ts | ✅ |
|
||||
| ts/smartproxy/forwarding/domain-manager.ts | ts/forwarding/config/domain-manager.ts | ✅ |
|
||||
| ts/smartproxy/forwarding/forwarding.handler.ts | ts/forwarding/handlers/base-handler.ts | ✅ |
|
||||
| ts/smartproxy/forwarding/http.handler.ts | ts/forwarding/handlers/http-handler.ts | ✅ |
|
||||
| ts/smartproxy/forwarding/https-passthrough.handler.ts | ts/forwarding/handlers/https-passthrough-handler.ts | ✅ |
|
||||
| ts/smartproxy/forwarding/https-terminate-to-http.handler.ts | ts/forwarding/handlers/https-terminate-to-http-handler.ts | ✅ |
|
||||
| ts/smartproxy/forwarding/https-terminate-to-https.handler.ts | ts/forwarding/handlers/https-terminate-to-https-handler.ts | ✅ |
|
||||
| ts/smartproxy/forwarding/forwarding.factory.ts | ts/forwarding/factory/forwarding-factory.ts | ✅ |
|
||||
| ts/smartproxy/forwarding/index.ts | ts/forwarding/index.ts | ✅ |
|
||||
| **Examples and Entry Points** | | |
|
||||
| ts/examples/forwarding-example.ts | ts/examples/forwarding-example.ts | ❌ |
|
||||
| ts/index.ts | ts/index.ts (updated) | ✅ |
|
||||
|
||||
## Import Strategy
|
||||
|
||||
Since path aliases will not be used, we'll maintain standard relative imports throughout the codebase:
|
||||
|
||||
1. **Import Strategy for Deeply Nested Files**
|
||||
```typescript
|
||||
// Example: Importing from another component in a nested directory
|
||||
// From ts/forwarding/handlers/http-handler.ts to ts/core/utils/validation-utils.ts
|
||||
import { validateConfig } from '../../../core/utils/validation-utils.js';
|
||||
```
|
||||
|
||||
2. **Barrel Files for Convenience**
|
||||
```typescript
|
||||
// ts/forwarding/index.ts
|
||||
export * from './config/forwarding-types.js';
|
||||
export * from './handlers/base-handler.js';
|
||||
// ... other exports
|
||||
|
||||
// Then in consuming code:
|
||||
import { ForwardingHandler, httpOnly } from '../../forwarding/index.js';
|
||||
```
|
||||
|
||||
3. **Flattened Imports Where Sensible**
|
||||
```typescript
|
||||
// Avoid excessive nesting with targeted exports
|
||||
// ts/index.ts will export key components for external use
|
||||
import { SmartProxy, NetworkProxy } from '../index.js';
|
||||
```
|
||||
|
||||
## Expected Outcomes
|
||||
|
||||
### Improved Code Organization
|
||||
- Related code will be grouped together in domain-specific directories
|
||||
- Consistent naming conventions will make code navigation intuitive
|
||||
- Clear module boundaries will prevent unintended dependencies
|
||||
|
||||
### Enhanced Developer Experience
|
||||
- Standardized interface naming will improve type clarity
|
||||
- Better documentation will help new contributors get started
|
||||
- Clear and predictable file locations
|
||||
|
||||
### Maintainability Benefits
|
||||
- Smaller, focused files with clear responsibilities
|
||||
- Unified patterns for common operations
|
||||
- Improved separation of concerns between components
|
||||
- Better test organization matching source structure
|
||||
|
||||
### Performance and Compatibility
|
||||
- No performance regression from structural changes
|
||||
- Backward compatibility through type aliases and consistent exports
|
||||
- Clear migration path for dependent projects
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
To ensure a smooth transition, we'll follow this approach for each component:
|
||||
|
||||
1. Create the new file structure first
|
||||
2. Migrate code while updating relative imports
|
||||
3. Test each component as it's migrated
|
||||
4. Only remove old files once all dependencies are updated
|
||||
5. Use a phased approach to allow parallel work
|
||||
|
||||
This approach ensures the codebase remains functional throughout the restructuring process while progressively adopting the new organization.
|
||||
|
||||
## Measuring Success
|
||||
|
||||
We'll measure the success of this restructuring by:
|
||||
|
||||
1. Reduced complexity in the directory structure
|
||||
2. Improved code coverage through better test organization
|
||||
3. Faster onboarding time for new developers
|
||||
4. Less time spent navigating the codebase
|
||||
5. Cleaner git blame output showing cohesive component changes
|
||||
|
||||
## Special Considerations
|
||||
|
||||
- We'll maintain backward compatibility for all public APIs
|
||||
- We'll provide detailed upgrade guides for any breaking changes
|
||||
- We'll ensure the build process produces compatible output
|
||||
- We'll preserve commit history using git move operations where possible
|
@ -12,7 +12,7 @@ const helpers = {
|
||||
httpOnly,
|
||||
tlsTerminateToHttp,
|
||||
tlsTerminateToHttps,
|
||||
sniPassthrough: httpsPassthrough
|
||||
httpsPassthrough
|
||||
};
|
||||
|
||||
tap.test('ForwardingHandlerFactory - apply defaults based on type', async () => {
|
||||
@ -188,7 +188,7 @@ tap.test('Helper Functions - create https-terminate-to-https config', async () =
|
||||
});
|
||||
|
||||
tap.test('Helper Functions - create https-passthrough config', async () => {
|
||||
const config = helpers.sniPassthrough({
|
||||
const config = helpers.httpsPassthrough({
|
||||
target: { host: 'localhost', port: 443 }
|
||||
});
|
||||
expect(config.type).toEqual('https-passthrough');
|
||||
|
@ -12,7 +12,7 @@ const helpers = {
|
||||
httpOnly,
|
||||
tlsTerminateToHttp,
|
||||
tlsTerminateToHttps,
|
||||
sniPassthrough: httpsPassthrough
|
||||
httpsPassthrough
|
||||
};
|
||||
|
||||
tap.test('ForwardingHandlerFactory - apply defaults based on type', async () => {
|
||||
@ -161,7 +161,7 @@ tap.test('Helper Functions - create https-terminate-to-https config', async () =
|
||||
});
|
||||
|
||||
tap.test('Helper Functions - create https-passthrough config', async () => {
|
||||
const config = helpers.sniPassthrough({
|
||||
const config = helpers.httpsPassthrough({
|
||||
target: { host: 'localhost', port: 443 }
|
||||
});
|
||||
expect(config.type).toEqual('https-passthrough');
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '11.0.0',
|
||||
version: '12.1.0',
|
||||
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.'
|
||||
}
|
||||
|
48
ts/certificate/acme/acme-factory.ts
Normal file
48
ts/certificate/acme/acme-factory.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { AcmeOptions } from '../models/certificate-types.js';
|
||||
import { ensureCertificateDirectory } from '../utils/certificate-helpers.js';
|
||||
// We'll need to update this import when we move the Port80Handler
|
||||
import { Port80Handler } from '../../port80handler/classes.port80handler.js';
|
||||
|
||||
/**
|
||||
* Factory to create a Port80Handler with common setup.
|
||||
* Ensures the certificate store directory exists and instantiates the handler.
|
||||
* @param options Port80Handler configuration options
|
||||
* @returns A new Port80Handler instance
|
||||
*/
|
||||
export function buildPort80Handler(
|
||||
options: AcmeOptions
|
||||
): Port80Handler {
|
||||
if (options.certificateStore) {
|
||||
ensureCertificateDirectory(options.certificateStore);
|
||||
console.log(`Ensured certificate store directory: ${options.certificateStore}`);
|
||||
}
|
||||
return new Port80Handler(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates default ACME options with sensible defaults
|
||||
* @param email Account email for ACME provider
|
||||
* @param certificateStore Path to store certificates
|
||||
* @param useProduction Whether to use production ACME servers
|
||||
* @returns Configured ACME options
|
||||
*/
|
||||
export function createDefaultAcmeOptions(
|
||||
email: string,
|
||||
certificateStore: string,
|
||||
useProduction: boolean = false
|
||||
): AcmeOptions {
|
||||
return {
|
||||
accountEmail: email,
|
||||
enabled: true,
|
||||
port: 80,
|
||||
useProduction,
|
||||
httpsRedirectPort: 443,
|
||||
renewThresholdDays: 30,
|
||||
renewCheckIntervalHours: 24,
|
||||
autoRenew: true,
|
||||
certificateStore,
|
||||
skipConfiguredCerts: false
|
||||
};
|
||||
}
|
110
ts/certificate/acme/challenge-handler.ts
Normal file
110
ts/certificate/acme/challenge-handler.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { AcmeOptions, CertificateData } from '../models/certificate-types.js';
|
||||
import { CertificateEvents } from '../events/certificate-events.js';
|
||||
|
||||
/**
|
||||
* Manages ACME challenges and certificate validation
|
||||
*/
|
||||
export class AcmeChallengeHandler extends plugins.EventEmitter {
|
||||
private options: AcmeOptions;
|
||||
private client: any; // ACME client from plugins
|
||||
private pendingChallenges: Map<string, any>;
|
||||
|
||||
/**
|
||||
* Creates a new ACME challenge handler
|
||||
* @param options ACME configuration options
|
||||
*/
|
||||
constructor(options: AcmeOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
this.pendingChallenges = new Map();
|
||||
|
||||
// Initialize ACME client if needed
|
||||
// This is just a placeholder implementation since we don't use the actual
|
||||
// client directly in this implementation - it's handled by Port80Handler
|
||||
this.client = null;
|
||||
console.log('Created challenge handler with options:',
|
||||
options.accountEmail,
|
||||
options.useProduction ? 'production' : 'staging'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates the ACME account key
|
||||
*/
|
||||
private getAccountKey(): Buffer {
|
||||
// Implementation details would depend on plugin requirements
|
||||
// This is a simplified version
|
||||
if (!this.options.certificateStore) {
|
||||
throw new Error('Certificate store is required for ACME challenges');
|
||||
}
|
||||
|
||||
// This is just a placeholder - actual implementation would check for
|
||||
// existing account key and create one if needed
|
||||
return Buffer.from('account-key-placeholder');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a domain using HTTP-01 challenge
|
||||
* @param domain Domain to validate
|
||||
* @param challengeToken ACME challenge token
|
||||
* @param keyAuthorization Key authorization for the challenge
|
||||
*/
|
||||
public async handleHttpChallenge(
|
||||
domain: string,
|
||||
challengeToken: string,
|
||||
keyAuthorization: string
|
||||
): Promise<void> {
|
||||
// Store challenge for response
|
||||
this.pendingChallenges.set(challengeToken, keyAuthorization);
|
||||
|
||||
try {
|
||||
// Wait for challenge validation - this would normally be handled by the ACME client
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
this.emit(CertificateEvents.CERTIFICATE_ISSUED, {
|
||||
domain,
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit(CertificateEvents.CERTIFICATE_FAILED, {
|
||||
domain,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
isRenewal: false
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
// Clean up the challenge
|
||||
this.pendingChallenges.delete(challengeToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Responds to an HTTP-01 challenge request
|
||||
* @param token Challenge token from the request path
|
||||
* @returns The key authorization if found
|
||||
*/
|
||||
public getChallengeResponse(token: string): string | null {
|
||||
return this.pendingChallenges.get(token) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a request path is an ACME challenge
|
||||
* @param path Request path
|
||||
* @returns True if this is an ACME challenge request
|
||||
*/
|
||||
public isAcmeChallenge(path: string): boolean {
|
||||
return path.startsWith('/.well-known/acme-challenge/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the challenge token from an ACME challenge path
|
||||
* @param path Request path
|
||||
* @returns The challenge token if valid
|
||||
*/
|
||||
public extractChallengeToken(path: string): string | null {
|
||||
if (!this.isAcmeChallenge(path)) return null;
|
||||
|
||||
const parts = path.split('/');
|
||||
return parts[parts.length - 1] || null;
|
||||
}
|
||||
}
|
3
ts/certificate/acme/index.ts
Normal file
3
ts/certificate/acme/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* ACME certificate provisioning
|
||||
*/
|
36
ts/certificate/events/certificate-events.ts
Normal file
36
ts/certificate/events/certificate-events.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Certificate-related events emitted by certificate management components
|
||||
*/
|
||||
export enum CertificateEvents {
|
||||
CERTIFICATE_ISSUED = 'certificate-issued',
|
||||
CERTIFICATE_RENEWED = 'certificate-renewed',
|
||||
CERTIFICATE_FAILED = 'certificate-failed',
|
||||
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
||||
CERTIFICATE_APPLIED = 'certificate-applied',
|
||||
// Events moved from Port80Handler for compatibility
|
||||
MANAGER_STARTED = 'manager-started',
|
||||
MANAGER_STOPPED = 'manager-stopped',
|
||||
}
|
||||
|
||||
/**
|
||||
* Port80Handler-specific events including certificate-related ones
|
||||
* @deprecated Use CertificateEvents and HttpEvents instead
|
||||
*/
|
||||
export enum Port80HandlerEvents {
|
||||
CERTIFICATE_ISSUED = 'certificate-issued',
|
||||
CERTIFICATE_RENEWED = 'certificate-renewed',
|
||||
CERTIFICATE_FAILED = 'certificate-failed',
|
||||
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
||||
MANAGER_STARTED = 'manager-started',
|
||||
MANAGER_STOPPED = 'manager-stopped',
|
||||
REQUEST_FORWARDED = 'request-forwarded',
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate provider events
|
||||
*/
|
||||
export enum CertProvisionerEvents {
|
||||
CERTIFICATE_ISSUED = 'certificate',
|
||||
CERTIFICATE_RENEWED = 'certificate',
|
||||
CERTIFICATE_FAILED = 'certificate-failed'
|
||||
}
|
67
ts/certificate/index.ts
Normal file
67
ts/certificate/index.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Certificate management module for SmartProxy
|
||||
* Provides certificate provisioning, storage, and management capabilities
|
||||
*/
|
||||
|
||||
// Certificate types and models
|
||||
export * from './models/certificate-types.js';
|
||||
|
||||
// Certificate events
|
||||
export * from './events/certificate-events.js';
|
||||
|
||||
// Certificate providers
|
||||
export * from './providers/cert-provisioner.js';
|
||||
|
||||
// ACME related exports
|
||||
export * from './acme/acme-factory.js';
|
||||
export * from './acme/challenge-handler.js';
|
||||
|
||||
// Certificate utilities
|
||||
export * from './utils/certificate-helpers.js';
|
||||
|
||||
// Certificate storage
|
||||
export * from './storage/file-storage.js';
|
||||
|
||||
// Convenience function to create a certificate provisioner with common settings
|
||||
import { CertProvisioner } from './providers/cert-provisioner.js';
|
||||
import { buildPort80Handler } from './acme/acme-factory.js';
|
||||
import type { AcmeOptions, DomainForwardConfig } from './models/certificate-types.js';
|
||||
import type { DomainConfig } from '../forwarding/config/domain-config.js';
|
||||
|
||||
/**
|
||||
* Creates a complete certificate provisioning system with default settings
|
||||
* @param domainConfigs Domain configurations
|
||||
* @param acmeOptions ACME options for certificate provisioning
|
||||
* @param networkProxyBridge Bridge to apply certificates to network proxy
|
||||
* @param certProvider Optional custom certificate provider
|
||||
* @returns Configured CertProvisioner
|
||||
*/
|
||||
export function createCertificateProvisioner(
|
||||
domainConfigs: DomainConfig[],
|
||||
acmeOptions: AcmeOptions,
|
||||
networkProxyBridge: any, // Placeholder until NetworkProxyBridge is migrated
|
||||
certProvider?: any // Placeholder until cert provider type is properly defined
|
||||
): CertProvisioner {
|
||||
// Build the Port80Handler for ACME challenges
|
||||
const port80Handler = buildPort80Handler(acmeOptions);
|
||||
|
||||
// Extract ACME-specific configuration
|
||||
const {
|
||||
renewThresholdDays = 30,
|
||||
renewCheckIntervalHours = 24,
|
||||
autoRenew = true,
|
||||
domainForwards = []
|
||||
} = acmeOptions;
|
||||
|
||||
// Create and return the certificate provisioner
|
||||
return new CertProvisioner(
|
||||
domainConfigs,
|
||||
port80Handler,
|
||||
networkProxyBridge,
|
||||
certProvider,
|
||||
renewThresholdDays,
|
||||
renewCheckIntervalHours,
|
||||
autoRenew,
|
||||
domainForwards
|
||||
);
|
||||
}
|
97
ts/certificate/models/certificate-types.ts
Normal file
97
ts/certificate/models/certificate-types.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
|
||||
/**
|
||||
* Certificate data structure containing all necessary information
|
||||
* about a certificate
|
||||
*/
|
||||
export interface CertificateData {
|
||||
domain: string;
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
expiryDate: Date;
|
||||
// Optional source and renewal information for event emissions
|
||||
source?: 'static' | 'http01' | 'dns01';
|
||||
isRenewal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificates pair (private and public keys)
|
||||
*/
|
||||
export interface Certificates {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate failure payload type
|
||||
*/
|
||||
export interface CertificateFailure {
|
||||
domain: string;
|
||||
error: string;
|
||||
isRenewal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate expiry payload type
|
||||
*/
|
||||
export interface CertificateExpiring {
|
||||
domain: string;
|
||||
expiryDate: Date;
|
||||
daysRemaining: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain forwarding configuration
|
||||
*/
|
||||
export interface ForwardConfig {
|
||||
ip: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain-specific forwarding configuration for ACME challenges
|
||||
*/
|
||||
export interface DomainForwardConfig {
|
||||
domain: string;
|
||||
forwardConfig?: ForwardConfig;
|
||||
acmeForwardConfig?: ForwardConfig;
|
||||
sslRedirect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain configuration options
|
||||
*/
|
||||
export interface DomainOptions {
|
||||
domainName: string;
|
||||
sslRedirect: boolean; // if true redirects the request to port 443
|
||||
acmeMaintenance: boolean; // tries to always have a valid cert for this domain
|
||||
forward?: ForwardConfig; // forwards all http requests to that target
|
||||
acmeForward?: ForwardConfig; // forwards letsencrypt requests to this config
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified ACME configuration options used across proxies and handlers
|
||||
*/
|
||||
export interface AcmeOptions {
|
||||
accountEmail?: string; // Email for Let's Encrypt account
|
||||
enabled?: boolean; // Whether ACME is enabled
|
||||
port?: number; // Port to listen on for ACME challenges (default: 80)
|
||||
useProduction?: boolean; // Use production environment (default: staging)
|
||||
httpsRedirectPort?: number; // Port to redirect HTTP requests to HTTPS (default: 443)
|
||||
renewThresholdDays?: number; // Days before expiry to renew certificates
|
||||
renewCheckIntervalHours?: number; // How often to check for renewals (in hours)
|
||||
autoRenew?: boolean; // Whether to automatically renew certificates
|
||||
certificateStore?: string; // Directory to store certificates
|
||||
skipConfiguredCerts?: boolean; // Skip domains with existing certificates
|
||||
domainForwards?: DomainForwardConfig[]; // Domain-specific forwarding configs
|
||||
}
|
||||
|
||||
// Backwards compatibility interfaces
|
||||
export interface ICertificates extends Certificates {}
|
||||
export interface ICertificateData extends CertificateData {}
|
||||
export interface ICertificateFailure extends CertificateFailure {}
|
||||
export interface ICertificateExpiring extends CertificateExpiring {}
|
||||
export interface IForwardConfig extends ForwardConfig {}
|
||||
export interface IDomainForwardConfig extends DomainForwardConfig {}
|
||||
export interface IDomainOptions extends DomainOptions {}
|
||||
export interface IAcmeOptions extends AcmeOptions {}
|
326
ts/certificate/providers/cert-provisioner.ts
Normal file
326
ts/certificate/providers/cert-provisioner.ts
Normal file
@ -0,0 +1,326 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { DomainConfig } from '../../forwarding/config/domain-config.js';
|
||||
import type { CertificateData, DomainForwardConfig, DomainOptions } from '../models/certificate-types.js';
|
||||
import { Port80HandlerEvents, CertProvisionerEvents } from '../events/certificate-events.js';
|
||||
import { Port80Handler } from '../../port80handler/classes.port80handler.js';
|
||||
// We need to define this interface until we migrate NetworkProxyBridge
|
||||
interface NetworkProxyBridge {
|
||||
applyExternalCertificate(certData: CertificateData): void;
|
||||
}
|
||||
|
||||
// This will be imported after NetworkProxyBridge is migrated
|
||||
// import type { NetworkProxyBridge } from '../../proxies/smart-proxy/network-proxy-bridge.js';
|
||||
|
||||
// For backward compatibility
|
||||
export type ISmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01';
|
||||
|
||||
/**
|
||||
* Type for static certificate provisioning
|
||||
*/
|
||||
export type CertProvisionObject = plugins.tsclass.network.ICert | 'http01' | 'dns01';
|
||||
|
||||
/**
|
||||
* CertProvisioner manages certificate provisioning and renewal workflows,
|
||||
* unifying static certificates and HTTP-01 challenges via Port80Handler.
|
||||
*/
|
||||
export class CertProvisioner extends plugins.EventEmitter {
|
||||
private domainConfigs: DomainConfig[];
|
||||
private port80Handler: Port80Handler;
|
||||
private networkProxyBridge: NetworkProxyBridge;
|
||||
private certProvisionFunction?: (domain: string) => Promise<CertProvisionObject>;
|
||||
private forwardConfigs: DomainForwardConfig[];
|
||||
private renewThresholdDays: number;
|
||||
private renewCheckIntervalHours: number;
|
||||
private autoRenew: boolean;
|
||||
private renewManager?: plugins.taskbuffer.TaskManager;
|
||||
// Track provisioning type per domain
|
||||
private provisionMap: Map<string, 'http01' | 'dns01' | 'static'>;
|
||||
|
||||
/**
|
||||
* @param domainConfigs Array of domain configuration objects
|
||||
* @param port80Handler HTTP-01 challenge handler instance
|
||||
* @param networkProxyBridge Bridge for applying external certificates
|
||||
* @param certProvider Optional callback returning a static cert or 'http01'
|
||||
* @param renewThresholdDays Days before expiry to trigger renewals
|
||||
* @param renewCheckIntervalHours Interval in hours to check for renewals
|
||||
* @param autoRenew Whether to automatically schedule renewals
|
||||
* @param forwardConfigs Domain forwarding configurations for ACME challenges
|
||||
*/
|
||||
constructor(
|
||||
domainConfigs: DomainConfig[],
|
||||
port80Handler: Port80Handler,
|
||||
networkProxyBridge: NetworkProxyBridge,
|
||||
certProvider?: (domain: string) => Promise<CertProvisionObject>,
|
||||
renewThresholdDays: number = 30,
|
||||
renewCheckIntervalHours: number = 24,
|
||||
autoRenew: boolean = true,
|
||||
forwardConfigs: DomainForwardConfig[] = []
|
||||
) {
|
||||
super();
|
||||
this.domainConfigs = domainConfigs;
|
||||
this.port80Handler = port80Handler;
|
||||
this.networkProxyBridge = networkProxyBridge;
|
||||
this.certProvisionFunction = certProvider;
|
||||
this.renewThresholdDays = renewThresholdDays;
|
||||
this.renewCheckIntervalHours = renewCheckIntervalHours;
|
||||
this.autoRenew = autoRenew;
|
||||
this.provisionMap = new Map();
|
||||
this.forwardConfigs = forwardConfigs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start initial provisioning and schedule renewals.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Subscribe to Port80Handler certificate events
|
||||
this.setupEventSubscriptions();
|
||||
|
||||
// Apply external forwarding for ACME challenges
|
||||
this.setupForwardingConfigs();
|
||||
|
||||
// Initial provisioning for all domains
|
||||
await this.provisionAllDomains();
|
||||
|
||||
// Schedule renewals if enabled
|
||||
if (this.autoRenew) {
|
||||
this.scheduleRenewals();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event subscriptions for certificate events
|
||||
*/
|
||||
private setupEventSubscriptions(): void {
|
||||
// We need to reimplement subscribeToPort80Handler here
|
||||
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: CertificateData) => {
|
||||
this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, { ...data, source: 'http01', isRenewal: false });
|
||||
});
|
||||
|
||||
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: CertificateData) => {
|
||||
this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, { ...data, source: 'http01', isRenewal: true });
|
||||
});
|
||||
|
||||
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (error) => {
|
||||
this.emit(CertProvisionerEvents.CERTIFICATE_FAILED, error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up forwarding configurations for the Port80Handler
|
||||
*/
|
||||
private setupForwardingConfigs(): void {
|
||||
for (const config of this.forwardConfigs) {
|
||||
const domainOptions: DomainOptions = {
|
||||
domainName: config.domain,
|
||||
sslRedirect: config.sslRedirect || false,
|
||||
acmeMaintenance: false,
|
||||
forward: config.forwardConfig,
|
||||
acmeForward: config.acmeForwardConfig
|
||||
};
|
||||
this.port80Handler.addDomain(domainOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision certificates for all configured domains
|
||||
*/
|
||||
private async provisionAllDomains(): Promise<void> {
|
||||
const domains = this.domainConfigs.flatMap(cfg => cfg.domains);
|
||||
|
||||
for (const domain of domains) {
|
||||
await this.provisionDomain(domain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a certificate for a single domain
|
||||
* @param domain Domain to provision
|
||||
*/
|
||||
private async provisionDomain(domain: string): Promise<void> {
|
||||
const isWildcard = domain.includes('*');
|
||||
let provision: CertProvisionObject = 'http01';
|
||||
|
||||
// Try to get a certificate from the provision function
|
||||
if (this.certProvisionFunction) {
|
||||
try {
|
||||
provision = await this.certProvisionFunction(domain);
|
||||
} catch (err) {
|
||||
console.error(`certProvider error for ${domain}:`, err);
|
||||
}
|
||||
} else if (isWildcard) {
|
||||
// No certProvider: cannot handle wildcard without DNS-01 support
|
||||
console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different provisioning methods
|
||||
if (provision === 'http01') {
|
||||
if (isWildcard) {
|
||||
console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.provisionMap.set(domain, 'http01');
|
||||
this.port80Handler.addDomain({
|
||||
domainName: domain,
|
||||
sslRedirect: true,
|
||||
acmeMaintenance: true
|
||||
});
|
||||
} else if (provision === 'dns01') {
|
||||
// DNS-01 challenges would be handled by the certProvisionFunction
|
||||
this.provisionMap.set(domain, 'dns01');
|
||||
// DNS-01 handling would go here if implemented
|
||||
} else {
|
||||
// Static certificate (e.g., DNS-01 provisioned or user-provided)
|
||||
this.provisionMap.set(domain, 'static');
|
||||
const certObj = provision as plugins.tsclass.network.ICert;
|
||||
const certData: CertificateData = {
|
||||
domain: certObj.domainName,
|
||||
certificate: certObj.publicKey,
|
||||
privateKey: certObj.privateKey,
|
||||
expiryDate: new Date(certObj.validUntil),
|
||||
source: 'static',
|
||||
isRenewal: false
|
||||
};
|
||||
|
||||
this.networkProxyBridge.applyExternalCertificate(certData);
|
||||
this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule certificate renewals using a task manager
|
||||
*/
|
||||
private scheduleRenewals(): void {
|
||||
this.renewManager = new plugins.taskbuffer.TaskManager();
|
||||
|
||||
const renewTask = new plugins.taskbuffer.Task({
|
||||
name: 'CertificateRenewals',
|
||||
taskFunction: async () => await this.performRenewals()
|
||||
});
|
||||
|
||||
const hours = this.renewCheckIntervalHours;
|
||||
const cronExpr = `0 0 */${hours} * * *`;
|
||||
|
||||
this.renewManager.addAndScheduleTask(renewTask, cronExpr);
|
||||
this.renewManager.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform renewals for all domains that need it
|
||||
*/
|
||||
private async performRenewals(): Promise<void> {
|
||||
for (const [domain, type] of this.provisionMap.entries()) {
|
||||
// Skip wildcard domains for HTTP-01 challenges
|
||||
if (domain.includes('*') && type === 'http01') continue;
|
||||
|
||||
try {
|
||||
await this.renewDomain(domain, type);
|
||||
} catch (err) {
|
||||
console.error(`Renewal error for ${domain}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew a certificate for a specific domain
|
||||
* @param domain Domain to renew
|
||||
* @param provisionType Type of provisioning for this domain
|
||||
*/
|
||||
private async renewDomain(domain: string, provisionType: 'http01' | 'dns01' | 'static'): Promise<void> {
|
||||
if (provisionType === 'http01') {
|
||||
await this.port80Handler.renewCertificate(domain);
|
||||
} else if ((provisionType === 'static' || provisionType === 'dns01') && this.certProvisionFunction) {
|
||||
const provision = await this.certProvisionFunction(domain);
|
||||
|
||||
if (provision !== 'http01' && provision !== 'dns01') {
|
||||
const certObj = provision as plugins.tsclass.network.ICert;
|
||||
const certData: CertificateData = {
|
||||
domain: certObj.domainName,
|
||||
certificate: certObj.publicKey,
|
||||
privateKey: certObj.privateKey,
|
||||
expiryDate: new Date(certObj.validUntil),
|
||||
source: 'static',
|
||||
isRenewal: true
|
||||
};
|
||||
|
||||
this.networkProxyBridge.applyExternalCertificate(certData);
|
||||
this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, certData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all scheduled renewal tasks.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.renewManager) {
|
||||
this.renewManager.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a certificate on-demand for the given domain.
|
||||
* @param domain Domain name to provision
|
||||
*/
|
||||
public async requestCertificate(domain: string): Promise<void> {
|
||||
const isWildcard = domain.includes('*');
|
||||
|
||||
// Determine provisioning method
|
||||
let provision: CertProvisionObject = 'http01';
|
||||
|
||||
if (this.certProvisionFunction) {
|
||||
provision = await this.certProvisionFunction(domain);
|
||||
} else if (isWildcard) {
|
||||
// Cannot perform HTTP-01 on wildcard without certProvider
|
||||
throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`);
|
||||
}
|
||||
|
||||
if (provision === 'http01') {
|
||||
if (isWildcard) {
|
||||
throw new Error(`Cannot request HTTP-01 certificate for wildcard domain: ${domain}`);
|
||||
}
|
||||
await this.port80Handler.renewCertificate(domain);
|
||||
} else if (provision === 'dns01') {
|
||||
// DNS-01 challenges would be handled by external mechanisms
|
||||
// This is a placeholder for future implementation
|
||||
console.log(`DNS-01 challenge requested for ${domain}`);
|
||||
} else {
|
||||
// Static certificate (e.g., DNS-01 provisioned) supports wildcards
|
||||
const certObj = provision as plugins.tsclass.network.ICert;
|
||||
const certData: CertificateData = {
|
||||
domain: certObj.domainName,
|
||||
certificate: certObj.publicKey,
|
||||
privateKey: certObj.privateKey,
|
||||
expiryDate: new Date(certObj.validUntil),
|
||||
source: 'static',
|
||||
isRenewal: false
|
||||
};
|
||||
|
||||
this.networkProxyBridge.applyExternalCertificate(certData);
|
||||
this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new domain for certificate provisioning
|
||||
* @param domain Domain to add
|
||||
* @param options Domain configuration options
|
||||
*/
|
||||
public async addDomain(domain: string, options?: {
|
||||
sslRedirect?: boolean;
|
||||
acmeMaintenance?: boolean;
|
||||
}): Promise<void> {
|
||||
const domainOptions: DomainOptions = {
|
||||
domainName: domain,
|
||||
sslRedirect: options?.sslRedirect || true,
|
||||
acmeMaintenance: options?.acmeMaintenance || true
|
||||
};
|
||||
|
||||
this.port80Handler.addDomain(domainOptions);
|
||||
await this.provisionDomain(domain);
|
||||
}
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
export { CertProvisioner as CertificateProvisioner }
|
3
ts/certificate/providers/index.ts
Normal file
3
ts/certificate/providers/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* Certificate providers
|
||||
*/
|
234
ts/certificate/storage/file-storage.ts
Normal file
234
ts/certificate/storage/file-storage.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { CertificateData, Certificates } from '../models/certificate-types.js';
|
||||
import { ensureCertificateDirectory } from '../utils/certificate-helpers.js';
|
||||
|
||||
/**
|
||||
* FileStorage provides file system storage for certificates
|
||||
*/
|
||||
export class FileStorage {
|
||||
private storageDir: string;
|
||||
|
||||
/**
|
||||
* Creates a new file storage provider
|
||||
* @param storageDir Directory to store certificates
|
||||
*/
|
||||
constructor(storageDir: string) {
|
||||
this.storageDir = path.resolve(storageDir);
|
||||
ensureCertificateDirectory(this.storageDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a certificate to the file system
|
||||
* @param domain Domain name
|
||||
* @param certData Certificate data to save
|
||||
*/
|
||||
public async saveCertificate(domain: string, certData: CertificateData): Promise<void> {
|
||||
const sanitizedDomain = this.sanitizeDomain(domain);
|
||||
const certDir = path.join(this.storageDir, sanitizedDomain);
|
||||
ensureCertificateDirectory(certDir);
|
||||
|
||||
const certPath = path.join(certDir, 'fullchain.pem');
|
||||
const keyPath = path.join(certDir, 'privkey.pem');
|
||||
const metaPath = path.join(certDir, 'metadata.json');
|
||||
|
||||
// Write certificate and private key
|
||||
await fs.promises.writeFile(certPath, certData.certificate, 'utf8');
|
||||
await fs.promises.writeFile(keyPath, certData.privateKey, 'utf8');
|
||||
|
||||
// Write metadata
|
||||
const metadata = {
|
||||
domain: certData.domain,
|
||||
expiryDate: certData.expiryDate.toISOString(),
|
||||
source: certData.source || 'unknown',
|
||||
issuedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
await fs.promises.writeFile(
|
||||
metaPath,
|
||||
JSON.stringify(metadata, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a certificate from the file system
|
||||
* @param domain Domain name
|
||||
* @returns Certificate data if found, null otherwise
|
||||
*/
|
||||
public async loadCertificate(domain: string): Promise<CertificateData | null> {
|
||||
const sanitizedDomain = this.sanitizeDomain(domain);
|
||||
const certDir = path.join(this.storageDir, sanitizedDomain);
|
||||
|
||||
if (!fs.existsSync(certDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const certPath = path.join(certDir, 'fullchain.pem');
|
||||
const keyPath = path.join(certDir, 'privkey.pem');
|
||||
const metaPath = path.join(certDir, 'metadata.json');
|
||||
|
||||
try {
|
||||
// Check if all required files exist
|
||||
if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read certificate and private key
|
||||
const certificate = await fs.promises.readFile(certPath, 'utf8');
|
||||
const privateKey = await fs.promises.readFile(keyPath, 'utf8');
|
||||
|
||||
// Try to read metadata if available
|
||||
let expiryDate = new Date();
|
||||
let source: 'static' | 'http01' | 'dns01' | undefined;
|
||||
|
||||
if (fs.existsSync(metaPath)) {
|
||||
const metaContent = await fs.promises.readFile(metaPath, 'utf8');
|
||||
const metadata = JSON.parse(metaContent);
|
||||
|
||||
if (metadata.expiryDate) {
|
||||
expiryDate = new Date(metadata.expiryDate);
|
||||
}
|
||||
|
||||
if (metadata.source) {
|
||||
source = metadata.source as 'static' | 'http01' | 'dns01';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
domain,
|
||||
certificate,
|
||||
privateKey,
|
||||
expiryDate,
|
||||
source
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error loading certificate for ${domain}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a certificate from the file system
|
||||
* @param domain Domain name
|
||||
*/
|
||||
public async deleteCertificate(domain: string): Promise<boolean> {
|
||||
const sanitizedDomain = this.sanitizeDomain(domain);
|
||||
const certDir = path.join(this.storageDir, sanitizedDomain);
|
||||
|
||||
if (!fs.existsSync(certDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Recursively delete the certificate directory
|
||||
await this.deleteDirectory(certDir);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting certificate for ${domain}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all domains with stored certificates
|
||||
* @returns Array of domain names
|
||||
*/
|
||||
public async listCertificates(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(this.storageDir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name);
|
||||
} catch (error) {
|
||||
console.error('Error listing certificates:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a certificate is expiring soon
|
||||
* @param domain Domain name
|
||||
* @param thresholdDays Days threshold to consider expiring
|
||||
* @returns Information about expiring certificate or null
|
||||
*/
|
||||
public async isExpiringSoon(
|
||||
domain: string,
|
||||
thresholdDays: number = 30
|
||||
): Promise<{ domain: string; expiryDate: Date; daysRemaining: number } | null> {
|
||||
const certData = await this.loadCertificate(domain);
|
||||
|
||||
if (!certData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiryDate = certData.expiryDate;
|
||||
const timeRemaining = expiryDate.getTime() - now.getTime();
|
||||
const daysRemaining = Math.floor(timeRemaining / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysRemaining <= thresholdDays) {
|
||||
return {
|
||||
domain,
|
||||
expiryDate,
|
||||
daysRemaining
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all certificates for expiration
|
||||
* @param thresholdDays Days threshold to consider expiring
|
||||
* @returns List of expiring certificates
|
||||
*/
|
||||
public async getExpiringCertificates(
|
||||
thresholdDays: number = 30
|
||||
): Promise<Array<{ domain: string; expiryDate: Date; daysRemaining: number }>> {
|
||||
const domains = await this.listCertificates();
|
||||
const expiringCerts = [];
|
||||
|
||||
for (const domain of domains) {
|
||||
const expiring = await this.isExpiringSoon(domain, thresholdDays);
|
||||
if (expiring) {
|
||||
expiringCerts.push(expiring);
|
||||
}
|
||||
}
|
||||
|
||||
return expiringCerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a directory recursively
|
||||
* @param directoryPath Directory to delete
|
||||
*/
|
||||
private async deleteDirectory(directoryPath: string): Promise<void> {
|
||||
if (fs.existsSync(directoryPath)) {
|
||||
const entries = await fs.promises.readdir(directoryPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directoryPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.deleteDirectory(fullPath);
|
||||
} else {
|
||||
await fs.promises.unlink(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.rmdir(directoryPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a domain name for use as a directory name
|
||||
* @param domain Domain name
|
||||
* @returns Sanitized domain name
|
||||
*/
|
||||
private sanitizeDomain(domain: string): string {
|
||||
// Replace wildcard and any invalid filesystem characters
|
||||
return domain.replace(/\*/g, '_wildcard_').replace(/[/\\:*?"<>|]/g, '_');
|
||||
}
|
||||
}
|
3
ts/certificate/storage/index.ts
Normal file
3
ts/certificate/storage/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* Certificate storage mechanisms
|
||||
*/
|
50
ts/certificate/utils/certificate-helpers.ts
Normal file
50
ts/certificate/utils/certificate-helpers.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { Certificates } from '../models/certificate-types.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Loads the default SSL certificates from the assets directory
|
||||
* @returns The certificate key pair
|
||||
*/
|
||||
export function loadDefaultCertificates(): Certificates {
|
||||
try {
|
||||
// Need to adjust path from /ts/certificate/utils to /assets/certs
|
||||
const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs');
|
||||
const privateKey = fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8');
|
||||
const publicKey = fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8');
|
||||
|
||||
if (!privateKey || !publicKey) {
|
||||
throw new Error('Failed to load default certificates');
|
||||
}
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading default certificates:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a certificate file exists at the specified path
|
||||
* @param certPath Path to check for certificate
|
||||
* @returns True if the certificate exists, false otherwise
|
||||
*/
|
||||
export function certificateExists(certPath: string): boolean {
|
||||
return fs.existsSync(certPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the certificate directory exists
|
||||
* @param dirPath Path to the certificate directory
|
||||
*/
|
||||
export function ensureCertificateDirectory(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { IAcmeOptions } from './types.js';
|
||||
import { Port80Handler } from '../port80handler/classes.port80handler.js';
|
||||
|
||||
/**
|
||||
* Factory to create a Port80Handler with common setup.
|
||||
* Ensures the certificate store directory exists and instantiates the handler.
|
||||
* @param options Port80Handler configuration options
|
||||
* @returns A new Port80Handler instance
|
||||
*/
|
||||
export function buildPort80Handler(
|
||||
options: IAcmeOptions
|
||||
): Port80Handler {
|
||||
if (options.certificateStore) {
|
||||
const certStorePath = path.resolve(options.certificateStore);
|
||||
if (!fs.existsSync(certStorePath)) {
|
||||
fs.mkdirSync(certStorePath, { recursive: true });
|
||||
console.log(`Created certificate store directory: ${certStorePath}`);
|
||||
}
|
||||
}
|
||||
return new Port80Handler(options);
|
||||
}
|
3
ts/core/events/index.ts
Normal file
3
ts/core/events/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* Common event definitions
|
||||
*/
|
8
ts/core/index.ts
Normal file
8
ts/core/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core functionality module
|
||||
*/
|
||||
|
||||
// Export submodules
|
||||
export * from './models/index.js';
|
||||
export * from './utils/index.js';
|
||||
export * from './events/index.js';
|
91
ts/core/models/common-types.ts
Normal file
91
ts/core/models/common-types.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
|
||||
/**
|
||||
* Shared types for certificate management and domain options
|
||||
*/
|
||||
|
||||
/**
|
||||
* Domain forwarding configuration
|
||||
*/
|
||||
export interface IForwardConfig {
|
||||
ip: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain configuration options
|
||||
*/
|
||||
export interface IDomainOptions {
|
||||
domainName: string;
|
||||
sslRedirect: boolean; // if true redirects the request to port 443
|
||||
acmeMaintenance: boolean; // tries to always have a valid cert for this domain
|
||||
forward?: IForwardConfig; // forwards all http requests to that target
|
||||
acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate data that can be emitted via events or set from outside
|
||||
*/
|
||||
export interface ICertificateData {
|
||||
domain: string;
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
expiryDate: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the Port80Handler
|
||||
*/
|
||||
export enum Port80HandlerEvents {
|
||||
CERTIFICATE_ISSUED = 'certificate-issued',
|
||||
CERTIFICATE_RENEWED = 'certificate-renewed',
|
||||
CERTIFICATE_FAILED = 'certificate-failed',
|
||||
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
||||
MANAGER_STARTED = 'manager-started',
|
||||
MANAGER_STOPPED = 'manager-stopped',
|
||||
REQUEST_FORWARDED = 'request-forwarded',
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate failure payload type
|
||||
*/
|
||||
export interface ICertificateFailure {
|
||||
domain: string;
|
||||
error: string;
|
||||
isRenewal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate expiry payload type
|
||||
*/
|
||||
export interface ICertificateExpiring {
|
||||
domain: string;
|
||||
expiryDate: Date;
|
||||
daysRemaining: number;
|
||||
}
|
||||
/**
|
||||
* Forwarding configuration for specific domains in ACME setup
|
||||
*/
|
||||
export interface IDomainForwardConfig {
|
||||
domain: string;
|
||||
forwardConfig?: IForwardConfig;
|
||||
acmeForwardConfig?: IForwardConfig;
|
||||
sslRedirect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified ACME configuration options used across proxies and handlers
|
||||
*/
|
||||
export interface IAcmeOptions {
|
||||
accountEmail?: string; // Email for Let's Encrypt account
|
||||
enabled?: boolean; // Whether ACME is enabled
|
||||
port?: number; // Port to listen on for ACME challenges (default: 80)
|
||||
useProduction?: boolean; // Use production environment (default: staging)
|
||||
httpsRedirectPort?: number; // Port to redirect HTTP requests to HTTPS (default: 443)
|
||||
renewThresholdDays?: number; // Days before expiry to renew certificates
|
||||
renewCheckIntervalHours?: number; // How often to check for renewals (in hours)
|
||||
autoRenew?: boolean; // Whether to automatically renew certificates
|
||||
certificateStore?: string; // Directory to store certificates
|
||||
skipConfiguredCerts?: boolean; // Skip domains with existing certificates
|
||||
domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs
|
||||
}
|
5
ts/core/models/index.ts
Normal file
5
ts/core/models/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Core data models and interfaces
|
||||
*/
|
||||
|
||||
export * from './common-types.js';
|
34
ts/core/utils/event-utils.ts
Normal file
34
ts/core/utils/event-utils.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { Port80Handler } from '../../port80handler/classes.port80handler.js';
|
||||
import { Port80HandlerEvents } from '../models/common-types.js';
|
||||
import type { ICertificateData, ICertificateFailure, ICertificateExpiring } from '../models/common-types.js';
|
||||
|
||||
/**
|
||||
* Subscribers callback definitions for Port80Handler events
|
||||
*/
|
||||
export interface Port80HandlerSubscribers {
|
||||
onCertificateIssued?: (data: ICertificateData) => void;
|
||||
onCertificateRenewed?: (data: ICertificateData) => void;
|
||||
onCertificateFailed?: (data: ICertificateFailure) => void;
|
||||
onCertificateExpiring?: (data: ICertificateExpiring) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to Port80Handler events based on provided callbacks
|
||||
*/
|
||||
export function subscribeToPort80Handler(
|
||||
handler: Port80Handler,
|
||||
subscribers: Port80HandlerSubscribers
|
||||
): void {
|
||||
if (subscribers.onCertificateIssued) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, subscribers.onCertificateIssued);
|
||||
}
|
||||
if (subscribers.onCertificateRenewed) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, subscribers.onCertificateRenewed);
|
||||
}
|
||||
if (subscribers.onCertificateFailed) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, subscribers.onCertificateFailed);
|
||||
}
|
||||
if (subscribers.onCertificateExpiring) {
|
||||
handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, subscribers.onCertificateExpiring);
|
||||
}
|
||||
}
|
7
ts/core/utils/index.ts
Normal file
7
ts/core/utils/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Core utility functions
|
||||
*/
|
||||
|
||||
export * from './event-utils.js';
|
||||
export * from './validation-utils.js';
|
||||
export * from './ip-utils.js';
|
175
ts/core/utils/ip-utils.ts
Normal file
175
ts/core/utils/ip-utils.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
|
||||
/**
|
||||
* Utility class for IP address operations
|
||||
*/
|
||||
export class IpUtils {
|
||||
/**
|
||||
* Check if the IP matches any of the glob patterns
|
||||
*
|
||||
* This method checks IP addresses against glob patterns and handles IPv4/IPv6 normalization.
|
||||
* It's used to implement IP filtering based on security configurations.
|
||||
*
|
||||
* @param ip - The IP address to check
|
||||
* @param patterns - Array of glob patterns
|
||||
* @returns true if IP matches any pattern, false otherwise
|
||||
*/
|
||||
public static isGlobIPMatch(ip: string, patterns: string[]): boolean {
|
||||
if (!ip || !patterns || patterns.length === 0) return false;
|
||||
|
||||
// Normalize the IP being checked
|
||||
const normalizedIPVariants = this.normalizeIP(ip);
|
||||
if (normalizedIPVariants.length === 0) return false;
|
||||
|
||||
// Normalize the pattern IPs for consistent comparison
|
||||
const expandedPatterns = patterns.flatMap(pattern => this.normalizeIP(pattern));
|
||||
|
||||
// Check for any match between normalized IP variants and patterns
|
||||
return normalizedIPVariants.some((ipVariant) =>
|
||||
expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize IP addresses for consistent comparison
|
||||
*
|
||||
* @param ip The IP address to normalize
|
||||
* @returns Array of normalized IP forms
|
||||
*/
|
||||
public static normalizeIP(ip: string): string[] {
|
||||
if (!ip) return [];
|
||||
|
||||
// Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1)
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
const ipv4 = ip.slice(7);
|
||||
return [ip, ipv4];
|
||||
}
|
||||
|
||||
// Handle IPv4 addresses by also checking IPv4-mapped form
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
||||
return [ip, `::ffff:${ip}`];
|
||||
}
|
||||
|
||||
return [ip];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is authorized using security rules
|
||||
*
|
||||
* @param ip - The IP address to check
|
||||
* @param allowedIPs - Array of allowed IP patterns
|
||||
* @param blockedIPs - Array of blocked IP patterns
|
||||
* @returns true if IP is authorized, false if blocked
|
||||
*/
|
||||
public static isIPAuthorized(ip: string, allowedIPs: string[] = [], blockedIPs: string[] = []): boolean {
|
||||
// Skip IP validation if no rules are defined
|
||||
if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// First check if IP is blocked - blocked IPs take precedence
|
||||
if (blockedIPs.length > 0 && this.isGlobIPMatch(ip, blockedIPs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then check if IP is allowed (if no allowed IPs are specified, all non-blocked IPs are allowed)
|
||||
return allowedIPs.length === 0 || this.isGlobIPMatch(ip, allowedIPs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address is a private network address
|
||||
*
|
||||
* @param ip The IP address to check
|
||||
* @returns true if the IP is a private network address, false otherwise
|
||||
*/
|
||||
public static isPrivateIP(ip: string): boolean {
|
||||
if (!ip) return false;
|
||||
|
||||
// Handle IPv4-mapped IPv6 addresses
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
ip = ip.slice(7);
|
||||
}
|
||||
|
||||
// Check IPv4 private ranges
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
||||
const parts = ip.split('.').map(Number);
|
||||
|
||||
// Check common private ranges
|
||||
// 10.0.0.0/8
|
||||
if (parts[0] === 10) return true;
|
||||
|
||||
// 172.16.0.0/12
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
||||
|
||||
// 192.168.0.0/16
|
||||
if (parts[0] === 192 && parts[1] === 168) return true;
|
||||
|
||||
// 127.0.0.0/8 (localhost)
|
||||
if (parts[0] === 127) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv6 local addresses
|
||||
return ip === '::1' || ip.startsWith('fc00:') || ip.startsWith('fd00:') || ip.startsWith('fe80:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address is a public network address
|
||||
*
|
||||
* @param ip The IP address to check
|
||||
* @returns true if the IP is a public network address, false otherwise
|
||||
*/
|
||||
public static isPublicIP(ip: string): boolean {
|
||||
return !this.isPrivateIP(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a subnet CIDR to an IP range for filtering
|
||||
*
|
||||
* @param cidr The CIDR notation (e.g., "192.168.1.0/24")
|
||||
* @returns Array of glob patterns that match the CIDR range
|
||||
*/
|
||||
public static cidrToGlobPatterns(cidr: string): string[] {
|
||||
if (!cidr || !cidr.includes('/')) return [];
|
||||
|
||||
const [ipPart, prefixPart] = cidr.split('/');
|
||||
const prefix = parseInt(prefixPart, 10);
|
||||
|
||||
if (isNaN(prefix) || prefix < 0 || prefix > 32) return [];
|
||||
|
||||
// For IPv4 only for now
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ipPart)) return [];
|
||||
|
||||
const ipParts = ipPart.split('.').map(Number);
|
||||
const fullMask = Math.pow(2, 32 - prefix) - 1;
|
||||
|
||||
// Convert IP to a numeric value
|
||||
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
||||
|
||||
// Calculate network address (IP & ~fullMask)
|
||||
const networkNum = ipNum & ~fullMask;
|
||||
|
||||
// For large ranges, return wildcard patterns
|
||||
if (prefix <= 8) {
|
||||
return [`${(networkNum >>> 24) & 255}.*.*.*`];
|
||||
} else if (prefix <= 16) {
|
||||
return [`${(networkNum >>> 24) & 255}.${(networkNum >>> 16) & 255}.*.*`];
|
||||
} else if (prefix <= 24) {
|
||||
return [`${(networkNum >>> 24) & 255}.${(networkNum >>> 16) & 255}.${(networkNum >>> 8) & 255}.*`];
|
||||
}
|
||||
|
||||
// For small ranges, create individual IP patterns
|
||||
const patterns = [];
|
||||
const maxAddresses = Math.min(256, Math.pow(2, 32 - prefix));
|
||||
|
||||
for (let i = 0; i < maxAddresses; i++) {
|
||||
const currentIpNum = networkNum + i;
|
||||
patterns.push(
|
||||
`${(currentIpNum >>> 24) & 255}.${(currentIpNum >>> 16) & 255}.${(currentIpNum >>> 8) & 255}.${currentIpNum & 255}`
|
||||
);
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
}
|
177
ts/core/utils/validation-utils.ts
Normal file
177
ts/core/utils/validation-utils.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IDomainOptions, IAcmeOptions } from '../models/common-types.js';
|
||||
|
||||
/**
|
||||
* Collection of validation utilities for configuration and domain options
|
||||
*/
|
||||
export class ValidationUtils {
|
||||
/**
|
||||
* Validates domain configuration options
|
||||
*
|
||||
* @param domainOptions The domain options to validate
|
||||
* @returns An object with validation result and error message if invalid
|
||||
*/
|
||||
public static validateDomainOptions(domainOptions: IDomainOptions): { isValid: boolean; error?: string } {
|
||||
if (!domainOptions) {
|
||||
return { isValid: false, error: 'Domain options cannot be null or undefined' };
|
||||
}
|
||||
|
||||
if (!domainOptions.domainName) {
|
||||
return { isValid: false, error: 'Domain name is required' };
|
||||
}
|
||||
|
||||
// Check domain pattern
|
||||
if (!this.isValidDomainName(domainOptions.domainName)) {
|
||||
return { isValid: false, error: `Invalid domain name: ${domainOptions.domainName}` };
|
||||
}
|
||||
|
||||
// Validate forward config if provided
|
||||
if (domainOptions.forward) {
|
||||
if (!domainOptions.forward.ip) {
|
||||
return { isValid: false, error: 'Forward IP is required when forward is specified' };
|
||||
}
|
||||
|
||||
if (!domainOptions.forward.port) {
|
||||
return { isValid: false, error: 'Forward port is required when forward is specified' };
|
||||
}
|
||||
|
||||
if (!this.isValidPort(domainOptions.forward.port)) {
|
||||
return { isValid: false, error: `Invalid forward port: ${domainOptions.forward.port}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate ACME forward config if provided
|
||||
if (domainOptions.acmeForward) {
|
||||
if (!domainOptions.acmeForward.ip) {
|
||||
return { isValid: false, error: 'ACME forward IP is required when acmeForward is specified' };
|
||||
}
|
||||
|
||||
if (!domainOptions.acmeForward.port) {
|
||||
return { isValid: false, error: 'ACME forward port is required when acmeForward is specified' };
|
||||
}
|
||||
|
||||
if (!this.isValidPort(domainOptions.acmeForward.port)) {
|
||||
return { isValid: false, error: `Invalid ACME forward port: ${domainOptions.acmeForward.port}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates ACME configuration options
|
||||
*
|
||||
* @param acmeOptions The ACME options to validate
|
||||
* @returns An object with validation result and error message if invalid
|
||||
*/
|
||||
public static validateAcmeOptions(acmeOptions: IAcmeOptions): { isValid: boolean; error?: string } {
|
||||
if (!acmeOptions) {
|
||||
return { isValid: false, error: 'ACME options cannot be null or undefined' };
|
||||
}
|
||||
|
||||
if (acmeOptions.enabled) {
|
||||
if (!acmeOptions.accountEmail) {
|
||||
return { isValid: false, error: 'Account email is required when ACME is enabled' };
|
||||
}
|
||||
|
||||
if (!this.isValidEmail(acmeOptions.accountEmail)) {
|
||||
return { isValid: false, error: `Invalid email: ${acmeOptions.accountEmail}` };
|
||||
}
|
||||
|
||||
if (acmeOptions.port && !this.isValidPort(acmeOptions.port)) {
|
||||
return { isValid: false, error: `Invalid ACME port: ${acmeOptions.port}` };
|
||||
}
|
||||
|
||||
if (acmeOptions.httpsRedirectPort && !this.isValidPort(acmeOptions.httpsRedirectPort)) {
|
||||
return { isValid: false, error: `Invalid HTTPS redirect port: ${acmeOptions.httpsRedirectPort}` };
|
||||
}
|
||||
|
||||
if (acmeOptions.renewThresholdDays && acmeOptions.renewThresholdDays < 1) {
|
||||
return { isValid: false, error: 'Renew threshold days must be greater than 0' };
|
||||
}
|
||||
|
||||
if (acmeOptions.renewCheckIntervalHours && acmeOptions.renewCheckIntervalHours < 1) {
|
||||
return { isValid: false, error: 'Renew check interval hours must be greater than 0' };
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a port number
|
||||
*
|
||||
* @param port The port to validate
|
||||
* @returns true if the port is valid, false otherwise
|
||||
*/
|
||||
public static isValidPort(port: number): boolean {
|
||||
return typeof port === 'number' && port > 0 && port <= 65535 && Number.isInteger(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a domain name
|
||||
*
|
||||
* @param domain The domain name to validate
|
||||
* @returns true if the domain name is valid, false otherwise
|
||||
*/
|
||||
public static isValidDomainName(domain: string): boolean {
|
||||
if (!domain || typeof domain !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wildcard domain check (*.example.com)
|
||||
if (domain.startsWith('*.')) {
|
||||
domain = domain.substring(2);
|
||||
}
|
||||
|
||||
// Simple domain validation pattern
|
||||
const domainPattern = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
return domainPattern.test(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address
|
||||
*
|
||||
* @param email The email to validate
|
||||
* @returns true if the email is valid, false otherwise
|
||||
*/
|
||||
public static isValidEmail(email: string): boolean {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic email validation pattern
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailPattern.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a certificate format (PEM)
|
||||
*
|
||||
* @param cert The certificate content to validate
|
||||
* @returns true if the certificate appears to be in PEM format, false otherwise
|
||||
*/
|
||||
public static isValidCertificate(cert: string): boolean {
|
||||
if (!cert || typeof cert !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return cert.includes('-----BEGIN CERTIFICATE-----') &&
|
||||
cert.includes('-----END CERTIFICATE-----');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a private key format (PEM)
|
||||
*
|
||||
* @param key The private key content to validate
|
||||
* @returns true if the key appears to be in PEM format, false otherwise
|
||||
*/
|
||||
public static isValidPrivateKey(key: string): boolean {
|
||||
if (!key || typeof key !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return key.includes('-----BEGIN PRIVATE KEY-----') &&
|
||||
key.includes('-----END PRIVATE KEY-----');
|
||||
}
|
||||
}
|
@ -1,128 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { createServer } from 'http';
|
||||
import { Socket } from 'net';
|
||||
import {
|
||||
DomainManager,
|
||||
DomainManagerEvents,
|
||||
createDomainConfig,
|
||||
helpers
|
||||
} from '../smartproxy/forwarding/index.js';
|
||||
|
||||
/**
|
||||
* Example showing how to use the unified forwarding system
|
||||
*/
|
||||
async function main() {
|
||||
console.log('Initializing forwarding example...');
|
||||
|
||||
// Create the domain manager
|
||||
const domainManager = new DomainManager();
|
||||
|
||||
// Set up event listeners
|
||||
domainManager.on(DomainManagerEvents.DOMAIN_ADDED, (data) => {
|
||||
console.log(`Domain added: ${data.domains.join(', ')} (${data.forwardingType})`);
|
||||
});
|
||||
|
||||
domainManager.on(DomainManagerEvents.DOMAIN_MATCHED, (data) => {
|
||||
console.log(`Domain matched: ${data.domain} (${data.handlerType})`);
|
||||
});
|
||||
|
||||
domainManager.on(DomainManagerEvents.DOMAIN_MATCH_FAILED, (data) => {
|
||||
console.log(`Domain match failed: ${data.domain}`);
|
||||
});
|
||||
|
||||
domainManager.on(DomainManagerEvents.ERROR, (data) => {
|
||||
console.error(`Error:`, data);
|
||||
});
|
||||
|
||||
// Add example domains with different forwarding types
|
||||
|
||||
// Example 1: HTTP-only forwarding
|
||||
await domainManager.addDomainConfig(
|
||||
createDomainConfig('example.com', helpers.httpOnly({
|
||||
target: { host: 'localhost', port: 3000 }
|
||||
}))
|
||||
);
|
||||
|
||||
// Example 2: HTTPS termination with HTTP backend
|
||||
await domainManager.addDomainConfig(
|
||||
createDomainConfig('secure.example.com', helpers.tlsTerminateToHttp({
|
||||
target: { host: 'localhost', port: 3000 }
|
||||
}))
|
||||
);
|
||||
|
||||
// Example 3: HTTPS termination with HTTPS backend
|
||||
await domainManager.addDomainConfig(
|
||||
createDomainConfig('api.example.com', helpers.tlsTerminateToHttps({
|
||||
target: { host: 'localhost', port: 8443 }
|
||||
}))
|
||||
);
|
||||
|
||||
// Example 4: SNI passthrough
|
||||
await domainManager.addDomainConfig(
|
||||
createDomainConfig('passthrough.example.com', helpers.sniPassthrough({
|
||||
target: { host: '10.0.0.5', port: 443 }
|
||||
}))
|
||||
);
|
||||
|
||||
// Example 5: Custom configuration for a more complex setup
|
||||
await domainManager.addDomainConfig(
|
||||
createDomainConfig(['*.example.com', '*.example.org'], {
|
||||
type: 'https-terminate-to-http',
|
||||
target: {
|
||||
host: ['10.0.0.10', '10.0.0.11'], // Round-robin load balancing
|
||||
port: 8080
|
||||
},
|
||||
http: {
|
||||
enabled: true,
|
||||
redirectToHttps: false // Allow both HTTP and HTTPS
|
||||
},
|
||||
acme: {
|
||||
enabled: true,
|
||||
maintenance: true,
|
||||
production: false, // Use staging for testing
|
||||
forwardChallenges: {
|
||||
host: '192.168.1.100',
|
||||
port: 8080
|
||||
}
|
||||
},
|
||||
security: {
|
||||
allowedIps: ['10.0.0.*', '192.168.1.*'],
|
||||
maxConnections: 100
|
||||
},
|
||||
advanced: {
|
||||
headers: {
|
||||
'X-Forwarded-For': '{clientIp}',
|
||||
'X-Forwarded-Host': '{sni}'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Create a simple HTTP server to demonstrate HTTP handler
|
||||
const httpServer = createServer((req, res) => {
|
||||
// Extract the domain from the Host header
|
||||
const domain = req.headers.host?.split(':')[0] || 'unknown';
|
||||
|
||||
// Forward the request to the appropriate handler
|
||||
if (!domainManager.handleHttpRequest(domain, req, res)) {
|
||||
// No handler found, send a default response
|
||||
res.statusCode = 404;
|
||||
res.end(`No handler found for domain: ${domain}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen on HTTP port
|
||||
httpServer.listen(80, () => {
|
||||
console.log('HTTP server listening on port 80');
|
||||
});
|
||||
|
||||
// For HTTPS and SNI, we would need to set up a TLS server
|
||||
// This is a simplified example that just shows how the domain manager works
|
||||
|
||||
console.log('Forwarding example initialized successfully');
|
||||
}
|
||||
|
||||
// Run the example
|
||||
main().catch(error => {
|
||||
console.error('Error running example:', error);
|
||||
});
|
31
ts/forwarding/config/domain-config.ts
Normal file
31
ts/forwarding/config/domain-config.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { ForwardConfig } from './forwarding-types.js';
|
||||
|
||||
/**
|
||||
* Domain configuration with unified forwarding configuration
|
||||
*/
|
||||
export interface DomainConfig {
|
||||
// Core properties - domain patterns
|
||||
domains: string[];
|
||||
|
||||
// Unified forwarding configuration
|
||||
forwarding: ForwardConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a domain configuration
|
||||
*/
|
||||
export function createDomainConfig(
|
||||
domains: string | string[],
|
||||
forwarding: ForwardConfig
|
||||
): DomainConfig {
|
||||
// Normalize domains to an array
|
||||
const domainArray = Array.isArray(domains) ? domains : [domains];
|
||||
|
||||
return {
|
||||
domains: domainArray,
|
||||
forwarding
|
||||
};
|
||||
}
|
||||
|
||||
// Backwards compatibility
|
||||
export interface IDomainConfig extends DomainConfig {}
|
283
ts/forwarding/config/domain-manager.ts
Normal file
283
ts/forwarding/config/domain-manager.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { DomainConfig } from './domain-config.js';
|
||||
import { ForwardingHandler } from '../handlers/base-handler.js';
|
||||
import { ForwardingHandlerEvents } from './forwarding-types.js';
|
||||
import { ForwardingHandlerFactory } from '../factory/forwarding-factory.js';
|
||||
|
||||
/**
|
||||
* Events emitted by the DomainManager
|
||||
*/
|
||||
export enum DomainManagerEvents {
|
||||
DOMAIN_ADDED = 'domain-added',
|
||||
DOMAIN_REMOVED = 'domain-removed',
|
||||
DOMAIN_MATCHED = 'domain-matched',
|
||||
DOMAIN_MATCH_FAILED = 'domain-match-failed',
|
||||
CERTIFICATE_NEEDED = 'certificate-needed',
|
||||
CERTIFICATE_LOADED = 'certificate-loaded',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages domains and their forwarding handlers
|
||||
*/
|
||||
export class DomainManager extends plugins.EventEmitter {
|
||||
private domainConfigs: DomainConfig[] = [];
|
||||
private domainHandlers: Map<string, ForwardingHandler> = new Map();
|
||||
|
||||
/**
|
||||
* Create a new DomainManager
|
||||
* @param initialDomains Optional initial domain configurations
|
||||
*/
|
||||
constructor(initialDomains?: DomainConfig[]) {
|
||||
super();
|
||||
|
||||
if (initialDomains) {
|
||||
this.setDomainConfigs(initialDomains);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or replace all domain configurations
|
||||
* @param configs Array of domain configurations
|
||||
*/
|
||||
public async setDomainConfigs(configs: DomainConfig[]): Promise<void> {
|
||||
// Clear existing handlers
|
||||
this.domainHandlers.clear();
|
||||
|
||||
// Store new configurations
|
||||
this.domainConfigs = [...configs];
|
||||
|
||||
// Initialize handlers for each domain
|
||||
for (const config of this.domainConfigs) {
|
||||
await this.createHandlersForDomain(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new domain configuration
|
||||
* @param config The domain configuration to add
|
||||
*/
|
||||
public async addDomainConfig(config: DomainConfig): Promise<void> {
|
||||
// Check if any of these domains already exist
|
||||
for (const domain of config.domains) {
|
||||
if (this.domainHandlers.has(domain)) {
|
||||
// Remove existing handler for this domain
|
||||
this.domainHandlers.delete(domain);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new configuration
|
||||
this.domainConfigs.push(config);
|
||||
|
||||
// Create handlers for the new domain
|
||||
await this.createHandlersForDomain(config);
|
||||
|
||||
this.emit(DomainManagerEvents.DOMAIN_ADDED, {
|
||||
domains: config.domains,
|
||||
forwardingType: config.forwarding.type
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a domain configuration
|
||||
* @param domain The domain to remove
|
||||
* @returns True if the domain was found and removed
|
||||
*/
|
||||
public removeDomainConfig(domain: string): boolean {
|
||||
// Find the config that includes this domain
|
||||
const index = this.domainConfigs.findIndex(config =>
|
||||
config.domains.includes(domain)
|
||||
);
|
||||
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the config
|
||||
const config = this.domainConfigs[index];
|
||||
|
||||
// Remove all handlers for this config
|
||||
for (const domainName of config.domains) {
|
||||
this.domainHandlers.delete(domainName);
|
||||
}
|
||||
|
||||
// Remove the config
|
||||
this.domainConfigs.splice(index, 1);
|
||||
|
||||
this.emit(DomainManagerEvents.DOMAIN_REMOVED, {
|
||||
domains: config.domains
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the handler for a domain
|
||||
* @param domain The domain to find a handler for
|
||||
* @returns The handler or undefined if no match
|
||||
*/
|
||||
public findHandlerForDomain(domain: string): ForwardingHandler | undefined {
|
||||
// Try exact match
|
||||
if (this.domainHandlers.has(domain)) {
|
||||
return this.domainHandlers.get(domain);
|
||||
}
|
||||
|
||||
// Try wildcard matches
|
||||
const wildcardHandler = this.findWildcardHandler(domain);
|
||||
if (wildcardHandler) {
|
||||
return wildcardHandler;
|
||||
}
|
||||
|
||||
// No match found
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a connection for a domain
|
||||
* @param domain The domain
|
||||
* @param socket The client socket
|
||||
* @returns True if the connection was handled
|
||||
*/
|
||||
public handleConnection(domain: string, socket: plugins.net.Socket): boolean {
|
||||
const handler = this.findHandlerForDomain(domain);
|
||||
|
||||
if (!handler) {
|
||||
this.emit(DomainManagerEvents.DOMAIN_MATCH_FAILED, {
|
||||
domain,
|
||||
remoteAddress: socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
this.emit(DomainManagerEvents.DOMAIN_MATCHED, {
|
||||
domain,
|
||||
handlerType: handler.constructor.name,
|
||||
remoteAddress: socket.remoteAddress
|
||||
});
|
||||
|
||||
// Handle the connection
|
||||
handler.handleConnection(socket);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an HTTP request for a domain
|
||||
* @param domain The domain
|
||||
* @param req The HTTP request
|
||||
* @param res The HTTP response
|
||||
* @returns True if the request was handled
|
||||
*/
|
||||
public handleHttpRequest(domain: string, req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): boolean {
|
||||
const handler = this.findHandlerForDomain(domain);
|
||||
|
||||
if (!handler) {
|
||||
this.emit(DomainManagerEvents.DOMAIN_MATCH_FAILED, {
|
||||
domain,
|
||||
remoteAddress: req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
this.emit(DomainManagerEvents.DOMAIN_MATCHED, {
|
||||
domain,
|
||||
handlerType: handler.constructor.name,
|
||||
remoteAddress: req.socket.remoteAddress
|
||||
});
|
||||
|
||||
// Handle the request
|
||||
handler.handleHttpRequest(req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handlers for a domain configuration
|
||||
* @param config The domain configuration
|
||||
*/
|
||||
private async createHandlersForDomain(config: DomainConfig): Promise<void> {
|
||||
try {
|
||||
// Create a handler for this forwarding configuration
|
||||
const handler = ForwardingHandlerFactory.createHandler(config.forwarding);
|
||||
|
||||
// Initialize the handler
|
||||
await handler.initialize();
|
||||
|
||||
// Set up event forwarding
|
||||
this.setupHandlerEvents(handler, config);
|
||||
|
||||
// Store the handler for each domain in the config
|
||||
for (const domain of config.domains) {
|
||||
this.domainHandlers.set(domain, handler);
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit(DomainManagerEvents.ERROR, {
|
||||
domains: config.domains,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event forwarding from a handler
|
||||
* @param handler The handler
|
||||
* @param config The domain configuration for this handler
|
||||
*/
|
||||
private setupHandlerEvents(handler: ForwardingHandler, config: DomainConfig): void {
|
||||
// Forward relevant events
|
||||
handler.on(ForwardingHandlerEvents.CERTIFICATE_NEEDED, (data) => {
|
||||
this.emit(DomainManagerEvents.CERTIFICATE_NEEDED, {
|
||||
...data,
|
||||
domains: config.domains
|
||||
});
|
||||
});
|
||||
|
||||
handler.on(ForwardingHandlerEvents.CERTIFICATE_LOADED, (data) => {
|
||||
this.emit(DomainManagerEvents.CERTIFICATE_LOADED, {
|
||||
...data,
|
||||
domains: config.domains
|
||||
});
|
||||
});
|
||||
|
||||
handler.on(ForwardingHandlerEvents.ERROR, (data) => {
|
||||
this.emit(DomainManagerEvents.ERROR, {
|
||||
...data,
|
||||
domains: config.domains
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a handler for a domain using wildcard matching
|
||||
* @param domain The domain to find a handler for
|
||||
* @returns The handler or undefined if no match
|
||||
*/
|
||||
private findWildcardHandler(domain: string): ForwardingHandler | undefined {
|
||||
// Exact match already checked in findHandlerForDomain
|
||||
|
||||
// Try subdomain wildcard (*.example.com)
|
||||
if (domain.includes('.')) {
|
||||
const parts = domain.split('.');
|
||||
if (parts.length > 2) {
|
||||
const wildcardDomain = `*.${parts.slice(1).join('.')}`;
|
||||
if (this.domainHandlers.has(wildcardDomain)) {
|
||||
return this.domainHandlers.get(wildcardDomain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try full wildcard
|
||||
if (this.domainHandlers.has('*')) {
|
||||
return this.domainHandlers.get('*');
|
||||
}
|
||||
|
||||
// No match found
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all domain configurations
|
||||
* @returns Array of domain configurations
|
||||
*/
|
||||
public getDomainConfigs(): DomainConfig[] {
|
||||
return [...this.domainConfigs];
|
||||
}
|
||||
}
|
171
ts/forwarding/config/forwarding-types.ts
Normal file
171
ts/forwarding/config/forwarding-types.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import type * as plugins from '../../plugins.js';
|
||||
|
||||
/**
|
||||
* The primary forwarding types supported by SmartProxy
|
||||
*/
|
||||
export type ForwardingType =
|
||||
| '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
|
||||
|
||||
/**
|
||||
* Target configuration for forwarding
|
||||
*/
|
||||
export interface TargetConfig {
|
||||
host: string | string[]; // Support single host or round-robin
|
||||
port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-specific options for forwarding
|
||||
*/
|
||||
export interface HttpOptions {
|
||||
enabled?: boolean; // Whether HTTP is enabled
|
||||
redirectToHttps?: boolean; // Redirect HTTP to HTTPS
|
||||
headers?: Record<string, string>; // Custom headers for HTTP responses
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTPS-specific options for forwarding
|
||||
*/
|
||||
export interface HttpsOptions {
|
||||
customCert?: { // Use custom cert instead of auto-provisioned
|
||||
key: string;
|
||||
cert: string;
|
||||
};
|
||||
forwardSni?: boolean; // Forward SNI info in passthrough mode
|
||||
}
|
||||
|
||||
/**
|
||||
* ACME certificate handling options
|
||||
*/
|
||||
export interface AcmeForwardingOptions {
|
||||
enabled?: boolean; // Enable ACME certificate provisioning
|
||||
maintenance?: boolean; // Auto-renew certificates
|
||||
production?: boolean; // Use production ACME servers
|
||||
forwardChallenges?: { // Forward ACME challenges
|
||||
host: string;
|
||||
port: number;
|
||||
useTls?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Security options for forwarding
|
||||
*/
|
||||
export interface SecurityOptions {
|
||||
allowedIps?: string[]; // IPs allowed to connect
|
||||
blockedIps?: string[]; // IPs blocked from connecting
|
||||
maxConnections?: number; // Max simultaneous connections
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced options for forwarding
|
||||
*/
|
||||
export interface AdvancedOptions {
|
||||
portRanges?: Array<{ from: number; to: number }>; // Allowed port ranges
|
||||
networkProxyPort?: number; // Custom NetworkProxy port if using terminate mode
|
||||
keepAlive?: boolean; // Enable TCP keepalive
|
||||
timeout?: number; // Connection timeout in ms
|
||||
headers?: Record<string, string>; // Custom headers with support for variables like {sni}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified forwarding configuration interface
|
||||
*/
|
||||
export interface ForwardConfig {
|
||||
// Define the primary forwarding type - use-case driven approach
|
||||
type: ForwardingType;
|
||||
|
||||
// Target configuration
|
||||
target: TargetConfig;
|
||||
|
||||
// Protocol options
|
||||
http?: HttpOptions;
|
||||
https?: HttpsOptions;
|
||||
acme?: AcmeForwardingOptions;
|
||||
|
||||
// Security and advanced options
|
||||
security?: SecurityOptions;
|
||||
advanced?: AdvancedOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function types for common forwarding patterns
|
||||
*/
|
||||
export const httpOnly = (
|
||||
partialConfig: Partial<ForwardConfig> & Pick<ForwardConfig, 'target'>
|
||||
): ForwardConfig => ({
|
||||
type: 'http-only',
|
||||
target: partialConfig.target,
|
||||
http: { enabled: true, ...(partialConfig.http || {}) },
|
||||
...(partialConfig.security ? { security: partialConfig.security } : {}),
|
||||
...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {})
|
||||
});
|
||||
|
||||
export const tlsTerminateToHttp = (
|
||||
partialConfig: Partial<ForwardConfig> & Pick<ForwardConfig, 'target'>
|
||||
): ForwardConfig => ({
|
||||
type: 'https-terminate-to-http',
|
||||
target: partialConfig.target,
|
||||
https: { ...(partialConfig.https || {}) },
|
||||
acme: { enabled: true, maintenance: true, ...(partialConfig.acme || {}) },
|
||||
http: { enabled: true, redirectToHttps: true, ...(partialConfig.http || {}) },
|
||||
...(partialConfig.security ? { security: partialConfig.security } : {}),
|
||||
...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {})
|
||||
});
|
||||
|
||||
export const tlsTerminateToHttps = (
|
||||
partialConfig: Partial<ForwardConfig> & Pick<ForwardConfig, 'target'>
|
||||
): ForwardConfig => ({
|
||||
type: 'https-terminate-to-https',
|
||||
target: partialConfig.target,
|
||||
https: { ...(partialConfig.https || {}) },
|
||||
acme: { enabled: true, maintenance: true, ...(partialConfig.acme || {}) },
|
||||
http: { enabled: true, redirectToHttps: true, ...(partialConfig.http || {}) },
|
||||
...(partialConfig.security ? { security: partialConfig.security } : {}),
|
||||
...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {})
|
||||
});
|
||||
|
||||
export const httpsPassthrough = (
|
||||
partialConfig: Partial<ForwardConfig> & Pick<ForwardConfig, 'target'>
|
||||
): ForwardConfig => ({
|
||||
type: 'https-passthrough',
|
||||
target: partialConfig.target,
|
||||
https: { forwardSni: true, ...(partialConfig.https || {}) },
|
||||
...(partialConfig.security ? { security: partialConfig.security } : {}),
|
||||
...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {})
|
||||
});
|
||||
|
||||
// Backwards compatibility interfaces with 'I' prefix
|
||||
export interface ITargetConfig extends TargetConfig {}
|
||||
export interface IHttpOptions extends HttpOptions {}
|
||||
export interface IHttpsOptions extends HttpsOptions {}
|
||||
export interface IAcmeForwardingOptions extends AcmeForwardingOptions {}
|
||||
export interface ISecurityOptions extends SecurityOptions {}
|
||||
export interface IAdvancedOptions extends AdvancedOptions {}
|
||||
export interface IForwardConfig extends ForwardConfig {}
|
7
ts/forwarding/config/index.ts
Normal file
7
ts/forwarding/config/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Forwarding configuration exports
|
||||
*/
|
||||
|
||||
export * from './forwarding-types.js';
|
||||
export * from './domain-config.js';
|
||||
export * from './domain-manager.js';
|
156
ts/forwarding/factory/forwarding-factory.ts
Normal file
156
ts/forwarding/factory/forwarding-factory.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import type { ForwardConfig } from '../config/forwarding-types.js';
|
||||
import type { 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: ForwardConfig): 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: ForwardConfig): ForwardConfig {
|
||||
// Create a deep copy of the configuration
|
||||
const result: ForwardConfig = 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
|
||||
};
|
||||
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
|
||||
};
|
||||
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
|
||||
};
|
||||
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
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a forwarding configuration
|
||||
* @param config The configuration to validate
|
||||
* @throws Error if the configuration is invalid
|
||||
*/
|
||||
public static validateConfig(config: ForwardConfig): 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');
|
||||
}
|
||||
|
||||
if (!config.target.port || config.target.port <= 0 || config.target.port > 65535) {
|
||||
throw new Error('Target must include a valid port (1-65535)');
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
5
ts/forwarding/factory/index.ts
Normal file
5
ts/forwarding/factory/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Forwarding factory implementations
|
||||
*/
|
||||
|
||||
export { ForwardingHandlerFactory } from './forwarding-factory.js';
|
127
ts/forwarding/handlers/base-handler.ts
Normal file
127
ts/forwarding/handlers/base-handler.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
ForwardConfig,
|
||||
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: ForwardConfig) {
|
||||
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
|
||||
* @returns A resolved target object with host and port
|
||||
*/
|
||||
protected getTargetFromConfig(): { 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: target.port
|
||||
};
|
||||
}
|
||||
|
||||
// Single host
|
||||
return {
|
||||
host: target.host,
|
||||
port: target.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)) {
|
||||
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
|
||||
}
|
||||
}
|
140
ts/forwarding/handlers/http-handler.ts
Normal file
140
ts/forwarding/handlers/http-handler.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { ForwardingHandler } from './base-handler.js';
|
||||
import type { ForwardConfig } from '../config/forwarding-types.js';
|
||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
||||
|
||||
/**
|
||||
* Handler for HTTP-only forwarding
|
||||
*/
|
||||
export class HttpForwardingHandler extends ForwardingHandler {
|
||||
/**
|
||||
* Create a new HTTP forwarding handler
|
||||
* @param config The forwarding configuration
|
||||
*/
|
||||
constructor(config: ForwardConfig) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
socket.on('close', (hadError) => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
hadError
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: error.message
|
||||
});
|
||||
});
|
||||
|
||||
this.emit(ForwardingHandlerEvents.CONNECTED, {
|
||||
remoteAddress
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 target from configuration
|
||||
const target = this.getTargetFromConfig();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
182
ts/forwarding/handlers/https-passthrough-handler.ts
Normal file
182
ts/forwarding/handlers/https-passthrough-handler.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { ForwardingHandler } from './base-handler.js';
|
||||
import type { ForwardConfig } from '../config/forwarding-types.js';
|
||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.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: ForwardConfig) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`
|
||||
});
|
||||
|
||||
// Create a connection to the target server
|
||||
const serverSocket = plugins.net.connect(target.port, target.host);
|
||||
|
||||
// Handle errors on the server socket
|
||||
serverSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Target connection error: ${error.message}`
|
||||
});
|
||||
|
||||
// Close the client socket if it's still open
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors on the client socket
|
||||
clientSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Client connection error: ${error.message}`
|
||||
});
|
||||
|
||||
// Close the server socket if it's still open
|
||||
if (!serverSocket.destroyed) {
|
||||
serverSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Track data transfer for logging
|
||||
let bytesSent = 0;
|
||||
let bytesReceived = 0;
|
||||
|
||||
// Forward data from client to server
|
||||
clientSocket.on('data', (data) => {
|
||||
bytesSent += data.length;
|
||||
|
||||
// Check if server socket is writable
|
||||
if (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
|
||||
});
|
||||
});
|
||||
|
||||
// Handle connection close
|
||||
const handleClose = () => {
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
|
||||
if (!serverSocket.destroyed) {
|
||||
serverSocket.destroy();
|
||||
}
|
||||
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress,
|
||||
bytesSent,
|
||||
bytesReceived
|
||||
});
|
||||
};
|
||||
|
||||
// Set up close handlers
|
||||
clientSocket.on('close', handleClose);
|
||||
serverSocket.on('close', handleClose);
|
||||
|
||||
// Set timeouts
|
||||
const timeout = this.getTimeout();
|
||||
clientSocket.setTimeout(timeout);
|
||||
serverSocket.setTimeout(timeout);
|
||||
|
||||
// Handle timeouts
|
||||
clientSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'Client connection timeout'
|
||||
});
|
||||
handleClose();
|
||||
});
|
||||
|
||||
serverSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'Server connection timeout'
|
||||
});
|
||||
handleClose();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
}
|
||||
}
|
264
ts/forwarding/handlers/https-terminate-to-http-handler.ts
Normal file
264
ts/forwarding/handlers/https-terminate-to-http-handler.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { ForwardingHandler } from './base-handler.js';
|
||||
import type { ForwardConfig } from '../config/forwarding-types.js';
|
||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.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: ForwardConfig) {
|
||||
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
|
||||
});
|
||||
|
||||
// Handle TLS errors
|
||||
tlsSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `TLS error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// The TLS socket will now emit HTTP traffic that can be processed
|
||||
// In a real implementation, we would create an HTTP parser and handle
|
||||
// the requests here, but for simplicity, we'll just log the data
|
||||
|
||||
let dataBuffer = Buffer.alloc(0);
|
||||
|
||||
tlsSocket.on('data', (data) => {
|
||||
// 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'))) {
|
||||
const target = this.getTargetFromConfig();
|
||||
|
||||
// Simple example: forward the data to an HTTP server
|
||||
const socket = plugins.net.connect(target.port, target.host, () => {
|
||||
socket.write(dataBuffer);
|
||||
dataBuffer = Buffer.alloc(0);
|
||||
|
||||
// Set up bidirectional data flow
|
||||
tlsSocket.pipe(socket);
|
||||
socket.pipe(tlsSocket);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Target connection error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle close
|
||||
tlsSocket.on('close', () => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress
|
||||
});
|
||||
});
|
||||
|
||||
// Set timeout
|
||||
const timeout = this.getTimeout();
|
||||
tlsSocket.setTimeout(timeout);
|
||||
|
||||
tlsSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'TLS connection timeout'
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
292
ts/forwarding/handlers/https-terminate-to-https-handler.ts
Normal file
292
ts/forwarding/handlers/https-terminate-to-https-handler.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { ForwardingHandler } from './base-handler.js';
|
||||
import type { ForwardConfig } from '../config/forwarding-types.js';
|
||||
import { ForwardingHandlerEvents } from '../config/forwarding-types.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: ForwardConfig) {
|
||||
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
|
||||
});
|
||||
|
||||
// Handle TLS errors
|
||||
tlsSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `TLS error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// The TLS socket will now emit HTTP traffic that can be processed
|
||||
// In a real implementation, we would create an HTTP parser and handle
|
||||
// the requests here, but for simplicity, we'll just forward the data
|
||||
|
||||
// Get the target from configuration
|
||||
const target = this.getTargetFromConfig();
|
||||
|
||||
// Set up the connection to the HTTPS backend
|
||||
const connectToBackend = () => {
|
||||
const 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
|
||||
}, () => {
|
||||
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
|
||||
direction: 'outbound',
|
||||
target: `${target.host}:${target.port}`,
|
||||
tls: true
|
||||
});
|
||||
|
||||
// Set up bidirectional data flow
|
||||
tlsSocket.pipe(backendSocket);
|
||||
backendSocket.pipe(tlsSocket);
|
||||
});
|
||||
|
||||
backendSocket.on('error', (error) => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: `Backend connection error: ${error.message}`
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle close
|
||||
backendSocket.on('close', () => {
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Set timeout
|
||||
const timeout = this.getTimeout();
|
||||
backendSocket.setTimeout(timeout);
|
||||
|
||||
backendSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'Backend connection timeout'
|
||||
});
|
||||
|
||||
if (!backendSocket.destroyed) {
|
||||
backendSocket.destroy();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Wait for the TLS handshake to complete before connecting to backend
|
||||
tlsSocket.on('secure', () => {
|
||||
connectToBackend();
|
||||
});
|
||||
|
||||
// Handle close
|
||||
tlsSocket.on('close', () => {
|
||||
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
||||
remoteAddress
|
||||
});
|
||||
});
|
||||
|
||||
// Set timeout
|
||||
const timeout = this.getTimeout();
|
||||
tlsSocket.setTimeout(timeout);
|
||||
|
||||
tlsSocket.on('timeout', () => {
|
||||
this.emit(ForwardingHandlerEvents.ERROR, {
|
||||
remoteAddress,
|
||||
error: 'TLS connection timeout'
|
||||
});
|
||||
|
||||
if (!tlsSocket.destroyed) {
|
||||
tlsSocket.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
9
ts/forwarding/handlers/index.ts
Normal file
9
ts/forwarding/handlers/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 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';
|
34
ts/forwarding/index.ts
Normal file
34
ts/forwarding/index.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Forwarding system module
|
||||
* Provides a flexible and type-safe way to configure and manage various forwarding strategies
|
||||
*/
|
||||
|
||||
// Export types and configuration
|
||||
export * from './config/forwarding-types.js';
|
||||
export * from './config/domain-config.js';
|
||||
export * from './config/domain-manager.js';
|
||||
|
||||
// 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';
|
||||
|
||||
// Helper functions as a convenience object
|
||||
import {
|
||||
httpOnly,
|
||||
tlsTerminateToHttp,
|
||||
tlsTerminateToHttps,
|
||||
httpsPassthrough
|
||||
} from './config/forwarding-types.js';
|
||||
|
||||
export const helpers = {
|
||||
httpOnly,
|
||||
tlsTerminateToHttp,
|
||||
tlsTerminateToHttps,
|
||||
httpsPassthrough
|
||||
};
|
@ -1,30 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface ICertificates {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export function loadDefaultCertificates(): ICertificates {
|
||||
try {
|
||||
const certPath = path.join(__dirname, '..', 'assets', 'certs');
|
||||
const privateKey = fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8');
|
||||
const publicKey = fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8');
|
||||
|
||||
if (!privateKey || !publicKey) {
|
||||
throw new Error('Failed to load default certificates');
|
||||
}
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading default certificates:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
19
ts/http/index.ts
Normal file
19
ts/http/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* HTTP functionality module
|
||||
*/
|
||||
|
||||
// Export types and models
|
||||
export * from './models/http-types.js';
|
||||
|
||||
// Export submodules
|
||||
export * from './port80/index.js';
|
||||
export * from './router/index.js';
|
||||
export * from './redirects/index.js';
|
||||
|
||||
// Convenience namespace exports
|
||||
export const Http = {
|
||||
Port80: {
|
||||
Handler: require('./port80/port80-handler.js').Port80Handler,
|
||||
ChallengeResponder: require('./port80/challenge-responder.js').ChallengeResponder
|
||||
}
|
||||
};
|
106
ts/http/models/http-types.ts
Normal file
106
ts/http/models/http-types.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
ForwardConfig,
|
||||
DomainOptions,
|
||||
AcmeOptions
|
||||
} from '../../certificate/models/certificate-types.js';
|
||||
|
||||
/**
|
||||
* HTTP-specific event types
|
||||
*/
|
||||
export enum HttpEvents {
|
||||
REQUEST_RECEIVED = 'request-received',
|
||||
REQUEST_FORWARDED = 'request-forwarded',
|
||||
REQUEST_HANDLED = 'request-handled',
|
||||
REQUEST_ERROR = 'request-error',
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP status codes as an enum for better type safety
|
||||
*/
|
||||
export enum HttpStatus {
|
||||
OK = 200,
|
||||
MOVED_PERMANENTLY = 301,
|
||||
FOUND = 302,
|
||||
TEMPORARY_REDIRECT = 307,
|
||||
PERMANENT_REDIRECT = 308,
|
||||
BAD_REQUEST = 400,
|
||||
NOT_FOUND = 404,
|
||||
METHOD_NOT_ALLOWED = 405,
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
NOT_IMPLEMENTED = 501,
|
||||
SERVICE_UNAVAILABLE = 503,
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a domain configuration with certificate status information
|
||||
*/
|
||||
export interface DomainCertificate {
|
||||
options: DomainOptions;
|
||||
certObtained: boolean;
|
||||
obtainingInProgress: boolean;
|
||||
certificate?: string;
|
||||
privateKey?: string;
|
||||
expiryDate?: Date;
|
||||
lastRenewalAttempt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base error class for HTTP-related errors
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to certificate operations
|
||||
*/
|
||||
export class CertificateError extends HttpError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly domain: string,
|
||||
public readonly isRenewal: boolean = false
|
||||
) {
|
||||
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
|
||||
this.name = 'CertificateError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to server operations
|
||||
*/
|
||||
export class ServerError extends HttpError {
|
||||
constructor(message: string, public readonly code?: string) {
|
||||
super(message);
|
||||
this.name = 'ServerError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect configuration for HTTP requests
|
||||
*/
|
||||
export interface RedirectConfig {
|
||||
source: string; // Source path or pattern
|
||||
destination: string; // Destination URL
|
||||
type: HttpStatus; // Redirect status code
|
||||
preserveQuery?: boolean; // Whether to preserve query parameters
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP router configuration
|
||||
*/
|
||||
export interface RouterConfig {
|
||||
routes: Array<{
|
||||
path: string;
|
||||
handler: (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void;
|
||||
}>;
|
||||
notFoundHandler?: (req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse) => void;
|
||||
}
|
||||
|
||||
// Backward compatibility interfaces
|
||||
export { HttpError as Port80HandlerError };
|
||||
export { CertificateError as CertError };
|
||||
export type IDomainCertificate = DomainCertificate;
|
311
ts/http/port80/challenge-responder.ts
Normal file
311
ts/http/port80/challenge-responder.ts
Normal file
@ -0,0 +1,311 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import {
|
||||
CertificateEvents
|
||||
} from '../../certificate/events/certificate-events.js';
|
||||
import type {
|
||||
CertificateData,
|
||||
CertificateFailure,
|
||||
CertificateExpiring
|
||||
} from '../../certificate/models/certificate-types.js';
|
||||
|
||||
/**
|
||||
* Handles ACME HTTP-01 challenge responses
|
||||
*/
|
||||
export class ChallengeResponder extends plugins.EventEmitter {
|
||||
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
||||
private http01Handler: plugins.smartacme.handlers.Http01MemoryHandler | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new challenge responder
|
||||
* @param useProduction Whether to use production ACME servers
|
||||
* @param email Account email for ACME
|
||||
* @param certificateStore Directory to store certificates
|
||||
*/
|
||||
constructor(
|
||||
private readonly useProduction: boolean = false,
|
||||
private readonly email: string = 'admin@example.com',
|
||||
private readonly certificateStore: string = './certs'
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the ACME client
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
try {
|
||||
// Initialize HTTP-01 challenge handler
|
||||
this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
|
||||
|
||||
// Initialize SmartAcme with proper options
|
||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||
accountEmail: this.email,
|
||||
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(),
|
||||
environment: this.useProduction ? 'production' : 'integration',
|
||||
challengeHandlers: [this.http01Handler],
|
||||
challengePriority: ['http-01'],
|
||||
});
|
||||
|
||||
// Ensure certificate store directory exists
|
||||
await this.ensureCertificateStore();
|
||||
|
||||
// Set up event forwarding from SmartAcme
|
||||
this.setupEventForwarding();
|
||||
|
||||
// Start SmartAcme
|
||||
await this.smartAcme.start();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to initialize ACME client: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event forwarding from SmartAcme to this component
|
||||
*/
|
||||
private setupEventForwarding(): void {
|
||||
if (!this.smartAcme) return;
|
||||
|
||||
// Cast smartAcme to any since different versions have different event APIs
|
||||
const smartAcmeAny = this.smartAcme as any;
|
||||
|
||||
// Forward certificate events to our own emitter
|
||||
if (typeof smartAcmeAny.on === 'function') {
|
||||
smartAcmeAny.on('certificate', (data: any) => {
|
||||
const certData: CertificateData = {
|
||||
domain: data.domain,
|
||||
certificate: data.cert || data.publicKey,
|
||||
privateKey: data.key || data.privateKey,
|
||||
expiryDate: new Date(data.expiryDate || data.validUntil),
|
||||
source: 'http01'
|
||||
};
|
||||
// Emit as issued or renewed based on the renewal flag
|
||||
const eventType = data.isRenewal
|
||||
? CertificateEvents.CERTIFICATE_RENEWED
|
||||
: CertificateEvents.CERTIFICATE_ISSUED;
|
||||
this.emit(eventType, certData);
|
||||
});
|
||||
|
||||
smartAcmeAny.on('error', (data: any) => {
|
||||
const failure: CertificateFailure = {
|
||||
domain: data.domain || 'unknown',
|
||||
error: data.message || data.toString(),
|
||||
isRenewal: false
|
||||
};
|
||||
this.emit(CertificateEvents.CERTIFICATE_FAILED, failure);
|
||||
});
|
||||
} else if (smartAcmeAny.eventEmitter && typeof smartAcmeAny.eventEmitter.on === 'function') {
|
||||
// Alternative event emitter approach for newer versions
|
||||
smartAcmeAny.eventEmitter.on('certificate', (data: any) => {
|
||||
const certData: CertificateData = {
|
||||
domain: data.domain,
|
||||
certificate: data.cert || data.publicKey,
|
||||
privateKey: data.key || data.privateKey,
|
||||
expiryDate: new Date(data.expiryDate || data.validUntil),
|
||||
source: 'http01'
|
||||
};
|
||||
const eventType = data.isRenewal
|
||||
? CertificateEvents.CERTIFICATE_RENEWED
|
||||
: CertificateEvents.CERTIFICATE_ISSUED;
|
||||
this.emit(eventType, certData);
|
||||
});
|
||||
|
||||
smartAcmeAny.eventEmitter.on('error', (data: any) => {
|
||||
const failure: CertificateFailure = {
|
||||
domain: data.domain || 'unknown',
|
||||
error: data.message || data.toString(),
|
||||
isRenewal: false
|
||||
};
|
||||
this.emit(CertificateEvents.CERTIFICATE_FAILED, failure);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure certificate store directory exists
|
||||
*/
|
||||
private async ensureCertificateStore(): Promise<void> {
|
||||
try {
|
||||
await plugins.fs.promises.mkdir(this.certificateStore, { recursive: true });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create certificate store: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP request and check if it's an ACME challenge
|
||||
* @param req HTTP request
|
||||
* @param res HTTP response
|
||||
* @returns true if the request was handled as an ACME challenge
|
||||
*/
|
||||
public handleRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!this.http01Handler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = req.url || '/';
|
||||
|
||||
// Check if this is an ACME challenge request
|
||||
if (url.startsWith('/.well-known/acme-challenge/')) {
|
||||
const token = url.split('/').pop() || '';
|
||||
|
||||
if (token && this.http01Handler) {
|
||||
try {
|
||||
// Try to delegate to the handler - casting to any for flexibility
|
||||
const handler = this.http01Handler as any;
|
||||
|
||||
// Different versions may have different handler methods
|
||||
if (typeof handler.handleChallenge === 'function') {
|
||||
handler.handleChallenge(req, res);
|
||||
return true;
|
||||
} else if (typeof handler.handleRequest === 'function') {
|
||||
// Some versions use handleRequest instead
|
||||
handler.handleRequest(req, res);
|
||||
return true;
|
||||
} else {
|
||||
// Fall back to manual response
|
||||
const resp = this.getTokenResponse(token);
|
||||
if (resp) {
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
|
||||
res.writeHead(200);
|
||||
res.end(resp);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Challenge not found
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid ACME challenge
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response for a specific token if available
|
||||
* This is a fallback method in case direct handler access isn't available
|
||||
*/
|
||||
private getTokenResponse(token: string): string | null {
|
||||
if (!this.http01Handler) return null;
|
||||
|
||||
try {
|
||||
// Cast to any to handle different versions of the API
|
||||
const handler = this.http01Handler as any;
|
||||
|
||||
// Try different methods that might be available in different versions
|
||||
if (typeof handler.getResponse === 'function') {
|
||||
return handler.getResponse(token);
|
||||
}
|
||||
|
||||
if (typeof handler.getChallengeVerification === 'function') {
|
||||
return handler.getChallengeVerification(token);
|
||||
}
|
||||
|
||||
// Try to access the challenges directly from the handler's internal state
|
||||
if (handler.challenges && typeof handler.challenges === 'object' && handler.challenges[token]) {
|
||||
return handler.challenges[token];
|
||||
}
|
||||
|
||||
// Try the token map if it exists (another common pattern)
|
||||
if (handler.tokenMap && typeof handler.tokenMap === 'object' && handler.tokenMap[token]) {
|
||||
return handler.tokenMap[token];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting token response:', err);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a certificate for a domain
|
||||
* @param domain Domain name
|
||||
* @param isRenewal Whether this is a renewal
|
||||
*/
|
||||
public async requestCertificate(domain: string, isRenewal: boolean = false): Promise<CertificateData> {
|
||||
if (!this.smartAcme) {
|
||||
throw new Error('ACME client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Request certificate via SmartAcme
|
||||
const certObj = await this.smartAcme.getCertificateForDomain(domain);
|
||||
|
||||
const certData: CertificateData = {
|
||||
domain,
|
||||
certificate: certObj.publicKey,
|
||||
privateKey: certObj.privateKey,
|
||||
expiryDate: new Date(certObj.validUntil),
|
||||
source: 'http01',
|
||||
isRenewal
|
||||
};
|
||||
|
||||
// SmartACME will emit its own events, but we'll emit our own too
|
||||
// for consistency with the rest of the system
|
||||
if (isRenewal) {
|
||||
this.emit(CertificateEvents.CERTIFICATE_RENEWED, certData);
|
||||
} else {
|
||||
this.emit(CertificateEvents.CERTIFICATE_ISSUED, certData);
|
||||
}
|
||||
|
||||
return certData;
|
||||
} catch (error) {
|
||||
// Construct failure object
|
||||
const failure: CertificateFailure = {
|
||||
domain,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
isRenewal,
|
||||
};
|
||||
|
||||
// Emit failure event
|
||||
this.emit(CertificateEvents.CERTIFICATE_FAILED, failure);
|
||||
|
||||
throw new Error(`Failed to ${isRenewal ? 'renew' : 'obtain'} certificate for ${domain}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a certificate is expiring soon
|
||||
* @param domain Domain name
|
||||
* @param certificate Certificate data
|
||||
* @param thresholdDays Days before expiry to trigger a renewal
|
||||
*/
|
||||
public checkCertificateExpiry(
|
||||
domain: string,
|
||||
certificate: CertificateData,
|
||||
thresholdDays: number = 30
|
||||
): void {
|
||||
if (!certificate.expiryDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiryDate = certificate.expiryDate;
|
||||
const daysDifference = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDifference <= thresholdDays) {
|
||||
const expiryInfo: CertificateExpiring = {
|
||||
domain,
|
||||
expiryDate,
|
||||
daysRemaining: daysDifference,
|
||||
};
|
||||
|
||||
this.emit(CertificateEvents.CERTIFICATE_EXPIRING, expiryInfo);
|
||||
|
||||
// Automatically attempt renewal if expiring
|
||||
if (this.smartAcme) {
|
||||
this.requestCertificate(domain, true).catch(error => {
|
||||
console.error(`Failed to auto-renew certificate for ${domain}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
13
ts/http/port80/index.ts
Normal file
13
ts/http/port80/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Port 80 handling
|
||||
*/
|
||||
|
||||
// Export the main components
|
||||
export { Port80Handler } from './port80-handler.js';
|
||||
export { ChallengeResponder } from './challenge-responder.js';
|
||||
|
||||
// Export backward compatibility interfaces and types
|
||||
export {
|
||||
HttpError as Port80HandlerError,
|
||||
CertificateError as CertError
|
||||
} from '../models/http-types.js';
|
685
ts/http/port80/port80-handler.ts
Normal file
685
ts/http/port80/port80-handler.ts
Normal file
@ -0,0 +1,685 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { CertificateEvents } from '../../certificate/events/certificate-events.js';
|
||||
import type {
|
||||
ForwardConfig,
|
||||
DomainOptions,
|
||||
CertificateData,
|
||||
CertificateFailure,
|
||||
CertificateExpiring,
|
||||
AcmeOptions
|
||||
} from '../../certificate/models/certificate-types.js';
|
||||
import {
|
||||
HttpEvents,
|
||||
HttpStatus,
|
||||
HttpError,
|
||||
CertificateError,
|
||||
ServerError,
|
||||
} from '../models/http-types.js';
|
||||
import type { DomainCertificate } from '../models/http-types.js';
|
||||
import { ChallengeResponder } from './challenge-responder.js';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export {
|
||||
HttpError as Port80HandlerError,
|
||||
CertificateError,
|
||||
ServerError
|
||||
}
|
||||
|
||||
// Port80Handler events enum for backward compatibility
|
||||
export const Port80HandlerEvents = CertificateEvents;
|
||||
|
||||
/**
|
||||
* Configuration options for the Port80Handler
|
||||
*/
|
||||
// Port80Handler options moved to common types
|
||||
|
||||
|
||||
/**
|
||||
* Port80Handler with ACME certificate management and request forwarding capabilities
|
||||
* Now with glob pattern support for domain matching
|
||||
*/
|
||||
export class Port80Handler extends plugins.EventEmitter {
|
||||
private domainCertificates: Map<string, DomainCertificate>;
|
||||
private challengeResponder: ChallengeResponder | null = null;
|
||||
private server: plugins.http.Server | null = null;
|
||||
|
||||
// Renewal scheduling is handled externally by SmartProxy
|
||||
private isShuttingDown: boolean = false;
|
||||
private options: Required<AcmeOptions>;
|
||||
|
||||
/**
|
||||
* Creates a new Port80Handler
|
||||
* @param options Configuration options
|
||||
*/
|
||||
constructor(options: AcmeOptions = {}) {
|
||||
super();
|
||||
this.domainCertificates = new Map<string, DomainCertificate>();
|
||||
|
||||
// Default options
|
||||
this.options = {
|
||||
port: options.port ?? 80,
|
||||
accountEmail: options.accountEmail ?? 'admin@example.com',
|
||||
useProduction: options.useProduction ?? false, // Safer default: staging
|
||||
httpsRedirectPort: options.httpsRedirectPort ?? 443,
|
||||
enabled: options.enabled ?? true, // Enable by default
|
||||
certificateStore: options.certificateStore ?? './certs',
|
||||
skipConfiguredCerts: options.skipConfiguredCerts ?? false,
|
||||
renewThresholdDays: options.renewThresholdDays ?? 30,
|
||||
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
|
||||
autoRenew: options.autoRenew ?? true,
|
||||
domainForwards: options.domainForwards ?? []
|
||||
};
|
||||
|
||||
// Initialize challenge responder
|
||||
if (this.options.enabled) {
|
||||
this.challengeResponder = new ChallengeResponder(
|
||||
this.options.useProduction,
|
||||
this.options.accountEmail,
|
||||
this.options.certificateStore
|
||||
);
|
||||
|
||||
// Forward certificate events from the challenge responder
|
||||
this.challengeResponder.on(CertificateEvents.CERTIFICATE_ISSUED, (data: CertificateData) => {
|
||||
this.emit(CertificateEvents.CERTIFICATE_ISSUED, data);
|
||||
});
|
||||
|
||||
this.challengeResponder.on(CertificateEvents.CERTIFICATE_RENEWED, (data: CertificateData) => {
|
||||
this.emit(CertificateEvents.CERTIFICATE_RENEWED, data);
|
||||
});
|
||||
|
||||
this.challengeResponder.on(CertificateEvents.CERTIFICATE_FAILED, (error: CertificateFailure) => {
|
||||
this.emit(CertificateEvents.CERTIFICATE_FAILED, error);
|
||||
});
|
||||
|
||||
this.challengeResponder.on(CertificateEvents.CERTIFICATE_EXPIRING, (expiry: CertificateExpiring) => {
|
||||
this.emit(CertificateEvents.CERTIFICATE_EXPIRING, expiry);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the HTTP server for ACME challenges
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.server) {
|
||||
throw new ServerError('Server is already running');
|
||||
}
|
||||
|
||||
if (this.isShuttingDown) {
|
||||
throw new ServerError('Server is shutting down');
|
||||
}
|
||||
|
||||
// Skip if disabled
|
||||
if (this.options.enabled === false) {
|
||||
console.log('Port80Handler is disabled, skipping start');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize the challenge responder if enabled
|
||||
if (this.options.enabled && this.challengeResponder) {
|
||||
try {
|
||||
await this.challengeResponder.initialize();
|
||||
} catch (error) {
|
||||
throw new ServerError(`Failed to initialize challenge responder: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
|
||||
|
||||
this.server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EACCES') {
|
||||
reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code));
|
||||
} else if (error.code === 'EADDRINUSE') {
|
||||
reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code));
|
||||
} else {
|
||||
reject(new ServerError(error.message, error.code));
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(this.options.port, () => {
|
||||
console.log(`Port80Handler is listening on port ${this.options.port}`);
|
||||
this.emit(CertificateEvents.MANAGER_STARTED, this.options.port);
|
||||
|
||||
// Start certificate process for domains with acmeMaintenance enabled
|
||||
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
||||
// Skip glob patterns for certificate issuance
|
||||
if (this.isGlobPattern(domain)) {
|
||||
console.log(`Skipping initial certificate for glob pattern: ${domain}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) {
|
||||
this.obtainCertificate(domain).catch(err => {
|
||||
console.error(`Error obtaining initial certificate for ${domain}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error starting server';
|
||||
reject(new ServerError(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the HTTP server and cleanup resources
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isShuttingDown = true;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
if (this.server) {
|
||||
this.server.close(() => {
|
||||
this.server = null;
|
||||
this.isShuttingDown = false;
|
||||
this.emit(CertificateEvents.MANAGER_STOPPED);
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
this.isShuttingDown = false;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a domain with configuration options
|
||||
* @param options Domain configuration options
|
||||
*/
|
||||
public addDomain(options: DomainOptions): void {
|
||||
if (!options.domainName || typeof options.domainName !== 'string') {
|
||||
throw new HttpError('Invalid domain name');
|
||||
}
|
||||
|
||||
const domainName = options.domainName;
|
||||
|
||||
if (!this.domainCertificates.has(domainName)) {
|
||||
this.domainCertificates.set(domainName, {
|
||||
options,
|
||||
certObtained: false,
|
||||
obtainingInProgress: false
|
||||
});
|
||||
|
||||
console.log(`Domain added: ${domainName} with configuration:`, {
|
||||
sslRedirect: options.sslRedirect,
|
||||
acmeMaintenance: options.acmeMaintenance,
|
||||
hasForward: !!options.forward,
|
||||
hasAcmeForward: !!options.acmeForward
|
||||
});
|
||||
|
||||
// If acmeMaintenance is enabled and not a glob pattern, start certificate process immediately
|
||||
if (options.acmeMaintenance && this.server && !this.isGlobPattern(domainName)) {
|
||||
this.obtainCertificate(domainName).catch(err => {
|
||||
console.error(`Error obtaining initial certificate for ${domainName}:`, err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Update existing domain with new options
|
||||
const existing = this.domainCertificates.get(domainName)!;
|
||||
existing.options = options;
|
||||
console.log(`Domain ${domainName} configuration updated`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a domain from management
|
||||
* @param domain The domain to remove
|
||||
*/
|
||||
public removeDomain(domain: string): void {
|
||||
if (this.domainCertificates.delete(domain)) {
|
||||
console.log(`Domain removed: ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the certificate for a domain if it exists
|
||||
* @param domain The domain to get the certificate for
|
||||
*/
|
||||
public getCertificate(domain: string): CertificateData | null {
|
||||
// Can't get certificates for glob patterns
|
||||
if (this.isGlobPattern(domain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const domainInfo = this.domainCertificates.get(domain);
|
||||
|
||||
if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
domain,
|
||||
certificate: domainInfo.certificate,
|
||||
privateKey: domainInfo.privateKey,
|
||||
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if a domain is a glob pattern
|
||||
* @param domain Domain to check
|
||||
* @returns True if the domain is a glob pattern
|
||||
*/
|
||||
private isGlobPattern(domain: string): boolean {
|
||||
return domain.includes('*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain info for a specific domain, using glob pattern matching if needed
|
||||
* @param requestDomain The actual domain from the request
|
||||
* @returns The domain info or null if not found
|
||||
*/
|
||||
private getDomainInfoForRequest(requestDomain: string): { domainInfo: DomainCertificate, pattern: string } | null {
|
||||
// Try direct match first
|
||||
if (this.domainCertificates.has(requestDomain)) {
|
||||
return {
|
||||
domainInfo: this.domainCertificates.get(requestDomain)!,
|
||||
pattern: requestDomain
|
||||
};
|
||||
}
|
||||
|
||||
// Then try glob patterns
|
||||
for (const [pattern, domainInfo] of this.domainCertificates.entries()) {
|
||||
if (this.isGlobPattern(pattern) && this.domainMatchesPattern(requestDomain, pattern)) {
|
||||
return { domainInfo, pattern };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain matches a glob pattern
|
||||
* @param domain The domain to check
|
||||
* @param pattern The pattern to match against
|
||||
* @returns True if the domain matches the pattern
|
||||
*/
|
||||
private domainMatchesPattern(domain: string, pattern: string): boolean {
|
||||
// Handle different glob pattern styles
|
||||
if (pattern.startsWith('*.')) {
|
||||
// *.example.com matches any subdomain
|
||||
const suffix = pattern.substring(2);
|
||||
return domain.endsWith(suffix) && domain.includes('.') && domain !== suffix;
|
||||
} else if (pattern.endsWith('.*')) {
|
||||
// example.* matches any TLD
|
||||
const prefix = pattern.substring(0, pattern.length - 2);
|
||||
const domainParts = domain.split('.');
|
||||
return domain.startsWith(prefix + '.') && domainParts.length >= 2;
|
||||
} else if (pattern === '*') {
|
||||
// Wildcard matches everything
|
||||
return true;
|
||||
} else {
|
||||
// Exact match (shouldn't reach here as we check exact matches first)
|
||||
return domain === pattern;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles incoming HTTP requests
|
||||
* @param req The HTTP request
|
||||
* @param res The HTTP response
|
||||
*/
|
||||
private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
||||
// Emit request received event with basic info
|
||||
this.emit(HttpEvents.REQUEST_RECEIVED, {
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
headers: req.headers
|
||||
});
|
||||
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader) {
|
||||
res.statusCode = HttpStatus.BAD_REQUEST;
|
||||
res.end('Bad Request: Host header is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract domain (ignoring any port in the Host header)
|
||||
const domain = hostHeader.split(':')[0];
|
||||
|
||||
// Check if this is an ACME challenge request that our ChallengeResponder can handle
|
||||
if (this.challengeResponder && req.url?.startsWith('/.well-known/acme-challenge/')) {
|
||||
// Handle ACME HTTP-01 challenge with the challenge responder
|
||||
const domainMatch = this.getDomainInfoForRequest(domain);
|
||||
|
||||
// If there's a specific ACME forwarding config for this domain, use that instead
|
||||
if (domainMatch?.domainInfo.options.acmeForward) {
|
||||
this.forwardRequest(req, res, domainMatch.domainInfo.options.acmeForward, 'ACME challenge');
|
||||
return;
|
||||
}
|
||||
|
||||
// If domain exists and has acmeMaintenance enabled, or we don't have the domain yet
|
||||
// (for auto-provisioning), try to handle the ACME challenge
|
||||
if (!domainMatch || domainMatch.domainInfo.options.acmeMaintenance) {
|
||||
// Let the challenge responder try to handle this request
|
||||
if (this.challengeResponder.handleRequest(req, res)) {
|
||||
// Challenge was handled
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic provisioning: if domain not yet managed, register for ACME and return 503
|
||||
if (!this.domainCertificates.has(domain)) {
|
||||
try {
|
||||
this.addDomain({ domainName: domain, sslRedirect: false, acmeMaintenance: true });
|
||||
} catch (err) {
|
||||
console.error(`Error registering domain for on-demand provisioning: ${err}`);
|
||||
}
|
||||
res.statusCode = HttpStatus.SERVICE_UNAVAILABLE;
|
||||
res.end('Certificate issuance in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get domain config, using glob pattern matching if needed
|
||||
const domainMatch = this.getDomainInfoForRequest(domain);
|
||||
if (!domainMatch) {
|
||||
res.statusCode = HttpStatus.NOT_FOUND;
|
||||
res.end('Domain not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const { domainInfo, pattern } = domainMatch;
|
||||
const options = domainInfo.options;
|
||||
|
||||
// Check if we should forward non-ACME requests
|
||||
if (options.forward) {
|
||||
this.forwardRequest(req, res, options.forward, 'HTTP');
|
||||
return;
|
||||
}
|
||||
|
||||
// If certificate exists and sslRedirect is enabled, redirect to HTTPS
|
||||
// (Skip for glob patterns as they won't have certificates)
|
||||
if (!this.isGlobPattern(pattern) && domainInfo.certObtained && options.sslRedirect) {
|
||||
const httpsPort = this.options.httpsRedirectPort;
|
||||
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
|
||||
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
|
||||
|
||||
res.statusCode = HttpStatus.MOVED_PERMANENTLY;
|
||||
res.setHeader('Location', redirectUrl);
|
||||
res.end(`Redirecting to ${redirectUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle case where certificate maintenance is enabled but not yet obtained
|
||||
// (Skip for glob patterns as they can't have certificates)
|
||||
if (!this.isGlobPattern(pattern) && options.acmeMaintenance && !domainInfo.certObtained) {
|
||||
// Trigger certificate issuance if not already running
|
||||
if (!domainInfo.obtainingInProgress) {
|
||||
this.obtainCertificate(domain).catch(err => {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
this.emit(CertificateEvents.CERTIFICATE_FAILED, {
|
||||
domain,
|
||||
error: errorMessage,
|
||||
isRenewal: false
|
||||
});
|
||||
console.error(`Error obtaining certificate for ${domain}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
res.statusCode = HttpStatus.SERVICE_UNAVAILABLE;
|
||||
res.end('Certificate issuance in progress, please try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Default response for unhandled request
|
||||
res.statusCode = HttpStatus.NOT_FOUND;
|
||||
res.end('No handlers configured for this request');
|
||||
|
||||
// Emit request handled event
|
||||
this.emit(HttpEvents.REQUEST_HANDLED, {
|
||||
domain,
|
||||
url: req.url,
|
||||
statusCode: res.statusCode
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards an HTTP request to the specified target
|
||||
* @param req The original request
|
||||
* @param res The response object
|
||||
* @param target The forwarding target (IP and port)
|
||||
* @param requestType Type of request for logging
|
||||
*/
|
||||
private forwardRequest(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
target: ForwardConfig,
|
||||
requestType: string
|
||||
): void {
|
||||
const options = {
|
||||
hostname: target.ip,
|
||||
port: target.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers }
|
||||
};
|
||||
|
||||
const domain = req.headers.host?.split(':')[0] || 'unknown';
|
||||
console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`);
|
||||
|
||||
const proxyReq = plugins.http.request(options, (proxyRes) => {
|
||||
// Copy status code
|
||||
res.statusCode = proxyRes.statusCode || HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
// Copy headers
|
||||
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
||||
if (value) res.setHeader(key, value);
|
||||
}
|
||||
|
||||
// Pipe response data
|
||||
proxyRes.pipe(res);
|
||||
|
||||
this.emit(HttpEvents.REQUEST_FORWARDED, {
|
||||
domain,
|
||||
requestType,
|
||||
target: `${target.ip}:${target.port}`,
|
||||
statusCode: proxyRes.statusCode
|
||||
});
|
||||
});
|
||||
|
||||
proxyReq.on('error', (error) => {
|
||||
console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error);
|
||||
|
||||
this.emit(HttpEvents.REQUEST_ERROR, {
|
||||
domain,
|
||||
error: error.message,
|
||||
target: `${target.ip}:${target.port}`
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
res.end(`Proxy error: ${error.message}`);
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Pipe original request to proxy request
|
||||
if (req.readable) {
|
||||
req.pipe(proxyReq);
|
||||
} else {
|
||||
proxyReq.end();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Obtains a certificate for a domain using ACME HTTP-01 challenge
|
||||
* @param domain The domain to obtain a certificate for
|
||||
* @param isRenewal Whether this is a renewal attempt
|
||||
*/
|
||||
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
|
||||
if (this.isGlobPattern(domain)) {
|
||||
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
|
||||
}
|
||||
|
||||
const domainInfo = this.domainCertificates.get(domain)!;
|
||||
|
||||
if (!domainInfo.options.acmeMaintenance) {
|
||||
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (domainInfo.obtainingInProgress) {
|
||||
console.log(`Certificate issuance already in progress for ${domain}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.challengeResponder) {
|
||||
throw new HttpError('Challenge responder is not initialized');
|
||||
}
|
||||
|
||||
domainInfo.obtainingInProgress = true;
|
||||
domainInfo.lastRenewalAttempt = new Date();
|
||||
|
||||
try {
|
||||
// Request certificate via ChallengeResponder
|
||||
const certData = await this.challengeResponder.requestCertificate(domain, isRenewal);
|
||||
|
||||
// Update domain info with certificate data
|
||||
domainInfo.certificate = certData.certificate;
|
||||
domainInfo.privateKey = certData.privateKey;
|
||||
domainInfo.certObtained = true;
|
||||
domainInfo.expiryDate = certData.expiryDate;
|
||||
|
||||
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
||||
|
||||
// The event will be emitted by the ChallengeResponder, we just store the certificate
|
||||
} catch (error: any) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Error during certificate issuance for ${domain}:`, error);
|
||||
|
||||
// The failure event will be emitted by the ChallengeResponder
|
||||
throw new CertificateError(errorMsg, domain, isRenewal);
|
||||
} finally {
|
||||
domainInfo.obtainingInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extract expiry date from certificate using a more robust approach
|
||||
* @param certificate Certificate PEM string
|
||||
* @param domain Domain for logging
|
||||
* @returns Extracted expiry date or default
|
||||
*/
|
||||
private extractExpiryDateFromCertificate(certificate: string, domain: string): Date {
|
||||
try {
|
||||
// This is still using regex, but in a real implementation you would use
|
||||
// a library like node-forge or x509 to properly parse the certificate
|
||||
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
||||
if (matches && matches[1]) {
|
||||
const expiryDate = new Date(matches[1]);
|
||||
|
||||
// Validate that we got a valid date
|
||||
if (!isNaN(expiryDate.getTime())) {
|
||||
console.log(`Certificate for ${domain} will expire on ${expiryDate.toISOString()}`);
|
||||
return expiryDate;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`Could not extract valid expiry date from certificate for ${domain}, using default`);
|
||||
return this.getDefaultExpiryDate();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to extract expiry date from certificate for ${domain}, using default`);
|
||||
return this.getDefaultExpiryDate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a default expiry date (90 days from now)
|
||||
* @returns Default expiry date
|
||||
*/
|
||||
private getDefaultExpiryDate(): Date {
|
||||
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days default
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a certificate event with the certificate data
|
||||
* @param eventType The event type to emit
|
||||
* @param data The certificate data
|
||||
*/
|
||||
private emitCertificateEvent(eventType: CertificateEvents, data: CertificateData): void {
|
||||
this.emit(eventType, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all domains and their certificate status
|
||||
* @returns Map of domains to certificate status
|
||||
*/
|
||||
public getDomainCertificateStatus(): Map<string, {
|
||||
certObtained: boolean;
|
||||
expiryDate?: Date;
|
||||
daysRemaining?: number;
|
||||
obtainingInProgress: boolean;
|
||||
lastRenewalAttempt?: Date;
|
||||
}> {
|
||||
const result = new Map<string, {
|
||||
certObtained: boolean;
|
||||
expiryDate?: Date;
|
||||
daysRemaining?: number;
|
||||
obtainingInProgress: boolean;
|
||||
lastRenewalAttempt?: Date;
|
||||
}>();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
||||
// Skip glob patterns
|
||||
if (this.isGlobPattern(domain)) continue;
|
||||
|
||||
const status: {
|
||||
certObtained: boolean;
|
||||
expiryDate?: Date;
|
||||
daysRemaining?: number;
|
||||
obtainingInProgress: boolean;
|
||||
lastRenewalAttempt?: Date;
|
||||
} = {
|
||||
certObtained: domainInfo.certObtained,
|
||||
expiryDate: domainInfo.expiryDate,
|
||||
obtainingInProgress: domainInfo.obtainingInProgress,
|
||||
lastRenewalAttempt: domainInfo.lastRenewalAttempt
|
||||
};
|
||||
|
||||
// Calculate days remaining if expiry date is available
|
||||
if (domainInfo.expiryDate) {
|
||||
const daysRemaining = Math.ceil(
|
||||
(domainInfo.expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
||||
);
|
||||
status.daysRemaining = daysRemaining;
|
||||
}
|
||||
|
||||
result.set(domain, status);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a certificate renewal for a specific domain.
|
||||
* @param domain The domain to renew.
|
||||
*/
|
||||
public async renewCertificate(domain: string): Promise<void> {
|
||||
if (!this.domainCertificates.has(domain)) {
|
||||
throw new HttpError(`Domain not managed: ${domain}`);
|
||||
}
|
||||
// Trigger renewal via ACME
|
||||
await this.obtainCertificate(domain, true);
|
||||
}
|
||||
}
|
3
ts/http/redirects/index.ts
Normal file
3
ts/http/redirects/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* HTTP redirects
|
||||
*/
|
3
ts/http/router/index.ts
Normal file
3
ts/http/router/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* HTTP routing
|
||||
*/
|
31
ts/index.ts
31
ts/index.ts
@ -1,12 +1,33 @@
|
||||
/**
|
||||
* SmartProxy main module exports
|
||||
*/
|
||||
|
||||
// Legacy exports (to maintain backward compatibility)
|
||||
export * from './nfttablesproxy/classes.nftablesproxy.js';
|
||||
export * from './networkproxy/index.js';
|
||||
export * from './port80handler/classes.port80handler.js';
|
||||
// Export port80handler elements selectively to avoid conflicts
|
||||
export {
|
||||
Port80Handler,
|
||||
default as Port80HandlerDefault,
|
||||
HttpError,
|
||||
ServerError,
|
||||
CertificateError
|
||||
} from './port80handler/classes.port80handler.js';
|
||||
// Use re-export to control the names
|
||||
export { Port80HandlerEvents } from './certificate/events/certificate-events.js';
|
||||
|
||||
export * from './redirect/classes.redirect.js';
|
||||
export * from './smartproxy/classes.smartproxy.js';
|
||||
export * from './smartproxy/classes.pp.snihandler.js';
|
||||
// Original: export * from './smartproxy/classes.pp.snihandler.js'
|
||||
// Now we export from the new module
|
||||
export { SniHandler } from './tls/sni/sni-handler.js';
|
||||
export * from './smartproxy/classes.pp.interfaces.js';
|
||||
|
||||
export * from './common/types.js';
|
||||
// Core types and utilities
|
||||
export * from './core/models/common-types.js';
|
||||
|
||||
// Export forwarding system
|
||||
export * as forwarding from './smartproxy/forwarding/index.js';
|
||||
// Modular exports for new architecture
|
||||
export * as forwarding from './forwarding/index.js';
|
||||
export * as certificate from './certificate/index.js';
|
||||
export * as tls from './tls/index.js';
|
||||
export * as http from './http/index.js';
|
@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
||||
import { type INetworkProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './classes.np.types.js';
|
||||
import { Port80Handler } from '../port80handler/classes.port80handler.js';
|
||||
import { Port80HandlerEvents } from '../common/types.js';
|
||||
import { buildPort80Handler } from '../common/acmeFactory.js';
|
||||
import { buildPort80Handler } from '../certificate/acme/acme-factory.js';
|
||||
import { subscribeToPort80Handler } from '../common/eventUtils.js';
|
||||
import type { IDomainOptions } from '../common/types.js';
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
// node native scope
|
||||
import { EventEmitter } from 'events';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as net from 'net';
|
||||
@ -7,7 +8,7 @@ import * as tls from 'tls';
|
||||
import * as url from 'url';
|
||||
import * as http2 from 'http2';
|
||||
|
||||
export { EventEmitter, http, https, net, tls, url, http2 };
|
||||
export { EventEmitter, fs, http, https, net, tls, url, http2 };
|
||||
|
||||
// tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
@ -1,679 +1,24 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { Port80HandlerEvents } from '../common/types.js';
|
||||
import type {
|
||||
IForwardConfig,
|
||||
IDomainOptions,
|
||||
ICertificateData,
|
||||
ICertificateFailure,
|
||||
ICertificateExpiring,
|
||||
IAcmeOptions
|
||||
} from '../common/types.js';
|
||||
// (fs and path I/O moved to CertProvisioner)
|
||||
|
||||
/**
|
||||
* Custom error classes for better error handling
|
||||
* TEMPORARY FILE FOR BACKWARD COMPATIBILITY
|
||||
* This will be removed in a future version when all imports are updated
|
||||
* @deprecated Use the new HTTP module instead
|
||||
*/
|
||||
export class Port80HandlerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'Port80HandlerError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CertificateError extends Port80HandlerError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly domain: string,
|
||||
public readonly isRenewal: boolean = false
|
||||
) {
|
||||
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
|
||||
this.name = 'CertificateError';
|
||||
}
|
||||
}
|
||||
// Re-export the Port80Handler from its new location
|
||||
export * from '../http/port80/port80-handler.js';
|
||||
|
||||
export class ServerError extends Port80HandlerError {
|
||||
constructor(message: string, public readonly code?: string) {
|
||||
super(message);
|
||||
this.name = 'ServerError';
|
||||
}
|
||||
}
|
||||
// Re-export HTTP error types for backward compatibility
|
||||
export * from '../http/models/http-types.js';
|
||||
|
||||
// Re-export selected events to avoid name conflicts
|
||||
export {
|
||||
CertificateEvents,
|
||||
Port80HandlerEvents,
|
||||
CertProvisionerEvents
|
||||
} from '../certificate/events/certificate-events.js';
|
||||
|
||||
/**
|
||||
* Represents a domain configuration with certificate status information
|
||||
*/
|
||||
interface IDomainCertificate {
|
||||
options: IDomainOptions;
|
||||
certObtained: boolean;
|
||||
obtainingInProgress: boolean;
|
||||
certificate?: string;
|
||||
privateKey?: string;
|
||||
expiryDate?: Date;
|
||||
lastRenewalAttempt?: Date;
|
||||
}
|
||||
// Import the new Port80Handler
|
||||
import { Port80Handler } from '../http/port80/port80-handler.js';
|
||||
|
||||
/**
|
||||
* Configuration options for the Port80Handler
|
||||
*/
|
||||
// Port80Handler options moved to common types
|
||||
|
||||
|
||||
/**
|
||||
* Port80Handler with ACME certificate management and request forwarding capabilities
|
||||
* Now with glob pattern support for domain matching
|
||||
*/
|
||||
export class Port80Handler extends plugins.EventEmitter {
|
||||
private domainCertificates: Map<string, IDomainCertificate>;
|
||||
// SmartAcme instance for certificate management
|
||||
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
||||
private smartAcmeHttp01Handler!: plugins.smartacme.handlers.Http01MemoryHandler;
|
||||
private server: plugins.http.Server | null = null;
|
||||
|
||||
// Renewal scheduling is handled externally by SmartProxy
|
||||
// (Removed internal renewal timer)
|
||||
private isShuttingDown: boolean = false;
|
||||
private options: Required<IAcmeOptions>;
|
||||
|
||||
/**
|
||||
* Creates a new Port80Handler
|
||||
* @param options Configuration options
|
||||
*/
|
||||
constructor(options: IAcmeOptions = {}) {
|
||||
super();
|
||||
this.domainCertificates = new Map<string, IDomainCertificate>();
|
||||
|
||||
// Default options
|
||||
this.options = {
|
||||
port: options.port ?? 80,
|
||||
accountEmail: options.accountEmail ?? 'admin@example.com',
|
||||
useProduction: options.useProduction ?? false, // Safer default: staging
|
||||
httpsRedirectPort: options.httpsRedirectPort ?? 443,
|
||||
enabled: options.enabled ?? true, // Enable by default
|
||||
certificateStore: options.certificateStore ?? './certs',
|
||||
skipConfiguredCerts: options.skipConfiguredCerts ?? false,
|
||||
renewThresholdDays: options.renewThresholdDays ?? 30,
|
||||
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
|
||||
autoRenew: options.autoRenew ?? true,
|
||||
domainForwards: options.domainForwards ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the HTTP server for ACME challenges
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.server) {
|
||||
throw new ServerError('Server is already running');
|
||||
}
|
||||
|
||||
if (this.isShuttingDown) {
|
||||
throw new ServerError('Server is shutting down');
|
||||
}
|
||||
|
||||
// Skip if disabled
|
||||
if (this.options.enabled === false) {
|
||||
console.log('Port80Handler is disabled, skipping start');
|
||||
return;
|
||||
}
|
||||
// Initialize SmartAcme with in-memory HTTP-01 challenge handler
|
||||
if (this.options.enabled) {
|
||||
this.smartAcmeHttp01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
|
||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||
accountEmail: this.options.accountEmail,
|
||||
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(),
|
||||
environment: this.options.useProduction ? 'production' : 'integration',
|
||||
challengeHandlers: [ this.smartAcmeHttp01Handler ],
|
||||
challengePriority: ['http-01'],
|
||||
});
|
||||
await this.smartAcme.start();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
|
||||
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
|
||||
|
||||
this.server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EACCES') {
|
||||
reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code));
|
||||
} else if (error.code === 'EADDRINUSE') {
|
||||
reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code));
|
||||
} else {
|
||||
reject(new ServerError(error.message, error.code));
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(this.options.port, () => {
|
||||
console.log(`Port80Handler is listening on port ${this.options.port}`);
|
||||
this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port);
|
||||
|
||||
// Start certificate process for domains with acmeMaintenance enabled
|
||||
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
||||
// Skip glob patterns for certificate issuance
|
||||
if (this.isGlobPattern(domain)) {
|
||||
console.log(`Skipping initial certificate for glob pattern: ${domain}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) {
|
||||
this.obtainCertificate(domain).catch(err => {
|
||||
console.error(`Error obtaining initial certificate for ${domain}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error starting server';
|
||||
reject(new ServerError(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the HTTP server and renewal timer
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isShuttingDown = true;
|
||||
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
if (this.server) {
|
||||
this.server.close(() => {
|
||||
this.server = null;
|
||||
this.isShuttingDown = false;
|
||||
this.emit(Port80HandlerEvents.MANAGER_STOPPED);
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
this.isShuttingDown = false;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a domain with configuration options
|
||||
* @param options Domain configuration options
|
||||
*/
|
||||
public addDomain(options: IDomainOptions): void {
|
||||
if (!options.domainName || typeof options.domainName !== 'string') {
|
||||
throw new Port80HandlerError('Invalid domain name');
|
||||
}
|
||||
|
||||
const domainName = options.domainName;
|
||||
|
||||
if (!this.domainCertificates.has(domainName)) {
|
||||
this.domainCertificates.set(domainName, {
|
||||
options,
|
||||
certObtained: false,
|
||||
obtainingInProgress: false
|
||||
});
|
||||
|
||||
console.log(`Domain added: ${domainName} with configuration:`, {
|
||||
sslRedirect: options.sslRedirect,
|
||||
acmeMaintenance: options.acmeMaintenance,
|
||||
hasForward: !!options.forward,
|
||||
hasAcmeForward: !!options.acmeForward
|
||||
});
|
||||
|
||||
// If acmeMaintenance is enabled and not a glob pattern, start certificate process immediately
|
||||
if (options.acmeMaintenance && this.server && !this.isGlobPattern(domainName)) {
|
||||
this.obtainCertificate(domainName).catch(err => {
|
||||
console.error(`Error obtaining initial certificate for ${domainName}:`, err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Update existing domain with new options
|
||||
const existing = this.domainCertificates.get(domainName)!;
|
||||
existing.options = options;
|
||||
console.log(`Domain ${domainName} configuration updated`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a domain from management
|
||||
* @param domain The domain to remove
|
||||
*/
|
||||
public removeDomain(domain: string): void {
|
||||
if (this.domainCertificates.delete(domain)) {
|
||||
console.log(`Domain removed: ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the certificate for a domain if it exists
|
||||
* @param domain The domain to get the certificate for
|
||||
*/
|
||||
public getCertificate(domain: string): ICertificateData | null {
|
||||
// Can't get certificates for glob patterns
|
||||
if (this.isGlobPattern(domain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const domainInfo = this.domainCertificates.get(domain);
|
||||
|
||||
if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
domain,
|
||||
certificate: domainInfo.certificate,
|
||||
privateKey: domainInfo.privateKey,
|
||||
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if a domain is a glob pattern
|
||||
* @param domain Domain to check
|
||||
* @returns True if the domain is a glob pattern
|
||||
*/
|
||||
private isGlobPattern(domain: string): boolean {
|
||||
return domain.includes('*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain info for a specific domain, using glob pattern matching if needed
|
||||
* @param requestDomain The actual domain from the request
|
||||
* @returns The domain info or null if not found
|
||||
*/
|
||||
private getDomainInfoForRequest(requestDomain: string): { domainInfo: IDomainCertificate, pattern: string } | null {
|
||||
// Try direct match first
|
||||
if (this.domainCertificates.has(requestDomain)) {
|
||||
return {
|
||||
domainInfo: this.domainCertificates.get(requestDomain)!,
|
||||
pattern: requestDomain
|
||||
};
|
||||
}
|
||||
|
||||
// Then try glob patterns
|
||||
for (const [pattern, domainInfo] of this.domainCertificates.entries()) {
|
||||
if (this.isGlobPattern(pattern) && this.domainMatchesPattern(requestDomain, pattern)) {
|
||||
return { domainInfo, pattern };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain matches a glob pattern
|
||||
* @param domain The domain to check
|
||||
* @param pattern The pattern to match against
|
||||
* @returns True if the domain matches the pattern
|
||||
*/
|
||||
private domainMatchesPattern(domain: string, pattern: string): boolean {
|
||||
// Handle different glob pattern styles
|
||||
if (pattern.startsWith('*.')) {
|
||||
// *.example.com matches any subdomain
|
||||
const suffix = pattern.substring(2);
|
||||
return domain.endsWith(suffix) && domain.includes('.') && domain !== suffix;
|
||||
} else if (pattern.endsWith('.*')) {
|
||||
// example.* matches any TLD
|
||||
const prefix = pattern.substring(0, pattern.length - 2);
|
||||
const domainParts = domain.split('.');
|
||||
return domain.startsWith(prefix + '.') && domainParts.length >= 2;
|
||||
} else if (pattern === '*') {
|
||||
// Wildcard matches everything
|
||||
return true;
|
||||
} else {
|
||||
// Exact match (shouldn't reach here as we check exact matches first)
|
||||
return domain === pattern;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles incoming HTTP requests
|
||||
* @param req The HTTP request
|
||||
* @param res The HTTP response
|
||||
*/
|
||||
private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader) {
|
||||
res.statusCode = 400;
|
||||
res.end('Bad Request: Host header is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract domain (ignoring any port in the Host header)
|
||||
const domain = hostHeader.split(':')[0];
|
||||
|
||||
// Dynamic provisioning: if domain not yet managed, register for ACME and return 503
|
||||
if (!this.domainCertificates.has(domain)) {
|
||||
try {
|
||||
this.addDomain({ domainName: domain, sslRedirect: false, acmeMaintenance: true });
|
||||
} catch (err) {
|
||||
console.error(`Error registering domain for on-demand provisioning: ${err}`);
|
||||
}
|
||||
res.statusCode = 503;
|
||||
res.end('Certificate issuance in progress');
|
||||
return;
|
||||
}
|
||||
// Get domain config, using glob pattern matching if needed
|
||||
const domainMatch = this.getDomainInfoForRequest(domain);
|
||||
if (!domainMatch) {
|
||||
res.statusCode = 404;
|
||||
res.end('Domain not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const { domainInfo, pattern } = domainMatch;
|
||||
const options = domainInfo.options;
|
||||
|
||||
// Handle ACME HTTP-01 challenge requests or forwarding
|
||||
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
|
||||
// Forward ACME requests if configured
|
||||
if (options.acmeForward) {
|
||||
this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
|
||||
return;
|
||||
}
|
||||
// If not managing ACME for this domain, return 404
|
||||
if (!options.acmeMaintenance) {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
// Delegate to Http01MemoryHandler
|
||||
if (this.smartAcmeHttp01Handler) {
|
||||
this.smartAcmeHttp01Handler.handleRequest(req, res);
|
||||
} else {
|
||||
res.statusCode = 500;
|
||||
res.end('ACME HTTP-01 handler not initialized');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we should forward non-ACME requests
|
||||
if (options.forward) {
|
||||
this.forwardRequest(req, res, options.forward, 'HTTP');
|
||||
return;
|
||||
}
|
||||
|
||||
// If certificate exists and sslRedirect is enabled, redirect to HTTPS
|
||||
// (Skip for glob patterns as they won't have certificates)
|
||||
if (!this.isGlobPattern(pattern) && domainInfo.certObtained && options.sslRedirect) {
|
||||
const httpsPort = this.options.httpsRedirectPort;
|
||||
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
|
||||
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
|
||||
|
||||
res.statusCode = 301;
|
||||
res.setHeader('Location', redirectUrl);
|
||||
res.end(`Redirecting to ${redirectUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle case where certificate maintenance is enabled but not yet obtained
|
||||
// (Skip for glob patterns as they can't have certificates)
|
||||
if (!this.isGlobPattern(pattern) && options.acmeMaintenance && !domainInfo.certObtained) {
|
||||
// Trigger certificate issuance if not already running
|
||||
if (!domainInfo.obtainingInProgress) {
|
||||
this.obtainCertificate(domain).catch(err => {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
|
||||
domain,
|
||||
error: errorMessage,
|
||||
isRenewal: false
|
||||
});
|
||||
console.error(`Error obtaining certificate for ${domain}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
res.statusCode = 503;
|
||||
res.end('Certificate issuance in progress, please try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Default response for unhandled request
|
||||
res.statusCode = 404;
|
||||
res.end('No handlers configured for this request');
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards an HTTP request to the specified target
|
||||
* @param req The original request
|
||||
* @param res The response object
|
||||
* @param target The forwarding target (IP and port)
|
||||
* @param requestType Type of request for logging
|
||||
*/
|
||||
private forwardRequest(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
target: IForwardConfig,
|
||||
requestType: string
|
||||
): void {
|
||||
const options = {
|
||||
hostname: target.ip,
|
||||
port: target.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers }
|
||||
};
|
||||
|
||||
const domain = req.headers.host?.split(':')[0] || 'unknown';
|
||||
console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`);
|
||||
|
||||
const proxyReq = plugins.http.request(options, (proxyRes) => {
|
||||
// Copy status code
|
||||
res.statusCode = proxyRes.statusCode || 500;
|
||||
|
||||
// Copy headers
|
||||
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
||||
if (value) res.setHeader(key, value);
|
||||
}
|
||||
|
||||
// Pipe response data
|
||||
proxyRes.pipe(res);
|
||||
|
||||
this.emit(Port80HandlerEvents.REQUEST_FORWARDED, {
|
||||
domain,
|
||||
requestType,
|
||||
target: `${target.ip}:${target.port}`,
|
||||
statusCode: proxyRes.statusCode
|
||||
});
|
||||
});
|
||||
|
||||
proxyReq.on('error', (error) => {
|
||||
console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 502;
|
||||
res.end(`Proxy error: ${error.message}`);
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Pipe original request to proxy request
|
||||
if (req.readable) {
|
||||
req.pipe(proxyReq);
|
||||
} else {
|
||||
proxyReq.end();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Obtains a certificate for a domain using ACME HTTP-01 challenge
|
||||
* @param domain The domain to obtain a certificate for
|
||||
* @param isRenewal Whether this is a renewal attempt
|
||||
*/
|
||||
/**
|
||||
* Obtains a certificate for a domain using SmartAcme HTTP-01 challenges
|
||||
* @param domain The domain to obtain a certificate for
|
||||
* @param isRenewal Whether this is a renewal attempt
|
||||
*/
|
||||
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
|
||||
if (this.isGlobPattern(domain)) {
|
||||
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
|
||||
}
|
||||
const domainInfo = this.domainCertificates.get(domain)!;
|
||||
if (!domainInfo.options.acmeMaintenance) {
|
||||
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
|
||||
return;
|
||||
}
|
||||
if (domainInfo.obtainingInProgress) {
|
||||
console.log(`Certificate issuance already in progress for ${domain}`);
|
||||
return;
|
||||
}
|
||||
if (!this.smartAcme) {
|
||||
throw new Port80HandlerError('SmartAcme is not initialized');
|
||||
}
|
||||
domainInfo.obtainingInProgress = true;
|
||||
domainInfo.lastRenewalAttempt = new Date();
|
||||
try {
|
||||
// Request certificate via SmartAcme
|
||||
const certObj = await this.smartAcme.getCertificateForDomain(domain);
|
||||
const certificate = certObj.publicKey;
|
||||
const privateKey = certObj.privateKey;
|
||||
const expiryDate = new Date(certObj.validUntil);
|
||||
domainInfo.certificate = certificate;
|
||||
domainInfo.privateKey = privateKey;
|
||||
domainInfo.certObtained = true;
|
||||
domainInfo.expiryDate = expiryDate;
|
||||
|
||||
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
||||
// Persistence moved to CertProvisioner
|
||||
const eventType = isRenewal
|
||||
? Port80HandlerEvents.CERTIFICATE_RENEWED
|
||||
: Port80HandlerEvents.CERTIFICATE_ISSUED;
|
||||
this.emitCertificateEvent(eventType, {
|
||||
domain,
|
||||
certificate,
|
||||
privateKey,
|
||||
expiryDate: expiryDate || this.getDefaultExpiryDate()
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || 'Unknown error';
|
||||
console.error(`Error during certificate issuance for ${domain}:`, error);
|
||||
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
|
||||
domain,
|
||||
error: errorMsg,
|
||||
isRenewal
|
||||
} as ICertificateFailure);
|
||||
throw new CertificateError(errorMsg, domain, isRenewal);
|
||||
} finally {
|
||||
domainInfo.obtainingInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extract expiry date from certificate using a more robust approach
|
||||
* @param certificate Certificate PEM string
|
||||
* @param domain Domain for logging
|
||||
* @returns Extracted expiry date or default
|
||||
*/
|
||||
private extractExpiryDateFromCertificate(certificate: string, domain: string): Date {
|
||||
try {
|
||||
// This is still using regex, but in a real implementation you would use
|
||||
// a library like node-forge or x509 to properly parse the certificate
|
||||
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
||||
if (matches && matches[1]) {
|
||||
const expiryDate = new Date(matches[1]);
|
||||
|
||||
// Validate that we got a valid date
|
||||
if (!isNaN(expiryDate.getTime())) {
|
||||
console.log(`Certificate for ${domain} will expire on ${expiryDate.toISOString()}`);
|
||||
return expiryDate;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`Could not extract valid expiry date from certificate for ${domain}, using default`);
|
||||
return this.getDefaultExpiryDate();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to extract expiry date from certificate for ${domain}, using default`);
|
||||
return this.getDefaultExpiryDate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a default expiry date (90 days from now)
|
||||
* @returns Default expiry date
|
||||
*/
|
||||
private getDefaultExpiryDate(): Date {
|
||||
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days default
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a certificate event with the certificate data
|
||||
* @param eventType The event type to emit
|
||||
* @param data The certificate data
|
||||
*/
|
||||
private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void {
|
||||
this.emit(eventType, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all domains and their certificate status
|
||||
* @returns Map of domains to certificate status
|
||||
*/
|
||||
public getDomainCertificateStatus(): Map<string, {
|
||||
certObtained: boolean;
|
||||
expiryDate?: Date;
|
||||
daysRemaining?: number;
|
||||
obtainingInProgress: boolean;
|
||||
lastRenewalAttempt?: Date;
|
||||
}> {
|
||||
const result = new Map<string, {
|
||||
certObtained: boolean;
|
||||
expiryDate?: Date;
|
||||
daysRemaining?: number;
|
||||
obtainingInProgress: boolean;
|
||||
lastRenewalAttempt?: Date;
|
||||
}>();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
||||
// Skip glob patterns
|
||||
if (this.isGlobPattern(domain)) continue;
|
||||
|
||||
const status: {
|
||||
certObtained: boolean;
|
||||
expiryDate?: Date;
|
||||
daysRemaining?: number;
|
||||
obtainingInProgress: boolean;
|
||||
lastRenewalAttempt?: Date;
|
||||
} = {
|
||||
certObtained: domainInfo.certObtained,
|
||||
expiryDate: domainInfo.expiryDate,
|
||||
obtainingInProgress: domainInfo.obtainingInProgress,
|
||||
lastRenewalAttempt: domainInfo.lastRenewalAttempt
|
||||
};
|
||||
|
||||
// Calculate days remaining if expiry date is available
|
||||
if (domainInfo.expiryDate) {
|
||||
const daysRemaining = Math.ceil(
|
||||
(domainInfo.expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
||||
);
|
||||
status.daysRemaining = daysRemaining;
|
||||
}
|
||||
|
||||
result.set(domain, status);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a certificate renewal for a specific domain.
|
||||
* @param domain The domain to renew.
|
||||
*/
|
||||
public async renewCertificate(domain: string): Promise<void> {
|
||||
if (!this.domainCertificates.has(domain)) {
|
||||
throw new Port80HandlerError(`Domain not managed: ${domain}`);
|
||||
}
|
||||
// Trigger renewal via ACME
|
||||
await this.obtainCertificate(domain, true);
|
||||
}
|
||||
}
|
||||
// Export it as the default export for backward compatibility
|
||||
export default Port80Handler;
|
8
ts/proxies/index.ts
Normal file
8
ts/proxies/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Proxy implementations module
|
||||
*/
|
||||
|
||||
// Export submodules
|
||||
export * from './smart-proxy/index.js';
|
||||
export * from './network-proxy/index.js';
|
||||
export * from './nftables-proxy/index.js';
|
3
ts/proxies/network-proxy/index.ts
Normal file
3
ts/proxies/network-proxy/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* NetworkProxy implementation
|
||||
*/
|
3
ts/proxies/network-proxy/models/index.ts
Normal file
3
ts/proxies/network-proxy/models/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* NetworkProxy models
|
||||
*/
|
3
ts/proxies/nftables-proxy/index.ts
Normal file
3
ts/proxies/nftables-proxy/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* NfTablesProxy implementation
|
||||
*/
|
3
ts/proxies/smart-proxy/index.ts
Normal file
3
ts/proxies/smart-proxy/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* SmartProxy implementation
|
||||
*/
|
3
ts/proxies/smart-proxy/models/index.ts
Normal file
3
ts/proxies/smart-proxy/models/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* SmartProxy models
|
||||
*/
|
@ -1,200 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IDomainConfig, ISmartProxyCertProvisionObject } from './classes.pp.interfaces.js';
|
||||
import { Port80Handler } from '../port80handler/classes.port80handler.js';
|
||||
import { Port80HandlerEvents } from '../common/types.js';
|
||||
import { subscribeToPort80Handler } from '../common/eventUtils.js';
|
||||
import type { ICertificateData } from '../common/types.js';
|
||||
import type { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
|
||||
|
||||
/**
|
||||
* CertProvisioner manages certificate provisioning and renewal workflows,
|
||||
* unifying static certificates and HTTP-01 challenges via Port80Handler.
|
||||
*/
|
||||
export class CertProvisioner extends plugins.EventEmitter {
|
||||
private domainConfigs: IDomainConfig[];
|
||||
private port80Handler: Port80Handler;
|
||||
private networkProxyBridge: NetworkProxyBridge;
|
||||
private certProvisionFunction?: (domain: string) => Promise<ISmartProxyCertProvisionObject>;
|
||||
private forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }>;
|
||||
private renewThresholdDays: number;
|
||||
private renewCheckIntervalHours: number;
|
||||
private autoRenew: boolean;
|
||||
private renewManager?: plugins.taskbuffer.TaskManager;
|
||||
// Track provisioning type per domain: 'http01' or 'static'
|
||||
private provisionMap: Map<string, 'http01' | 'static'>;
|
||||
|
||||
/**
|
||||
* @param domainConfigs Array of domain configuration objects
|
||||
* @param port80Handler HTTP-01 challenge handler instance
|
||||
* @param networkProxyBridge Bridge for applying external certificates
|
||||
* @param certProvider Optional callback returning a static cert or 'http01'
|
||||
* @param renewThresholdDays Days before expiry to trigger renewals
|
||||
* @param renewCheckIntervalHours Interval in hours to check for renewals
|
||||
* @param autoRenew Whether to automatically schedule renewals
|
||||
*/
|
||||
constructor(
|
||||
domainConfigs: IDomainConfig[],
|
||||
port80Handler: Port80Handler,
|
||||
networkProxyBridge: NetworkProxyBridge,
|
||||
certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>,
|
||||
renewThresholdDays: number = 30,
|
||||
renewCheckIntervalHours: number = 24,
|
||||
autoRenew: boolean = true,
|
||||
forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }> = []
|
||||
) {
|
||||
super();
|
||||
this.domainConfigs = domainConfigs;
|
||||
this.port80Handler = port80Handler;
|
||||
this.networkProxyBridge = networkProxyBridge;
|
||||
this.certProvisionFunction = certProvider;
|
||||
this.renewThresholdDays = renewThresholdDays;
|
||||
this.renewCheckIntervalHours = renewCheckIntervalHours;
|
||||
this.autoRenew = autoRenew;
|
||||
this.provisionMap = new Map();
|
||||
this.forwardConfigs = forwardConfigs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start initial provisioning and schedule renewals.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Subscribe to Port80Handler certificate events
|
||||
subscribeToPort80Handler(this.port80Handler, {
|
||||
onCertificateIssued: (data: ICertificateData) => {
|
||||
this.emit('certificate', { ...data, source: 'http01', isRenewal: false });
|
||||
},
|
||||
onCertificateRenewed: (data: ICertificateData) => {
|
||||
this.emit('certificate', { ...data, source: 'http01', isRenewal: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Apply external forwarding for ACME challenges (e.g. Synology)
|
||||
for (const f of this.forwardConfigs) {
|
||||
this.port80Handler.addDomain({
|
||||
domainName: f.domain,
|
||||
sslRedirect: f.sslRedirect,
|
||||
acmeMaintenance: false,
|
||||
forward: f.forwardConfig,
|
||||
acmeForward: f.acmeForwardConfig
|
||||
});
|
||||
}
|
||||
// Initial provisioning for all domains
|
||||
const domains = this.domainConfigs.flatMap(cfg => cfg.domains);
|
||||
for (const domain of domains) {
|
||||
const isWildcard = domain.includes('*');
|
||||
let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01';
|
||||
if (this.certProvisionFunction) {
|
||||
try {
|
||||
provision = await this.certProvisionFunction(domain);
|
||||
} catch (err) {
|
||||
console.error(`certProvider error for ${domain}:`, err);
|
||||
}
|
||||
} else if (isWildcard) {
|
||||
// No certProvider: cannot handle wildcard without DNS-01 support
|
||||
console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`);
|
||||
continue;
|
||||
}
|
||||
if (provision === 'http01') {
|
||||
if (isWildcard) {
|
||||
console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`);
|
||||
continue;
|
||||
}
|
||||
this.provisionMap.set(domain, 'http01');
|
||||
this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true });
|
||||
} else {
|
||||
// Static certificate (e.g., DNS-01 provisioned or user-provided) supports wildcard domains
|
||||
this.provisionMap.set(domain, 'static');
|
||||
const certObj = provision as plugins.tsclass.network.ICert;
|
||||
const certData: ICertificateData = {
|
||||
domain: certObj.domainName,
|
||||
certificate: certObj.publicKey,
|
||||
privateKey: certObj.privateKey,
|
||||
expiryDate: new Date(certObj.validUntil)
|
||||
};
|
||||
this.networkProxyBridge.applyExternalCertificate(certData);
|
||||
this.emit('certificate', { ...certData, source: 'static', isRenewal: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule renewals if enabled
|
||||
if (this.autoRenew) {
|
||||
this.renewManager = new plugins.taskbuffer.TaskManager();
|
||||
const renewTask = new plugins.taskbuffer.Task({
|
||||
name: 'CertificateRenewals',
|
||||
taskFunction: async () => {
|
||||
for (const [domain, type] of this.provisionMap.entries()) {
|
||||
// Skip wildcard domains
|
||||
if (domain.includes('*')) continue;
|
||||
try {
|
||||
if (type === 'http01') {
|
||||
await this.port80Handler.renewCertificate(domain);
|
||||
} else if (type === 'static' && this.certProvisionFunction) {
|
||||
const provision2 = await this.certProvisionFunction(domain);
|
||||
if (provision2 !== 'http01') {
|
||||
const certObj = provision2 as plugins.tsclass.network.ICert;
|
||||
const certData: ICertificateData = {
|
||||
domain: certObj.domainName,
|
||||
certificate: certObj.publicKey,
|
||||
privateKey: certObj.privateKey,
|
||||
expiryDate: new Date(certObj.validUntil)
|
||||
};
|
||||
this.networkProxyBridge.applyExternalCertificate(certData);
|
||||
this.emit('certificate', { ...certData, source: 'static', isRenewal: true });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Renewal error for ${domain}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const hours = this.renewCheckIntervalHours;
|
||||
const cronExpr = `0 0 */${hours} * * *`;
|
||||
this.renewManager.addAndScheduleTask(renewTask, cronExpr);
|
||||
this.renewManager.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all scheduled renewal tasks.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
// Stop scheduled renewals
|
||||
if (this.renewManager) {
|
||||
this.renewManager.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a certificate on-demand for the given domain.
|
||||
* @param domain Domain name to provision
|
||||
*/
|
||||
public async requestCertificate(domain: string): Promise<void> {
|
||||
const isWildcard = domain.includes('*');
|
||||
// Determine provisioning method
|
||||
let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01';
|
||||
if (this.certProvisionFunction) {
|
||||
provision = await this.certProvisionFunction(domain);
|
||||
} else if (isWildcard) {
|
||||
// Cannot perform HTTP-01 on wildcard without certProvider
|
||||
throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`);
|
||||
}
|
||||
if (provision === 'http01') {
|
||||
if (isWildcard) {
|
||||
throw new Error(`Cannot request HTTP-01 certificate for wildcard domain: ${domain}`);
|
||||
}
|
||||
await this.port80Handler.renewCertificate(domain);
|
||||
} else {
|
||||
// Static certificate (e.g., DNS-01 provisioned) supports wildcards
|
||||
const certObj = provision as plugins.tsclass.network.ICert;
|
||||
const certData: ICertificateData = {
|
||||
domain: certObj.domainName,
|
||||
certificate: certObj.publicKey,
|
||||
privateKey: certObj.privateKey,
|
||||
expiryDate: new Date(certObj.validUntil)
|
||||
};
|
||||
this.networkProxyBridge.applyExternalCertificate(certData);
|
||||
this.emit('certificate', { ...certData, source: 'static', isRenewal: false });
|
||||
}
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ import { NetworkProxy } from '../networkproxy/classes.np.networkproxy.js';
|
||||
import { Port80Handler } from '../port80handler/classes.port80handler.js';
|
||||
import { Port80HandlerEvents } from '../common/types.js';
|
||||
import { subscribeToPort80Handler } from '../common/eventUtils.js';
|
||||
import type { ICertificateData } from '../common/types.js';
|
||||
import type { CertificateData } from '../certificate/models/certificate-types.js';
|
||||
import type { IConnectionRecord, ISmartProxyOptions, IDomainConfig } from './classes.pp.interfaces.js';
|
||||
|
||||
/**
|
||||
@ -66,7 +66,7 @@ export class NetworkProxyBridge {
|
||||
/**
|
||||
* Handle certificate issuance or renewal events
|
||||
*/
|
||||
private handleCertificateEvent(data: ICertificateData): void {
|
||||
private handleCertificateEvent(data: CertificateData): void {
|
||||
if (!this.networkProxy) return;
|
||||
|
||||
console.log(`Received certificate for ${data.domain} from Port80Handler, updating NetworkProxy`);
|
||||
@ -99,7 +99,7 @@ export class NetworkProxyBridge {
|
||||
/**
|
||||
* Apply an external (static) certificate into NetworkProxy
|
||||
*/
|
||||
public applyExternalCertificate(data: ICertificateData): void {
|
||||
public applyExternalCertificate(data: CertificateData): void {
|
||||
if (!this.networkProxy) {
|
||||
console.log(`NetworkProxy not initialized: cannot apply external certificate for ${data.domain}`);
|
||||
return;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { ISmartProxyOptions } from './classes.pp.interfaces.js';
|
||||
import { SniHandler } from './classes.pp.snihandler.js';
|
||||
import { SniHandler } from '../tls/sni/sni-handler.js';
|
||||
|
||||
/**
|
||||
* Interface for connection information used for SNI extraction
|
||||
|
@ -9,9 +9,9 @@ import { TimeoutManager } from './classes.pp.timeoutmanager.js';
|
||||
import { PortRangeManager } from './classes.pp.portrangemanager.js';
|
||||
import { ConnectionHandler } from './classes.pp.connectionhandler.js';
|
||||
import { Port80Handler } from '../port80handler/classes.port80handler.js';
|
||||
import { CertProvisioner } from './classes.pp.certprovisioner.js';
|
||||
import type { ICertificateData } from '../common/types.js';
|
||||
import { buildPort80Handler } from '../common/acmeFactory.js';
|
||||
import { CertProvisioner } from '../certificate/providers/cert-provisioner.js';
|
||||
import type { CertificateData } from '../certificate/models/certificate-types.js';
|
||||
import { buildPort80Handler } from '../certificate/acme/acme-factory.js';
|
||||
import type { ForwardingType } from './types/forwarding.types.js';
|
||||
import { createPort80HandlerOptions } from '../common/port80-adapter.js';
|
||||
|
||||
@ -470,7 +470,7 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
} else {
|
||||
// Static certificate (e.g., DNS-01 provisioned) supports wildcards
|
||||
const certObj = provision as plugins.tsclass.network.ICert;
|
||||
const certData: ICertificateData = {
|
||||
const certData: CertificateData = {
|
||||
domain: certObj.domainName,
|
||||
certificate: certObj.publicKey,
|
||||
privateKey: certObj.privateKey,
|
||||
|
@ -48,5 +48,5 @@ export const helpers = {
|
||||
httpOnly,
|
||||
tlsTerminateToHttp,
|
||||
tlsTerminateToHttps,
|
||||
sniPassthrough: httpsPassthrough // Alias for backward compatibility
|
||||
httpsPassthrough
|
||||
};
|
3
ts/tls/alerts/index.ts
Normal file
3
ts/tls/alerts/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* TLS alerts
|
||||
*/
|
@ -1,52 +1,53 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { TlsAlertLevel, TlsAlertDescription, TlsVersion } from '../utils/tls-utils.js';
|
||||
|
||||
/**
|
||||
* TlsAlert class for managing TLS alert messages
|
||||
* TlsAlert class for creating and sending TLS alert messages
|
||||
*/
|
||||
export class TlsAlert {
|
||||
// TLS Alert Levels
|
||||
static readonly LEVEL_WARNING = 0x01;
|
||||
static readonly LEVEL_FATAL = 0x02;
|
||||
|
||||
// TLS Alert Description Codes - RFC 8446 (TLS 1.3) / RFC 5246 (TLS 1.2)
|
||||
static readonly CLOSE_NOTIFY = 0x00;
|
||||
static readonly UNEXPECTED_MESSAGE = 0x0A;
|
||||
static readonly BAD_RECORD_MAC = 0x14;
|
||||
static readonly DECRYPTION_FAILED = 0x15; // TLS 1.0 only
|
||||
static readonly RECORD_OVERFLOW = 0x16;
|
||||
static readonly DECOMPRESSION_FAILURE = 0x1E; // TLS 1.2 and below
|
||||
static readonly HANDSHAKE_FAILURE = 0x28;
|
||||
static readonly NO_CERTIFICATE = 0x29; // SSLv3 only
|
||||
static readonly BAD_CERTIFICATE = 0x2A;
|
||||
static readonly UNSUPPORTED_CERTIFICATE = 0x2B;
|
||||
static readonly CERTIFICATE_REVOKED = 0x2C;
|
||||
static readonly CERTIFICATE_EXPIRED = 0x2F;
|
||||
static readonly CERTIFICATE_UNKNOWN = 0x30;
|
||||
static readonly ILLEGAL_PARAMETER = 0x2F;
|
||||
static readonly UNKNOWN_CA = 0x30;
|
||||
static readonly ACCESS_DENIED = 0x31;
|
||||
static readonly DECODE_ERROR = 0x32;
|
||||
static readonly DECRYPT_ERROR = 0x33;
|
||||
static readonly EXPORT_RESTRICTION = 0x3C; // TLS 1.0 only
|
||||
static readonly PROTOCOL_VERSION = 0x46;
|
||||
static readonly INSUFFICIENT_SECURITY = 0x47;
|
||||
static readonly INTERNAL_ERROR = 0x50;
|
||||
static readonly INAPPROPRIATE_FALLBACK = 0x56;
|
||||
static readonly USER_CANCELED = 0x5A;
|
||||
static readonly NO_RENEGOTIATION = 0x64; // TLS 1.2 and below
|
||||
static readonly MISSING_EXTENSION = 0x6D; // TLS 1.3
|
||||
static readonly UNSUPPORTED_EXTENSION = 0x6E; // TLS 1.3
|
||||
static readonly CERTIFICATE_REQUIRED = 0x6F; // TLS 1.3
|
||||
static readonly UNRECOGNIZED_NAME = 0x70;
|
||||
static readonly BAD_CERTIFICATE_STATUS_RESPONSE = 0x71;
|
||||
static readonly BAD_CERTIFICATE_HASH_VALUE = 0x72; // TLS 1.2 and below
|
||||
static readonly UNKNOWN_PSK_IDENTITY = 0x73;
|
||||
static readonly CERTIFICATE_REQUIRED_1_3 = 0x74; // TLS 1.3
|
||||
static readonly NO_APPLICATION_PROTOCOL = 0x78;
|
||||
// Use enum values from TlsAlertLevel
|
||||
static readonly LEVEL_WARNING = TlsAlertLevel.WARNING;
|
||||
static readonly LEVEL_FATAL = TlsAlertLevel.FATAL;
|
||||
|
||||
// Use enum values from TlsAlertDescription
|
||||
static readonly CLOSE_NOTIFY = TlsAlertDescription.CLOSE_NOTIFY;
|
||||
static readonly UNEXPECTED_MESSAGE = TlsAlertDescription.UNEXPECTED_MESSAGE;
|
||||
static readonly BAD_RECORD_MAC = TlsAlertDescription.BAD_RECORD_MAC;
|
||||
static readonly DECRYPTION_FAILED = TlsAlertDescription.DECRYPTION_FAILED;
|
||||
static readonly RECORD_OVERFLOW = TlsAlertDescription.RECORD_OVERFLOW;
|
||||
static readonly DECOMPRESSION_FAILURE = TlsAlertDescription.DECOMPRESSION_FAILURE;
|
||||
static readonly HANDSHAKE_FAILURE = TlsAlertDescription.HANDSHAKE_FAILURE;
|
||||
static readonly NO_CERTIFICATE = TlsAlertDescription.NO_CERTIFICATE;
|
||||
static readonly BAD_CERTIFICATE = TlsAlertDescription.BAD_CERTIFICATE;
|
||||
static readonly UNSUPPORTED_CERTIFICATE = TlsAlertDescription.UNSUPPORTED_CERTIFICATE;
|
||||
static readonly CERTIFICATE_REVOKED = TlsAlertDescription.CERTIFICATE_REVOKED;
|
||||
static readonly CERTIFICATE_EXPIRED = TlsAlertDescription.CERTIFICATE_EXPIRED;
|
||||
static readonly CERTIFICATE_UNKNOWN = TlsAlertDescription.CERTIFICATE_UNKNOWN;
|
||||
static readonly ILLEGAL_PARAMETER = TlsAlertDescription.ILLEGAL_PARAMETER;
|
||||
static readonly UNKNOWN_CA = TlsAlertDescription.UNKNOWN_CA;
|
||||
static readonly ACCESS_DENIED = TlsAlertDescription.ACCESS_DENIED;
|
||||
static readonly DECODE_ERROR = TlsAlertDescription.DECODE_ERROR;
|
||||
static readonly DECRYPT_ERROR = TlsAlertDescription.DECRYPT_ERROR;
|
||||
static readonly EXPORT_RESTRICTION = TlsAlertDescription.EXPORT_RESTRICTION;
|
||||
static readonly PROTOCOL_VERSION = TlsAlertDescription.PROTOCOL_VERSION;
|
||||
static readonly INSUFFICIENT_SECURITY = TlsAlertDescription.INSUFFICIENT_SECURITY;
|
||||
static readonly INTERNAL_ERROR = TlsAlertDescription.INTERNAL_ERROR;
|
||||
static readonly INAPPROPRIATE_FALLBACK = TlsAlertDescription.INAPPROPRIATE_FALLBACK;
|
||||
static readonly USER_CANCELED = TlsAlertDescription.USER_CANCELED;
|
||||
static readonly NO_RENEGOTIATION = TlsAlertDescription.NO_RENEGOTIATION;
|
||||
static readonly MISSING_EXTENSION = TlsAlertDescription.MISSING_EXTENSION;
|
||||
static readonly UNSUPPORTED_EXTENSION = TlsAlertDescription.UNSUPPORTED_EXTENSION;
|
||||
static readonly CERTIFICATE_REQUIRED = TlsAlertDescription.CERTIFICATE_REQUIRED;
|
||||
static readonly UNRECOGNIZED_NAME = TlsAlertDescription.UNRECOGNIZED_NAME;
|
||||
static readonly BAD_CERTIFICATE_STATUS_RESPONSE = TlsAlertDescription.BAD_CERTIFICATE_STATUS_RESPONSE;
|
||||
static readonly BAD_CERTIFICATE_HASH_VALUE = TlsAlertDescription.BAD_CERTIFICATE_HASH_VALUE;
|
||||
static readonly UNKNOWN_PSK_IDENTITY = TlsAlertDescription.UNKNOWN_PSK_IDENTITY;
|
||||
static readonly CERTIFICATE_REQUIRED_1_3 = TlsAlertDescription.CERTIFICATE_REQUIRED_1_3;
|
||||
static readonly NO_APPLICATION_PROTOCOL = TlsAlertDescription.NO_APPLICATION_PROTOCOL;
|
||||
|
||||
/**
|
||||
* Create a TLS alert buffer with the specified level and description code
|
||||
*
|
||||
*
|
||||
* @param level Alert level (warning or fatal)
|
||||
* @param description Alert description code
|
||||
* @param tlsVersion TLS version bytes (default is TLS 1.2: 0x0303)
|
||||
@ -55,7 +56,7 @@ export class TlsAlert {
|
||||
static create(
|
||||
level: number,
|
||||
description: number,
|
||||
tlsVersion: [number, number] = [0x03, 0x03]
|
||||
tlsVersion: [number, number] = [TlsVersion.TLS1_2[0], TlsVersion.TLS1_2[1]]
|
||||
): Buffer {
|
||||
return Buffer.from([
|
||||
0x15, // Alert record type
|
33
ts/tls/index.ts
Normal file
33
ts/tls/index.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* TLS module providing SNI extraction, TLS alerts, and other TLS-related utilities
|
||||
*/
|
||||
|
||||
// Export TLS alert functionality
|
||||
export * from './alerts/tls-alert.js';
|
||||
|
||||
// Export SNI handling
|
||||
export * from './sni/sni-handler.js';
|
||||
export * from './sni/sni-extraction.js';
|
||||
export * from './sni/client-hello-parser.js';
|
||||
|
||||
// Export TLS utilities
|
||||
export * from './utils/tls-utils.js';
|
||||
|
||||
// Create a namespace for SNI utilities
|
||||
import { SniHandler } from './sni/sni-handler.js';
|
||||
import { SniExtraction } from './sni/sni-extraction.js';
|
||||
import { ClientHelloParser } from './sni/client-hello-parser.js';
|
||||
|
||||
// Export utility objects for convenience
|
||||
export const SNI = {
|
||||
// Main handler class (for backward compatibility)
|
||||
Handler: SniHandler,
|
||||
|
||||
// Utility classes
|
||||
Extraction: SniExtraction,
|
||||
Parser: ClientHelloParser,
|
||||
|
||||
// Convenience functions
|
||||
extractSNI: SniHandler.extractSNI,
|
||||
processTlsPacket: SniHandler.processTlsPacket,
|
||||
};
|
629
ts/tls/sni/client-hello-parser.ts
Normal file
629
ts/tls/sni/client-hello-parser.ts
Normal file
@ -0,0 +1,629 @@
|
||||
import { Buffer } from 'buffer';
|
||||
import {
|
||||
TlsRecordType,
|
||||
TlsHandshakeType,
|
||||
TlsExtensionType,
|
||||
TlsUtils
|
||||
} from '../utils/tls-utils.js';
|
||||
|
||||
/**
|
||||
* Interface for logging functions used by the parser
|
||||
*/
|
||||
export type LoggerFunction = (message: string) => void;
|
||||
|
||||
/**
|
||||
* Result of a session resumption check
|
||||
*/
|
||||
export interface SessionResumptionResult {
|
||||
isResumption: boolean;
|
||||
hasSNI: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about parsed TLS extensions
|
||||
*/
|
||||
export interface ExtensionInfo {
|
||||
type: number;
|
||||
length: number;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a ClientHello parse operation
|
||||
*/
|
||||
export interface ClientHelloParseResult {
|
||||
isValid: boolean;
|
||||
version?: [number, number];
|
||||
random?: Buffer;
|
||||
sessionId?: Buffer;
|
||||
hasSessionId: boolean;
|
||||
cipherSuites?: Buffer;
|
||||
compressionMethods?: Buffer;
|
||||
extensions: ExtensionInfo[];
|
||||
serverNameList?: string[];
|
||||
hasSessionTicket: boolean;
|
||||
hasPsk: boolean;
|
||||
hasEarlyData: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragment tracking information
|
||||
*/
|
||||
export interface FragmentTrackingInfo {
|
||||
buffer: Buffer;
|
||||
timestamp: number;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for parsing TLS ClientHello messages
|
||||
*/
|
||||
export class ClientHelloParser {
|
||||
// Buffer for handling fragmented ClientHello messages
|
||||
private static fragmentedBuffers: Map<string, FragmentTrackingInfo> = new Map();
|
||||
private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup
|
||||
|
||||
/**
|
||||
* Clean up expired fragments
|
||||
*/
|
||||
private static cleanupExpiredFragments(): void {
|
||||
const now = Date.now();
|
||||
for (const [connectionId, info] of this.fragmentedBuffers.entries()) {
|
||||
if (now - info.timestamp > this.fragmentTimeout) {
|
||||
this.fragmentedBuffers.delete(connectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles potential fragmented ClientHello messages by buffering and reassembling
|
||||
* TLS record fragments that might span multiple TCP packets.
|
||||
*
|
||||
* @param buffer The current buffer fragment
|
||||
* @param connectionId Unique identifier for the connection
|
||||
* @param logger Optional logging function
|
||||
* @returns A complete buffer if reassembly is successful, or undefined if more fragments are needed
|
||||
*/
|
||||
public static handleFragmentedClientHello(
|
||||
buffer: Buffer,
|
||||
connectionId: string,
|
||||
logger?: LoggerFunction
|
||||
): Buffer | undefined {
|
||||
const log = logger || (() => {});
|
||||
|
||||
// Periodically clean up expired fragments
|
||||
this.cleanupExpiredFragments();
|
||||
|
||||
// Check if we've seen this connection before
|
||||
if (!this.fragmentedBuffers.has(connectionId)) {
|
||||
// New connection, start with this buffer
|
||||
this.fragmentedBuffers.set(connectionId, {
|
||||
buffer,
|
||||
timestamp: Date.now(),
|
||||
connectionId
|
||||
});
|
||||
|
||||
// Evaluate if this buffer already contains a complete ClientHello
|
||||
try {
|
||||
if (buffer.length >= 5) {
|
||||
// Get the record length from TLS header
|
||||
const recordLength = (buffer[3] << 8) + buffer[4] + 5; // +5 for the TLS record header itself
|
||||
log(`Initial buffer size: ${buffer.length}, expected record length: ${recordLength}`);
|
||||
|
||||
// Check if this buffer already contains a complete TLS record
|
||||
if (buffer.length >= recordLength) {
|
||||
log(`Initial buffer contains complete ClientHello, length: ${buffer.length}`);
|
||||
return buffer;
|
||||
}
|
||||
} else {
|
||||
log(
|
||||
`Initial buffer too small (${buffer.length} bytes), needs at least 5 bytes for TLS header`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Error checking initial buffer completeness: ${e}`);
|
||||
}
|
||||
|
||||
log(`Started buffering connection ${connectionId}, initial size: ${buffer.length}`);
|
||||
return undefined; // Need more fragments
|
||||
} else {
|
||||
// Existing connection, append this buffer
|
||||
const existingInfo = this.fragmentedBuffers.get(connectionId)!;
|
||||
const newBuffer = Buffer.concat([existingInfo.buffer, buffer]);
|
||||
|
||||
// Update the buffer and timestamp
|
||||
this.fragmentedBuffers.set(connectionId, {
|
||||
...existingInfo,
|
||||
buffer: newBuffer,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
log(`Appended to buffer for ${connectionId}, new size: ${newBuffer.length}`);
|
||||
|
||||
// Check if we now have a complete ClientHello
|
||||
try {
|
||||
if (newBuffer.length >= 5) {
|
||||
// Get the record length from TLS header
|
||||
const recordLength = (newBuffer[3] << 8) + newBuffer[4] + 5; // +5 for the TLS record header itself
|
||||
log(
|
||||
`Reassembled buffer size: ${newBuffer.length}, expected record length: ${recordLength}`
|
||||
);
|
||||
|
||||
// Check if we have a complete TLS record now
|
||||
if (newBuffer.length >= recordLength) {
|
||||
log(
|
||||
`Assembled complete ClientHello, length: ${newBuffer.length}, needed: ${recordLength}`
|
||||
);
|
||||
|
||||
// Extract the complete TLS record (might be followed by more data)
|
||||
const completeRecord = newBuffer.slice(0, recordLength);
|
||||
|
||||
// Check if this record is indeed a ClientHello (type 1) at position 5
|
||||
if (
|
||||
completeRecord.length > 5 &&
|
||||
completeRecord[5] === TlsHandshakeType.CLIENT_HELLO
|
||||
) {
|
||||
log(`Verified record is a ClientHello handshake message`);
|
||||
|
||||
// Complete message received, remove from tracking
|
||||
this.fragmentedBuffers.delete(connectionId);
|
||||
return completeRecord;
|
||||
} else {
|
||||
log(`Record is complete but not a ClientHello handshake, continuing to buffer`);
|
||||
// This might be another TLS record type preceding the ClientHello
|
||||
|
||||
// Try checking for a ClientHello starting at the end of this record
|
||||
if (newBuffer.length > recordLength + 5) {
|
||||
const nextRecordType = newBuffer[recordLength];
|
||||
log(
|
||||
`Next record type: ${nextRecordType} (looking for ${TlsRecordType.HANDSHAKE})`
|
||||
);
|
||||
|
||||
if (nextRecordType === TlsRecordType.HANDSHAKE) {
|
||||
const handshakeType = newBuffer[recordLength + 5];
|
||||
log(
|
||||
`Next handshake type: ${handshakeType} (looking for ${TlsHandshakeType.CLIENT_HELLO})`
|
||||
);
|
||||
|
||||
if (handshakeType === TlsHandshakeType.CLIENT_HELLO) {
|
||||
// Found a ClientHello in the next record, return the entire buffer
|
||||
log(`Found ClientHello in subsequent record, returning full buffer`);
|
||||
this.fragmentedBuffers.delete(connectionId);
|
||||
return newBuffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Error checking reassembled buffer completeness: ${e}`);
|
||||
}
|
||||
|
||||
return undefined; // Still need more fragments
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a TLS ClientHello message and extracts all components
|
||||
*
|
||||
* @param buffer The buffer containing the ClientHello message
|
||||
* @param logger Optional logging function
|
||||
* @returns Parsed ClientHello or undefined if parsing failed
|
||||
*/
|
||||
public static parseClientHello(
|
||||
buffer: Buffer,
|
||||
logger?: LoggerFunction
|
||||
): ClientHelloParseResult {
|
||||
const log = logger || (() => {});
|
||||
const result: ClientHelloParseResult = {
|
||||
isValid: false,
|
||||
hasSessionId: false,
|
||||
extensions: [],
|
||||
hasSessionTicket: false,
|
||||
hasPsk: false,
|
||||
hasEarlyData: false
|
||||
};
|
||||
|
||||
try {
|
||||
// Check basic validity
|
||||
if (buffer.length < 5) {
|
||||
result.error = 'Buffer too small for TLS record header';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check record type (must be HANDSHAKE)
|
||||
if (buffer[0] !== TlsRecordType.HANDSHAKE) {
|
||||
result.error = `Not a TLS handshake record: ${buffer[0]}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get TLS version from record header
|
||||
const majorVersion = buffer[1];
|
||||
const minorVersion = buffer[2];
|
||||
result.version = [majorVersion, minorVersion];
|
||||
log(`TLS record version: ${majorVersion}.${minorVersion}`);
|
||||
|
||||
// Parse record length (bytes 3-4, big-endian)
|
||||
const recordLength = (buffer[3] << 8) + buffer[4];
|
||||
log(`Record length: ${recordLength}`);
|
||||
|
||||
// Validate record length against buffer size
|
||||
if (buffer.length < recordLength + 5) {
|
||||
result.error = 'Buffer smaller than expected record length';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Start of handshake message in the buffer
|
||||
let pos = 5;
|
||||
|
||||
// Check handshake type (must be CLIENT_HELLO)
|
||||
if (buffer[pos] !== TlsHandshakeType.CLIENT_HELLO) {
|
||||
result.error = `Not a ClientHello message: ${buffer[pos]}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Skip handshake type (1 byte)
|
||||
pos += 1;
|
||||
|
||||
// Parse handshake length (3 bytes, big-endian)
|
||||
const handshakeLength = (buffer[pos] << 16) + (buffer[pos + 1] << 8) + buffer[pos + 2];
|
||||
log(`Handshake length: ${handshakeLength}`);
|
||||
|
||||
// Skip handshake length (3 bytes)
|
||||
pos += 3;
|
||||
|
||||
// Check client version (2 bytes)
|
||||
const clientMajorVersion = buffer[pos];
|
||||
const clientMinorVersion = buffer[pos + 1];
|
||||
log(`Client version: ${clientMajorVersion}.${clientMinorVersion}`);
|
||||
|
||||
// Skip client version (2 bytes)
|
||||
pos += 2;
|
||||
|
||||
// Extract client random (32 bytes)
|
||||
if (pos + 32 > buffer.length) {
|
||||
result.error = 'Buffer too small for client random';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.random = buffer.slice(pos, pos + 32);
|
||||
log(`Client random: ${result.random.toString('hex')}`);
|
||||
|
||||
// Skip client random (32 bytes)
|
||||
pos += 32;
|
||||
|
||||
// Parse session ID
|
||||
if (pos + 1 > buffer.length) {
|
||||
result.error = 'Buffer too small for session ID length';
|
||||
return result;
|
||||
}
|
||||
|
||||
const sessionIdLength = buffer[pos];
|
||||
log(`Session ID length: ${sessionIdLength}`);
|
||||
pos += 1;
|
||||
|
||||
result.hasSessionId = sessionIdLength > 0;
|
||||
|
||||
if (sessionIdLength > 0) {
|
||||
if (pos + sessionIdLength > buffer.length) {
|
||||
result.error = 'Buffer too small for session ID';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.sessionId = buffer.slice(pos, pos + sessionIdLength);
|
||||
log(`Session ID: ${result.sessionId.toString('hex')}`);
|
||||
}
|
||||
|
||||
// Skip session ID
|
||||
pos += sessionIdLength;
|
||||
|
||||
// Check if we have enough bytes left for cipher suites
|
||||
if (pos + 2 > buffer.length) {
|
||||
result.error = 'Buffer too small for cipher suites length';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Parse cipher suites length (2 bytes, big-endian)
|
||||
const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
log(`Cipher suites length: ${cipherSuitesLength}`);
|
||||
pos += 2;
|
||||
|
||||
// Extract cipher suites
|
||||
if (pos + cipherSuitesLength > buffer.length) {
|
||||
result.error = 'Buffer too small for cipher suites';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.cipherSuites = buffer.slice(pos, pos + cipherSuitesLength);
|
||||
|
||||
// Skip cipher suites
|
||||
pos += cipherSuitesLength;
|
||||
|
||||
// Check if we have enough bytes left for compression methods
|
||||
if (pos + 1 > buffer.length) {
|
||||
result.error = 'Buffer too small for compression methods length';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Parse compression methods length (1 byte)
|
||||
const compressionMethodsLength = buffer[pos];
|
||||
log(`Compression methods length: ${compressionMethodsLength}`);
|
||||
pos += 1;
|
||||
|
||||
// Extract compression methods
|
||||
if (pos + compressionMethodsLength > buffer.length) {
|
||||
result.error = 'Buffer too small for compression methods';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.compressionMethods = buffer.slice(pos, pos + compressionMethodsLength);
|
||||
|
||||
// Skip compression methods
|
||||
pos += compressionMethodsLength;
|
||||
|
||||
// Check if we have enough bytes for extensions length
|
||||
if (pos + 2 > buffer.length) {
|
||||
// No extensions present - this is valid for older TLS versions
|
||||
result.isValid = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Parse extensions length (2 bytes, big-endian)
|
||||
const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
log(`Extensions length: ${extensionsLength}`);
|
||||
pos += 2;
|
||||
|
||||
// Extensions end position
|
||||
const extensionsEnd = pos + extensionsLength;
|
||||
|
||||
// Check if extensions length is valid
|
||||
if (extensionsEnd > buffer.length) {
|
||||
result.error = 'Extensions length exceeds buffer size';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Iterate through extensions
|
||||
const serverNames: string[] = [];
|
||||
|
||||
while (pos + 4 <= extensionsEnd) {
|
||||
// Parse extension type (2 bytes, big-endian)
|
||||
const extensionType = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
log(`Extension type: 0x${extensionType.toString(16).padStart(4, '0')}`);
|
||||
pos += 2;
|
||||
|
||||
// Parse extension length (2 bytes, big-endian)
|
||||
const extensionLength = (buffer[pos] << 8) + buffer[pos + 1];
|
||||
log(`Extension length: ${extensionLength}`);
|
||||
pos += 2;
|
||||
|
||||
// Extract extension data
|
||||
if (pos + extensionLength > extensionsEnd) {
|
||||
result.error = `Extension ${extensionType} data exceeds bounds`;
|
||||
return result;
|
||||
}
|
||||
|
||||
const extensionData = buffer.slice(pos, pos + extensionLength);
|
||||
|
||||
// Record all extensions
|
||||
result.extensions.push({
|
||||
type: extensionType,
|
||||
length: extensionLength,
|
||||
data: extensionData
|
||||
});
|
||||
|
||||
// Track specific extension types
|
||||
if (extensionType === TlsExtensionType.SERVER_NAME) {
|
||||
// Server Name Indication (SNI)
|
||||
this.parseServerNameExtension(extensionData, serverNames, logger);
|
||||
} else if (extensionType === TlsExtensionType.SESSION_TICKET) {
|
||||
// Session ticket
|
||||
result.hasSessionTicket = true;
|
||||
} else if (extensionType === TlsExtensionType.PRE_SHARED_KEY) {
|
||||
// TLS 1.3 PSK
|
||||
result.hasPsk = true;
|
||||
} else if (extensionType === TlsExtensionType.EARLY_DATA) {
|
||||
// TLS 1.3 Early Data (0-RTT)
|
||||
result.hasEarlyData = true;
|
||||
}
|
||||
|
||||
// Move to next extension
|
||||
pos += extensionLength;
|
||||
}
|
||||
|
||||
// Store any server names found
|
||||
if (serverNames.length > 0) {
|
||||
result.serverNameList = serverNames;
|
||||
}
|
||||
|
||||
// Mark as valid if we get here
|
||||
result.isValid = true;
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log(`Error parsing ClientHello: ${errorMessage}`);
|
||||
result.error = errorMessage;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the server name extension data and extracts hostnames
|
||||
*
|
||||
* @param data Extension data buffer
|
||||
* @param serverNames Array to populate with found server names
|
||||
* @param logger Optional logging function
|
||||
* @returns true if parsing succeeded
|
||||
*/
|
||||
private static parseServerNameExtension(
|
||||
data: Buffer,
|
||||
serverNames: string[],
|
||||
logger?: LoggerFunction
|
||||
): boolean {
|
||||
const log = logger || (() => {});
|
||||
|
||||
try {
|
||||
// Need at least 2 bytes for server name list length
|
||||
if (data.length < 2) {
|
||||
log('SNI extension too small for server name list length');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse server name list length (2 bytes)
|
||||
const listLength = (data[0] << 8) + data[1];
|
||||
|
||||
// Skip to first name entry
|
||||
let pos = 2;
|
||||
|
||||
// End of list
|
||||
const listEnd = pos + listLength;
|
||||
|
||||
// Validate length
|
||||
if (listEnd > data.length) {
|
||||
log('SNI server name list exceeds extension data');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process all name entries
|
||||
while (pos + 3 <= listEnd) {
|
||||
// Name type (1 byte)
|
||||
const nameType = data[pos];
|
||||
pos += 1;
|
||||
|
||||
// For hostname, type must be 0
|
||||
if (nameType !== 0) {
|
||||
// Skip this entry
|
||||
if (pos + 2 <= listEnd) {
|
||||
const nameLength = (data[pos] << 8) + data[pos + 1];
|
||||
pos += 2 + nameLength;
|
||||
continue;
|
||||
} else {
|
||||
log('Malformed SNI entry');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse hostname length (2 bytes)
|
||||
if (pos + 2 > listEnd) {
|
||||
log('SNI extension truncated');
|
||||
return false;
|
||||
}
|
||||
|
||||
const nameLength = (data[pos] << 8) + data[pos + 1];
|
||||
pos += 2;
|
||||
|
||||
// Extract hostname
|
||||
if (pos + nameLength > listEnd) {
|
||||
log('SNI hostname truncated');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the hostname as UTF-8
|
||||
try {
|
||||
const hostname = data.slice(pos, pos + nameLength).toString('utf8');
|
||||
log(`Found SNI hostname: ${hostname}`);
|
||||
serverNames.push(hostname);
|
||||
} catch (err) {
|
||||
log(`Error extracting hostname: ${err}`);
|
||||
}
|
||||
|
||||
// Move to next entry
|
||||
pos += nameLength;
|
||||
}
|
||||
|
||||
return serverNames.length > 0;
|
||||
} catch (error) {
|
||||
log(`Error parsing SNI extension: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a ClientHello contains session resumption indicators
|
||||
*
|
||||
* @param buffer The ClientHello buffer
|
||||
* @param logger Optional logging function
|
||||
* @returns Session resumption result
|
||||
*/
|
||||
public static hasSessionResumption(
|
||||
buffer: Buffer,
|
||||
logger?: LoggerFunction
|
||||
): SessionResumptionResult {
|
||||
const log = logger || (() => {});
|
||||
|
||||
if (!TlsUtils.isClientHello(buffer)) {
|
||||
return { isResumption: false, hasSNI: false };
|
||||
}
|
||||
|
||||
const parseResult = this.parseClientHello(buffer, logger);
|
||||
if (!parseResult.isValid) {
|
||||
log(`ClientHello parse failed: ${parseResult.error}`);
|
||||
return { isResumption: false, hasSNI: false };
|
||||
}
|
||||
|
||||
// Check resumption indicators
|
||||
const hasSessionId = parseResult.hasSessionId;
|
||||
const hasSessionTicket = parseResult.hasSessionTicket;
|
||||
const hasPsk = parseResult.hasPsk;
|
||||
const hasEarlyData = parseResult.hasEarlyData;
|
||||
|
||||
// Check for SNI
|
||||
const hasSNI = !!parseResult.serverNameList && parseResult.serverNameList.length > 0;
|
||||
|
||||
// Consider it a resumption if any resumption mechanism is present
|
||||
const isResumption = hasSessionTicket || hasPsk || hasEarlyData ||
|
||||
(hasSessionId && !hasPsk); // Legacy resumption
|
||||
|
||||
// Log details
|
||||
if (isResumption) {
|
||||
log(
|
||||
'Session resumption detected: ' +
|
||||
(hasSessionTicket ? 'session ticket, ' : '') +
|
||||
(hasPsk ? 'PSK, ' : '') +
|
||||
(hasEarlyData ? 'early data, ' : '') +
|
||||
(hasSessionId ? 'session ID' : '') +
|
||||
(hasSNI ? ', with SNI' : ', without SNI')
|
||||
);
|
||||
}
|
||||
|
||||
return { isResumption, hasSNI };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a ClientHello appears to be from a tab reactivation
|
||||
*
|
||||
* @param buffer The ClientHello buffer
|
||||
* @param logger Optional logging function
|
||||
* @returns true if it appears to be a tab reactivation
|
||||
*/
|
||||
public static isTabReactivationHandshake(
|
||||
buffer: Buffer,
|
||||
logger?: LoggerFunction
|
||||
): boolean {
|
||||
const log = logger || (() => {});
|
||||
|
||||
if (!TlsUtils.isClientHello(buffer)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the ClientHello
|
||||
const parseResult = this.parseClientHello(buffer, logger);
|
||||
if (!parseResult.isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tab reactivation pattern: session identifier + (ticket or PSK) but no SNI
|
||||
const hasSessionId = parseResult.hasSessionId;
|
||||
const hasSessionTicket = parseResult.hasSessionTicket;
|
||||
const hasPsk = parseResult.hasPsk;
|
||||
const hasSNI = !!parseResult.serverNameList && parseResult.serverNameList.length > 0;
|
||||
|
||||
if ((hasSessionId && (hasSessionTicket || hasPsk)) && !hasSNI) {
|
||||
log('Detected tab reactivation pattern: session resumption without SNI');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
3
ts/tls/sni/index.ts
Normal file
3
ts/tls/sni/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* SNI handling
|
||||
*/
|
353
ts/tls/sni/sni-extraction.ts
Normal file
353
ts/tls/sni/sni-extraction.ts
Normal file
@ -0,0 +1,353 @@
|
||||
import { Buffer } from 'buffer';
|
||||
import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js';
|
||||
import {
|
||||
ClientHelloParser,
|
||||
type LoggerFunction
|
||||
} from './client-hello-parser.js';
|
||||
|
||||
/**
|
||||
* Connection tracking information
|
||||
*/
|
||||
export interface ConnectionInfo {
|
||||
sourceIp: string;
|
||||
sourcePort: number;
|
||||
destIp: string;
|
||||
destPort: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilities for extracting SNI information from TLS handshakes
|
||||
*/
|
||||
export class SniExtraction {
|
||||
/**
|
||||
* Extracts the SNI (Server Name Indication) from a TLS ClientHello message.
|
||||
*
|
||||
* @param buffer The buffer containing the TLS ClientHello message
|
||||
* @param logger Optional logging function
|
||||
* @returns The extracted server name or undefined if not found
|
||||
*/
|
||||
public static extractSNI(buffer: Buffer, logger?: LoggerFunction): string | undefined {
|
||||
const log = logger || (() => {});
|
||||
|
||||
try {
|
||||
// Parse the ClientHello
|
||||
const parseResult = ClientHelloParser.parseClientHello(buffer, logger);
|
||||
if (!parseResult.isValid) {
|
||||
log(`Failed to parse ClientHello: ${parseResult.error}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if ServerName extension was found
|
||||
if (parseResult.serverNameList && parseResult.serverNameList.length > 0) {
|
||||
// Use the first hostname (most common case)
|
||||
const serverName = parseResult.serverNameList[0];
|
||||
log(`Found SNI: ${serverName}`);
|
||||
return serverName;
|
||||
}
|
||||
|
||||
log('No SNI extension found in ClientHello');
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
log(`Error extracting SNI: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to extract SNI from the PSK extension in a TLS 1.3 ClientHello.
|
||||
*
|
||||
* In TLS 1.3, when a client attempts to resume a session, it may include
|
||||
* the server name in the PSK identity hint rather than in the SNI extension.
|
||||
*
|
||||
* @param buffer The buffer containing the TLS ClientHello message
|
||||
* @param logger Optional logging function
|
||||
* @returns The extracted server name or undefined if not found
|
||||
*/
|
||||
public static extractSNIFromPSKExtension(
|
||||
buffer: Buffer,
|
||||
logger?: LoggerFunction
|
||||
): string | undefined {
|
||||
const log = logger || (() => {});
|
||||
|
||||
try {
|
||||
// Ensure this is a ClientHello
|
||||
if (!TlsUtils.isClientHello(buffer)) {
|
||||
log('Not a ClientHello message');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse the ClientHello to find PSK extension
|
||||
const parseResult = ClientHelloParser.parseClientHello(buffer, logger);
|
||||
if (!parseResult.isValid || !parseResult.extensions) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Find the PSK extension
|
||||
const pskExtension = parseResult.extensions.find(ext =>
|
||||
ext.type === TlsExtensionType.PRE_SHARED_KEY);
|
||||
|
||||
if (!pskExtension) {
|
||||
log('No PSK extension found');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse the PSK extension data
|
||||
const data = pskExtension.data;
|
||||
|
||||
// PSK extension structure:
|
||||
// 2 bytes: identities list length
|
||||
if (data.length < 2) return undefined;
|
||||
|
||||
const identitiesLength = (data[0] << 8) + data[1];
|
||||
let pos = 2;
|
||||
|
||||
// End of identities list
|
||||
const identitiesEnd = pos + identitiesLength;
|
||||
if (identitiesEnd > data.length) return undefined;
|
||||
|
||||
// Process each PSK identity
|
||||
while (pos + 2 <= identitiesEnd) {
|
||||
// Identity length (2 bytes)
|
||||
if (pos + 2 > identitiesEnd) break;
|
||||
|
||||
const identityLength = (data[pos] << 8) + data[pos + 1];
|
||||
pos += 2;
|
||||
|
||||
if (pos + identityLength > identitiesEnd) break;
|
||||
|
||||
// Try to extract hostname from identity
|
||||
// Chrome often embeds the hostname in the PSK identity
|
||||
// This is a heuristic as there's no standard format
|
||||
if (identityLength > 0) {
|
||||
const identity = data.slice(pos, pos + identityLength);
|
||||
|
||||
// Skip identity bytes
|
||||
pos += identityLength;
|
||||
|
||||
// Skip obfuscated ticket age (4 bytes)
|
||||
if (pos + 4 <= identitiesEnd) {
|
||||
pos += 4;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to parse the identity as UTF-8
|
||||
try {
|
||||
const identityStr = identity.toString('utf8');
|
||||
log(`PSK identity: ${identityStr}`);
|
||||
|
||||
// Check if the identity contains hostname hints
|
||||
// Chrome often embeds the hostname in a known format
|
||||
// Try to extract using common patterns
|
||||
|
||||
// Pattern 1: Look for domain name pattern
|
||||
const domainPattern =
|
||||
/([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?/i;
|
||||
const domainMatch = identityStr.match(domainPattern);
|
||||
if (domainMatch && domainMatch[0]) {
|
||||
log(`Found domain in PSK identity: ${domainMatch[0]}`);
|
||||
return domainMatch[0];
|
||||
}
|
||||
|
||||
// Pattern 2: Chrome sometimes uses a specific format with delimiters
|
||||
// This is a heuristic approach since the format isn't standardized
|
||||
const parts = identityStr.split('|');
|
||||
if (parts.length > 1) {
|
||||
for (const part of parts) {
|
||||
if (part.includes('.') && !part.includes('/')) {
|
||||
const possibleDomain = part.trim();
|
||||
if (/^[a-z0-9.-]+$/i.test(possibleDomain)) {
|
||||
log(`Found possible domain in PSK delimiter format: ${possibleDomain}`);
|
||||
return possibleDomain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log('Failed to parse PSK identity as UTF-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log('No hostname found in PSK extension');
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
log(`Error parsing PSK: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for SNI extraction with support for fragmented messages
|
||||
* and session resumption edge cases.
|
||||
*
|
||||
* @param buffer The buffer containing TLS data
|
||||
* @param connectionInfo Connection tracking information
|
||||
* @param logger Optional logging function
|
||||
* @param cachedSni Optional previously cached SNI value
|
||||
* @returns The extracted server name or undefined
|
||||
*/
|
||||
public static extractSNIWithResumptionSupport(
|
||||
buffer: Buffer,
|
||||
connectionInfo?: ConnectionInfo,
|
||||
logger?: LoggerFunction,
|
||||
cachedSni?: string
|
||||
): string | undefined {
|
||||
const log = logger || (() => {});
|
||||
|
||||
// Log buffer details for debugging
|
||||
if (logger) {
|
||||
log(`Buffer size: ${buffer.length} bytes`);
|
||||
log(`Buffer starts with: ${buffer.slice(0, Math.min(10, buffer.length)).toString('hex')}`);
|
||||
|
||||
if (buffer.length >= 5) {
|
||||
const recordType = buffer[0];
|
||||
const majorVersion = buffer[1];
|
||||
const minorVersion = buffer[2];
|
||||
const recordLength = (buffer[3] << 8) + buffer[4];
|
||||
|
||||
log(
|
||||
`TLS Record: type=${recordType}, version=${majorVersion}.${minorVersion}, length=${recordLength}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to handle fragmented packets
|
||||
let processBuffer = buffer;
|
||||
if (connectionInfo) {
|
||||
const connectionId = TlsUtils.createConnectionId(connectionInfo);
|
||||
const reassembledBuffer = ClientHelloParser.handleFragmentedClientHello(
|
||||
buffer,
|
||||
connectionId,
|
||||
logger
|
||||
);
|
||||
|
||||
if (!reassembledBuffer) {
|
||||
log(`Waiting for more fragments on connection ${connectionId}`);
|
||||
return undefined; // Need more fragments to complete ClientHello
|
||||
}
|
||||
|
||||
processBuffer = reassembledBuffer;
|
||||
log(`Using reassembled buffer of length ${processBuffer.length}`);
|
||||
}
|
||||
|
||||
// First try the standard SNI extraction
|
||||
const standardSni = this.extractSNI(processBuffer, logger);
|
||||
if (standardSni) {
|
||||
log(`Found standard SNI: ${standardSni}`);
|
||||
return standardSni;
|
||||
}
|
||||
|
||||
// Check for session resumption when standard SNI extraction fails
|
||||
if (TlsUtils.isClientHello(processBuffer)) {
|
||||
const resumptionInfo = ClientHelloParser.hasSessionResumption(processBuffer, logger);
|
||||
|
||||
if (resumptionInfo.isResumption) {
|
||||
log(`Detected session resumption in ClientHello without standard SNI`);
|
||||
|
||||
// Try to extract SNI from PSK extension
|
||||
const pskSni = this.extractSNIFromPSKExtension(processBuffer, logger);
|
||||
if (pskSni) {
|
||||
log(`Extracted SNI from PSK extension: ${pskSni}`);
|
||||
return pskSni;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If cached SNI was provided, use it for application data packets
|
||||
if (cachedSni && TlsUtils.isTlsApplicationData(buffer)) {
|
||||
log(`Using provided cached SNI for application data: ${cachedSni}`);
|
||||
return cachedSni;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified method for processing a TLS packet and extracting SNI.
|
||||
* Main entry point for SNI extraction that handles all edge cases.
|
||||
*
|
||||
* @param buffer The buffer containing TLS data
|
||||
* @param connectionInfo Connection tracking information
|
||||
* @param logger Optional logging function
|
||||
* @param cachedSni Optional previously cached SNI value
|
||||
* @returns The extracted server name or undefined
|
||||
*/
|
||||
public static processTlsPacket(
|
||||
buffer: Buffer,
|
||||
connectionInfo: ConnectionInfo,
|
||||
logger?: LoggerFunction,
|
||||
cachedSni?: string
|
||||
): string | undefined {
|
||||
const log = logger || (() => {});
|
||||
|
||||
// Add timestamp if not provided
|
||||
if (!connectionInfo.timestamp) {
|
||||
connectionInfo.timestamp = Date.now();
|
||||
}
|
||||
|
||||
// Check if this is a TLS handshake or application data
|
||||
if (!TlsUtils.isTlsHandshake(buffer) && !TlsUtils.isTlsApplicationData(buffer)) {
|
||||
log('Not a TLS handshake or application data packet');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Create connection ID for tracking
|
||||
const connectionId = TlsUtils.createConnectionId(connectionInfo);
|
||||
log(`Processing TLS packet for connection ${connectionId}, buffer length: ${buffer.length}`);
|
||||
|
||||
// Handle application data with cached SNI (for connection racing)
|
||||
if (TlsUtils.isTlsApplicationData(buffer)) {
|
||||
// If explicit cachedSni was provided, use it
|
||||
if (cachedSni) {
|
||||
log(`Using provided cached SNI for application data: ${cachedSni}`);
|
||||
return cachedSni;
|
||||
}
|
||||
|
||||
log('Application data packet without cached SNI, cannot determine hostname');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Enhanced session resumption detection
|
||||
if (TlsUtils.isClientHello(buffer)) {
|
||||
const resumptionInfo = ClientHelloParser.hasSessionResumption(buffer, logger);
|
||||
|
||||
if (resumptionInfo.isResumption) {
|
||||
log(`Session resumption detected in TLS packet`);
|
||||
|
||||
// Always try standard SNI extraction first
|
||||
const standardSni = this.extractSNI(buffer, logger);
|
||||
if (standardSni) {
|
||||
log(`Found standard SNI in session resumption: ${standardSni}`);
|
||||
return standardSni;
|
||||
}
|
||||
|
||||
// Enhanced session resumption SNI extraction
|
||||
// Try extracting from PSK identity
|
||||
const pskSni = this.extractSNIFromPSKExtension(buffer, logger);
|
||||
if (pskSni) {
|
||||
log(`Extracted SNI from PSK extension: ${pskSni}`);
|
||||
return pskSni;
|
||||
}
|
||||
|
||||
log(`Session resumption without extractable SNI`);
|
||||
}
|
||||
}
|
||||
|
||||
// For handshake messages, try the full extraction process
|
||||
const sni = this.extractSNIWithResumptionSupport(buffer, connectionInfo, logger);
|
||||
|
||||
if (sni) {
|
||||
log(`Successfully extracted SNI: ${sni}`);
|
||||
return sni;
|
||||
}
|
||||
|
||||
// If we couldn't extract an SNI, check if this is a valid ClientHello
|
||||
if (TlsUtils.isClientHello(buffer)) {
|
||||
log('Valid ClientHello detected, but no SNI extracted - might need more data');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
264
ts/tls/sni/sni-handler.ts
Normal file
264
ts/tls/sni/sni-handler.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { Buffer } from 'buffer';
|
||||
import {
|
||||
TlsRecordType,
|
||||
TlsHandshakeType,
|
||||
TlsExtensionType,
|
||||
TlsUtils
|
||||
} from '../utils/tls-utils.js';
|
||||
import {
|
||||
ClientHelloParser,
|
||||
type LoggerFunction
|
||||
} from './client-hello-parser.js';
|
||||
import {
|
||||
SniExtraction,
|
||||
type ConnectionInfo
|
||||
} from './sni-extraction.js';
|
||||
|
||||
/**
|
||||
* SNI (Server Name Indication) handler for TLS connections.
|
||||
* Provides robust extraction of SNI values from TLS ClientHello messages
|
||||
* with support for fragmented packets, TLS 1.3 resumption, Chrome-specific
|
||||
* connection behaviors, and tab hibernation/reactivation scenarios.
|
||||
*
|
||||
* This class retains the original API but leverages the new modular implementation
|
||||
* for better maintainability and testability.
|
||||
*/
|
||||
export class SniHandler {
|
||||
// Re-export constants for backward compatibility
|
||||
private static readonly TLS_HANDSHAKE_RECORD_TYPE = TlsRecordType.HANDSHAKE;
|
||||
private static readonly TLS_APPLICATION_DATA_TYPE = TlsRecordType.APPLICATION_DATA;
|
||||
private static readonly TLS_CLIENT_HELLO_HANDSHAKE_TYPE = TlsHandshakeType.CLIENT_HELLO;
|
||||
private static readonly TLS_SNI_EXTENSION_TYPE = TlsExtensionType.SERVER_NAME;
|
||||
private static readonly TLS_SESSION_TICKET_EXTENSION_TYPE = TlsExtensionType.SESSION_TICKET;
|
||||
private static readonly TLS_SNI_HOST_NAME_TYPE = 0; // NameType.HOST_NAME in RFC 6066
|
||||
private static readonly TLS_PSK_EXTENSION_TYPE = TlsExtensionType.PRE_SHARED_KEY;
|
||||
private static readonly TLS_PSK_KE_MODES_EXTENSION_TYPE = TlsExtensionType.PSK_KEY_EXCHANGE_MODES;
|
||||
private static readonly TLS_EARLY_DATA_EXTENSION_TYPE = TlsExtensionType.EARLY_DATA;
|
||||
|
||||
/**
|
||||
* Checks if a buffer contains a TLS handshake message (record type 22)
|
||||
* @param buffer - The buffer to check
|
||||
* @returns true if the buffer starts with a TLS handshake record type
|
||||
*/
|
||||
public static isTlsHandshake(buffer: Buffer): boolean {
|
||||
return TlsUtils.isTlsHandshake(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a buffer contains TLS application data (record type 23)
|
||||
* @param buffer - The buffer to check
|
||||
* @returns true if the buffer starts with a TLS application data record type
|
||||
*/
|
||||
public static isTlsApplicationData(buffer: Buffer): boolean {
|
||||
return TlsUtils.isTlsApplicationData(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a connection ID based on source/destination information
|
||||
* Used to track fragmented ClientHello messages across multiple packets
|
||||
*
|
||||
* @param connectionInfo - Object containing connection identifiers (IP/port)
|
||||
* @returns A string ID for the connection
|
||||
*/
|
||||
public static createConnectionId(connectionInfo: {
|
||||
sourceIp?: string;
|
||||
sourcePort?: number;
|
||||
destIp?: string;
|
||||
destPort?: number;
|
||||
}): string {
|
||||
return TlsUtils.createConnectionId(connectionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles potential fragmented ClientHello messages by buffering and reassembling
|
||||
* TLS record fragments that might span multiple TCP packets.
|
||||
*
|
||||
* @param buffer - The current buffer fragment
|
||||
* @param connectionId - Unique identifier for the connection
|
||||
* @param enableLogging - Whether to enable logging
|
||||
* @returns A complete buffer if reassembly is successful, or undefined if more fragments are needed
|
||||
*/
|
||||
public static handleFragmentedClientHello(
|
||||
buffer: Buffer,
|
||||
connectionId: string,
|
||||
enableLogging: boolean = false
|
||||
): Buffer | undefined {
|
||||
const logger = enableLogging ?
|
||||
(message: string) => console.log(`[SNI Fragment] ${message}`) :
|
||||
undefined;
|
||||
|
||||
return ClientHelloParser.handleFragmentedClientHello(buffer, connectionId, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a buffer contains a TLS ClientHello message
|
||||
* @param buffer - The buffer to check
|
||||
* @returns true if the buffer appears to be a ClientHello message
|
||||
*/
|
||||
public static isClientHello(buffer: Buffer): boolean {
|
||||
return TlsUtils.isClientHello(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a ClientHello message contains session resumption indicators
|
||||
* such as session tickets or PSK (Pre-Shared Key) extensions.
|
||||
*
|
||||
* @param buffer - The buffer containing a ClientHello message
|
||||
* @param enableLogging - Whether to enable logging
|
||||
* @returns Object containing details about session resumption and SNI presence
|
||||
*/
|
||||
public static hasSessionResumption(
|
||||
buffer: Buffer,
|
||||
enableLogging: boolean = false
|
||||
): { isResumption: boolean; hasSNI: boolean } {
|
||||
const logger = enableLogging ?
|
||||
(message: string) => console.log(`[Session Resumption] ${message}`) :
|
||||
undefined;
|
||||
|
||||
return ClientHelloParser.hasSessionResumption(buffer, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects characteristics of a tab reactivation TLS handshake
|
||||
* These often have specific patterns in Chrome and other browsers
|
||||
*
|
||||
* @param buffer - The buffer containing a ClientHello message
|
||||
* @param enableLogging - Whether to enable logging
|
||||
* @returns true if this appears to be a tab reactivation handshake
|
||||
*/
|
||||
public static isTabReactivationHandshake(
|
||||
buffer: Buffer,
|
||||
enableLogging: boolean = false
|
||||
): boolean {
|
||||
const logger = enableLogging ?
|
||||
(message: string) => console.log(`[Tab Reactivation] ${message}`) :
|
||||
undefined;
|
||||
|
||||
return ClientHelloParser.isTabReactivationHandshake(buffer, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the SNI (Server Name Indication) from a TLS ClientHello message.
|
||||
* Implements robust parsing with support for session resumption edge cases.
|
||||
*
|
||||
* @param buffer - The buffer containing the TLS ClientHello message
|
||||
* @param enableLogging - Whether to enable detailed debug logging
|
||||
* @returns The extracted server name or undefined if not found
|
||||
*/
|
||||
public static extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined {
|
||||
const logger = enableLogging ?
|
||||
(message: string) => console.log(`[SNI Extraction] ${message}`) :
|
||||
undefined;
|
||||
|
||||
return SniExtraction.extractSNI(buffer, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to extract SNI from the PSK extension in a TLS 1.3 ClientHello.
|
||||
*
|
||||
* In TLS 1.3, when a client attempts to resume a session, it may include
|
||||
* the server name in the PSK identity hint rather than in the SNI extension.
|
||||
*
|
||||
* @param buffer - The buffer containing the TLS ClientHello message
|
||||
* @param enableLogging - Whether to enable detailed debug logging
|
||||
* @returns The extracted server name or undefined if not found
|
||||
*/
|
||||
public static extractSNIFromPSKExtension(
|
||||
buffer: Buffer,
|
||||
enableLogging: boolean = false
|
||||
): string | undefined {
|
||||
const logger = enableLogging ?
|
||||
(message: string) => console.log(`[PSK-SNI Extraction] ${message}`) :
|
||||
undefined;
|
||||
|
||||
return SniExtraction.extractSNIFromPSKExtension(buffer, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the buffer contains TLS 1.3 early data (0-RTT)
|
||||
* @param buffer - The buffer to check
|
||||
* @param enableLogging - Whether to enable logging
|
||||
* @returns true if early data is detected
|
||||
*/
|
||||
public static hasEarlyData(buffer: Buffer, enableLogging: boolean = false): boolean {
|
||||
// This functionality has been moved to ClientHelloParser
|
||||
// We can implement it in terms of the parse result if needed
|
||||
const logger = enableLogging ?
|
||||
(message: string) => console.log(`[Early Data] ${message}`) :
|
||||
undefined;
|
||||
|
||||
const parseResult = ClientHelloParser.parseClientHello(buffer, logger);
|
||||
return parseResult.isValid && parseResult.hasEarlyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to extract SNI from an initial ClientHello packet and handles
|
||||
* session resumption edge cases more robustly than the standard extraction.
|
||||
*
|
||||
* This method handles:
|
||||
* 1. Standard SNI extraction
|
||||
* 2. TLS 1.3 PSK-based resumption (Chrome, Firefox, etc.)
|
||||
* 3. Session ticket-based resumption
|
||||
* 4. Fragmented ClientHello messages
|
||||
* 5. TLS 1.3 Early Data (0-RTT)
|
||||
* 6. Chrome's connection racing behaviors
|
||||
*
|
||||
* @param buffer - The buffer containing the TLS ClientHello message
|
||||
* @param connectionInfo - Optional connection information for fragment handling
|
||||
* @param enableLogging - Whether to enable detailed debug logging
|
||||
* @returns The extracted server name or undefined if not found or more data needed
|
||||
*/
|
||||
public static extractSNIWithResumptionSupport(
|
||||
buffer: Buffer,
|
||||
connectionInfo?: {
|
||||
sourceIp?: string;
|
||||
sourcePort?: number;
|
||||
destIp?: string;
|
||||
destPort?: number;
|
||||
},
|
||||
enableLogging: boolean = false
|
||||
): string | undefined {
|
||||
const logger = enableLogging ?
|
||||
(message: string) => console.log(`[SNI Extraction] ${message}`) :
|
||||
undefined;
|
||||
|
||||
return SniExtraction.extractSNIWithResumptionSupport(
|
||||
buffer,
|
||||
connectionInfo as ConnectionInfo,
|
||||
logger
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for SNI extraction that handles all edge cases.
|
||||
* This should be called for each TLS packet received from a client.
|
||||
*
|
||||
* The method uses connection tracking to handle fragmented ClientHello
|
||||
* messages and various TLS 1.3 behaviors, including Chrome's connection
|
||||
* racing patterns and tab reactivation behaviors.
|
||||
*
|
||||
* @param buffer - The buffer containing TLS data
|
||||
* @param connectionInfo - Connection metadata (IPs and ports)
|
||||
* @param enableLogging - Whether to enable detailed debug logging
|
||||
* @param cachedSni - Optional cached SNI from previous connections (for racing detection)
|
||||
* @returns The extracted server name or undefined if not found or more data needed
|
||||
*/
|
||||
public static processTlsPacket(
|
||||
buffer: Buffer,
|
||||
connectionInfo: {
|
||||
sourceIp: string;
|
||||
sourcePort: number;
|
||||
destIp: string;
|
||||
destPort: number;
|
||||
timestamp?: number;
|
||||
},
|
||||
enableLogging: boolean = false,
|
||||
cachedSni?: string
|
||||
): string | undefined {
|
||||
const logger = enableLogging ?
|
||||
(message: string) => console.log(`[TLS Packet] ${message}`) :
|
||||
undefined;
|
||||
|
||||
return SniExtraction.processTlsPacket(buffer, connectionInfo, logger, cachedSni);
|
||||
}
|
||||
}
|
3
ts/tls/utils/index.ts
Normal file
3
ts/tls/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/**
|
||||
* TLS utilities
|
||||
*/
|
201
ts/tls/utils/tls-utils.ts
Normal file
201
ts/tls/utils/tls-utils.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
|
||||
/**
|
||||
* TLS record types as defined in various RFCs
|
||||
*/
|
||||
export enum TlsRecordType {
|
||||
CHANGE_CIPHER_SPEC = 20,
|
||||
ALERT = 21,
|
||||
HANDSHAKE = 22,
|
||||
APPLICATION_DATA = 23,
|
||||
HEARTBEAT = 24, // RFC 6520
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS handshake message types
|
||||
*/
|
||||
export enum TlsHandshakeType {
|
||||
HELLO_REQUEST = 0,
|
||||
CLIENT_HELLO = 1,
|
||||
SERVER_HELLO = 2,
|
||||
NEW_SESSION_TICKET = 4,
|
||||
ENCRYPTED_EXTENSIONS = 8, // TLS 1.3
|
||||
CERTIFICATE = 11,
|
||||
SERVER_KEY_EXCHANGE = 12,
|
||||
CERTIFICATE_REQUEST = 13,
|
||||
SERVER_HELLO_DONE = 14,
|
||||
CERTIFICATE_VERIFY = 15,
|
||||
CLIENT_KEY_EXCHANGE = 16,
|
||||
FINISHED = 20,
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS extension types
|
||||
*/
|
||||
export enum TlsExtensionType {
|
||||
SERVER_NAME = 0, // SNI
|
||||
MAX_FRAGMENT_LENGTH = 1,
|
||||
CLIENT_CERTIFICATE_URL = 2,
|
||||
TRUSTED_CA_KEYS = 3,
|
||||
TRUNCATED_HMAC = 4,
|
||||
STATUS_REQUEST = 5, // OCSP
|
||||
SUPPORTED_GROUPS = 10, // Previously named "elliptic_curves"
|
||||
EC_POINT_FORMATS = 11,
|
||||
SIGNATURE_ALGORITHMS = 13,
|
||||
APPLICATION_LAYER_PROTOCOL_NEGOTIATION = 16, // ALPN
|
||||
SIGNED_CERTIFICATE_TIMESTAMP = 18, // Certificate Transparency
|
||||
PADDING = 21,
|
||||
SESSION_TICKET = 35,
|
||||
PRE_SHARED_KEY = 41, // TLS 1.3
|
||||
EARLY_DATA = 42, // TLS 1.3 0-RTT
|
||||
SUPPORTED_VERSIONS = 43, // TLS 1.3
|
||||
COOKIE = 44, // TLS 1.3
|
||||
PSK_KEY_EXCHANGE_MODES = 45, // TLS 1.3
|
||||
CERTIFICATE_AUTHORITIES = 47, // TLS 1.3
|
||||
POST_HANDSHAKE_AUTH = 49, // TLS 1.3
|
||||
SIGNATURE_ALGORITHMS_CERT = 50, // TLS 1.3
|
||||
KEY_SHARE = 51, // TLS 1.3
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS alert levels
|
||||
*/
|
||||
export enum TlsAlertLevel {
|
||||
WARNING = 1,
|
||||
FATAL = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS alert description codes
|
||||
*/
|
||||
export enum TlsAlertDescription {
|
||||
CLOSE_NOTIFY = 0,
|
||||
UNEXPECTED_MESSAGE = 10,
|
||||
BAD_RECORD_MAC = 20,
|
||||
DECRYPTION_FAILED = 21, // TLS 1.0 only
|
||||
RECORD_OVERFLOW = 22,
|
||||
DECOMPRESSION_FAILURE = 30, // TLS 1.2 and below
|
||||
HANDSHAKE_FAILURE = 40,
|
||||
NO_CERTIFICATE = 41, // SSLv3 only
|
||||
BAD_CERTIFICATE = 42,
|
||||
UNSUPPORTED_CERTIFICATE = 43,
|
||||
CERTIFICATE_REVOKED = 44,
|
||||
CERTIFICATE_EXPIRED = 45,
|
||||
CERTIFICATE_UNKNOWN = 46,
|
||||
ILLEGAL_PARAMETER = 47,
|
||||
UNKNOWN_CA = 48,
|
||||
ACCESS_DENIED = 49,
|
||||
DECODE_ERROR = 50,
|
||||
DECRYPT_ERROR = 51,
|
||||
EXPORT_RESTRICTION = 60, // TLS 1.0 only
|
||||
PROTOCOL_VERSION = 70,
|
||||
INSUFFICIENT_SECURITY = 71,
|
||||
INTERNAL_ERROR = 80,
|
||||
INAPPROPRIATE_FALLBACK = 86,
|
||||
USER_CANCELED = 90,
|
||||
NO_RENEGOTIATION = 100, // TLS 1.2 and below
|
||||
MISSING_EXTENSION = 109, // TLS 1.3
|
||||
UNSUPPORTED_EXTENSION = 110, // TLS 1.3
|
||||
CERTIFICATE_REQUIRED = 111, // TLS 1.3
|
||||
UNRECOGNIZED_NAME = 112,
|
||||
BAD_CERTIFICATE_STATUS_RESPONSE = 113,
|
||||
BAD_CERTIFICATE_HASH_VALUE = 114, // TLS 1.2 and below
|
||||
UNKNOWN_PSK_IDENTITY = 115,
|
||||
CERTIFICATE_REQUIRED_1_3 = 116, // TLS 1.3
|
||||
NO_APPLICATION_PROTOCOL = 120,
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS version codes (major.minor)
|
||||
*/
|
||||
export const TlsVersion = {
|
||||
SSL3: [0x03, 0x00],
|
||||
TLS1_0: [0x03, 0x01],
|
||||
TLS1_1: [0x03, 0x02],
|
||||
TLS1_2: [0x03, 0x03],
|
||||
TLS1_3: [0x03, 0x04],
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility functions for TLS protocol operations
|
||||
*/
|
||||
export class TlsUtils {
|
||||
/**
|
||||
* Checks if a buffer contains a TLS handshake record
|
||||
* @param buffer The buffer to check
|
||||
* @returns true if the buffer starts with a TLS handshake record
|
||||
*/
|
||||
public static isTlsHandshake(buffer: Buffer): boolean {
|
||||
return buffer.length > 0 && buffer[0] === TlsRecordType.HANDSHAKE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a buffer contains TLS application data
|
||||
* @param buffer The buffer to check
|
||||
* @returns true if the buffer starts with a TLS application data record
|
||||
*/
|
||||
public static isTlsApplicationData(buffer: Buffer): boolean {
|
||||
return buffer.length > 0 && buffer[0] === TlsRecordType.APPLICATION_DATA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a buffer contains a TLS alert record
|
||||
* @param buffer The buffer to check
|
||||
* @returns true if the buffer starts with a TLS alert record
|
||||
*/
|
||||
public static isTlsAlert(buffer: Buffer): boolean {
|
||||
return buffer.length > 0 && buffer[0] === TlsRecordType.ALERT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a buffer contains a TLS ClientHello message
|
||||
* @param buffer The buffer to check
|
||||
* @returns true if the buffer appears to be a ClientHello message
|
||||
*/
|
||||
public static isClientHello(buffer: Buffer): boolean {
|
||||
// Minimum ClientHello size (TLS record header + handshake header)
|
||||
if (buffer.length < 9) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check record type (must be TLS_HANDSHAKE_RECORD_TYPE)
|
||||
if (buffer[0] !== TlsRecordType.HANDSHAKE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip version and length in TLS record header (5 bytes total)
|
||||
// Check handshake type at byte 5 (must be CLIENT_HELLO)
|
||||
return buffer[5] === TlsHandshakeType.CLIENT_HELLO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the record length from a TLS record header
|
||||
* @param buffer Buffer containing a TLS record
|
||||
* @returns The record length if the buffer is valid, -1 otherwise
|
||||
*/
|
||||
public static getTlsRecordLength(buffer: Buffer): number {
|
||||
if (buffer.length < 5) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Bytes 3-4 contain the record length (big-endian)
|
||||
return (buffer[3] << 8) + buffer[4];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a connection ID based on source/destination information
|
||||
* Used to track fragmented ClientHello messages across multiple packets
|
||||
*
|
||||
* @param connectionInfo Object containing connection identifiers
|
||||
* @returns A string ID for the connection
|
||||
*/
|
||||
public static createConnectionId(connectionInfo: {
|
||||
sourceIp?: string;
|
||||
sourcePort?: number;
|
||||
destIp?: string;
|
||||
destPort?: number;
|
||||
}): string {
|
||||
const { sourceIp, sourcePort, destIp, destPort } = connectionInfo;
|
||||
return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user