update structure

This commit is contained in:
Philipp Kunz 2025-05-09 17:00:27 +00:00
parent 4a72d9f3bf
commit f1c0b8bfb7
60 changed files with 4919 additions and 1400 deletions

View File

@ -68,70 +68,73 @@ The codebase employs several strong design patterns:
### Phase 1: Project Setup & Core Structure (Week 1) ### Phase 1: Project Setup & Core Structure (Week 1)
- [ ] Create new directory structure - [x] Create new directory structure
- [ ] Create core subdirectories within `ts` directory - [x] Create core subdirectories within `ts` directory
- [ ] Set up barrel files (`index.ts`) in each directory - [x] Set up barrel files (`index.ts`) in each directory
- [ ] Migrate core utilities - [x] Migrate core utilities
- [ ] Keep `ts/plugins.ts` in its current location per project requirements - [x] Keep `ts/plugins.ts` in its current location per project requirements
- [ ] Move `ts/common/types.ts``ts/core/models/common-types.ts` - [x] Move `ts/common/types.ts``ts/core/models/common-types.ts`
- [ ] Move `ts/common/eventUtils.ts``ts/core/utils/event-utils.ts` - [x] Move `ts/common/eventUtils.ts``ts/core/utils/event-utils.ts`
- [ ] Extract `ValidationUtils``ts/core/utils/validation-utils.ts` - [x] Extract `ValidationUtils``ts/core/utils/validation-utils.ts`
- [ ] Extract `IpUtils``ts/core/utils/ip-utils.ts` - [x] Extract `IpUtils``ts/core/utils/ip-utils.ts`
- [ ] Update build and test scripts - [x] Update build and test scripts
- [ ] Modify `package.json` build script for new structure - [x] Modify `package.json` build script for new structure
- [ ] Create parallel test structure - [x] Create parallel test structure
### Phase 2: Forwarding System Migration (Weeks 1-2) ### Phase 2: Forwarding System Migration (Weeks 1-2)
This component has the cleanest design, so we'll start migration here: This component has the cleanest design, so we'll start migration here:
- [ ] Migrate forwarding types and interfaces - [x] Migrate forwarding types and interfaces
- [ ] Move `ts/smartproxy/types/forwarding.types.ts``ts/forwarding/config/forwarding-types.ts` - [x] Move `ts/smartproxy/types/forwarding.types.ts``ts/forwarding/config/forwarding-types.ts`
- [ ] Normalize interface names (remove 'I' prefix where appropriate) - [x] Normalize interface names (remove 'I' prefix where appropriate)
- [ ] Migrate domain configuration - [x] Migrate domain configuration
- [ ] Move `ts/smartproxy/forwarding/domain-config.ts``ts/forwarding/config/domain-config.ts` - [x] Move `ts/smartproxy/forwarding/domain-config.ts``ts/forwarding/config/domain-config.ts`
- [ ] Move `ts/smartproxy/forwarding/domain-manager.ts``ts/forwarding/config/domain-manager.ts` - [x] Move `ts/smartproxy/forwarding/domain-manager.ts``ts/forwarding/config/domain-manager.ts`
- [ ] Migrate handler implementations - [ ] Migrate handler implementations
- [ ] Move base handler: `forwarding.handler.ts``ts/forwarding/handlers/base-handler.ts` - [x] Move base handler: `forwarding.handler.ts``ts/forwarding/handlers/base-handler.ts`
- [ ] Move HTTP handler: `http.handler.ts``ts/forwarding/handlers/http-handler.ts` - [x] Move HTTP handler: `http.handler.ts``ts/forwarding/handlers/http-handler.ts`
- [ ] Move passthrough handler: `https-passthrough.handler.ts``ts/forwarding/handlers/https-passthrough-handler.ts` - [x] Move passthrough handler: `https-passthrough.handler.ts``ts/forwarding/handlers/https-passthrough-handler.ts`
- [ ] Move TLS termination handlers to respective files in `ts/forwarding/handlers/` - [x] Move TLS termination handlers to respective files in `ts/forwarding/handlers/`
- [ ] Move factory: `forwarding.factory.ts``ts/forwarding/factory/forwarding-factory.ts` - [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`
- [ ] Create proper forwarding system exports - [x] Create proper forwarding system exports
- [ ] Update all imports in forwarding components using relative paths - [x] Update all imports in forwarding components using relative paths
- [ ] Create comprehensive barrel file in `ts/forwarding/index.ts` - [x] Create comprehensive barrel file in `ts/forwarding/index.ts`
- [ ] Test forwarding system in isolation - [x] Test forwarding system in isolation
### Phase 3: Certificate Management Migration (Week 2) ### Phase 3: Certificate Management Migration (Week 2)
- [ ] Create certificate management structure - [x] Create certificate management structure
- [ ] Create `ts/certificate/models/certificate-types.ts` for interfaces - [x] Create `ts/certificate/models/certificate-types.ts` for interfaces
- [ ] Extract certificate events to `ts/certificate/events/` - [x] Extract certificate events to `ts/certificate/events/certificate-events.ts`
- [ ] Migrate certificate providers - [x] Migrate certificate providers
- [ ] Move `ts/classes.pp.certprovisioner.ts` → `ts/certificate/providers/cert-provisioner.ts` - [x] Move `ts/smartproxy/classes.pp.certprovisioner.ts` → `ts/certificate/providers/cert-provisioner.ts`
- [ ] Move `ts/common/acmeFactory.ts``ts/certificate/acme/acme-factory.ts` - [x] Move `ts/common/acmeFactory.ts``ts/certificate/acme/acme-factory.ts`
- [ ] Extract ACME challenge handling to `ts/certificate/acme/challenge-handler.ts` - [x] Extract ACME challenge handling to `ts/certificate/acme/challenge-handler.ts`
- [ ] Update certificate utilities - [x] Update certificate utilities
- [ ] Move `ts/helpers.certificates.ts``ts/certificate/utils/certificate-helpers.ts` - [x] Move `ts/helpers.certificates.ts``ts/certificate/utils/certificate-helpers.ts`
- [ ] Create proper exports in `ts/certificate/index.ts` - [x] Create certificate storage in `ts/certificate/storage/file-storage.ts`
- [x] Create proper exports in `ts/certificate/index.ts`
### Phase 4: TLS & SNI Handling Migration (Week 2-3) ### Phase 4: TLS & SNI Handling Migration (Week 2-3)
- [ ] Migrate TLS alert system - [x] Migrate TLS alert system
- [ ] Move `ts/smartproxy/classes.pp.tlsalert.ts``ts/tls/alerts/tls-alert.ts` - [x] Move `ts/smartproxy/classes.pp.tlsalert.ts``ts/tls/alerts/tls-alert.ts`
- [ ] Extract common TLS utilities to `ts/tls/utils/tls-utils.ts` - [x] Extract common TLS utilities to `ts/tls/utils/tls-utils.ts`
- [ ] Migrate SNI handling - [x] Migrate SNI handling
- [ ] Move `ts/smartproxy/classes.pp.snihandler.ts``ts/tls/sni/sni-handler.ts` - [x] Move `ts/smartproxy/classes.pp.snihandler.ts``ts/tls/sni/sni-handler.ts`
- [ ] Extract SNI extraction to `ts/tls/sni/sni-extraction.ts` - [x] Extract SNI extraction to `ts/tls/sni/sni-extraction.ts`
- [ ] Extract ClientHello parsing to `ts/tls/sni/client-hello-parser.ts` - [x] Extract ClientHello parsing to `ts/tls/sni/client-hello-parser.ts`
### Phase 5: HTTP Component Migration (Week 3) ### Phase 5: HTTP Component Migration (Week 3)
@ -234,17 +237,27 @@ This component has the cleanest design, so we'll start migration here:
| Current File | New File | Status | | Current File | New File | Status |
|--------------|----------|--------| |--------------|----------|--------|
| **Core/Common Files** | | | | **Core/Common Files** | | |
| ts/common/types.ts | ts/core/models/common-types.ts | | | ts/common/types.ts | ts/core/models/common-types.ts | |
| ts/common/eventUtils.ts | ts/core/utils/event-utils.ts | | | ts/common/eventUtils.ts | ts/core/utils/event-utils.ts | |
| ts/common/acmeFactory.ts | ts/certificate/acme/acme-factory.ts | ❌ | | ts/common/acmeFactory.ts | ts/certificate/acme/acme-factory.ts | ❌ |
| ts/plugins.ts | ts/plugins.ts (stays in original location) | ❌ | | ts/plugins.ts | ts/plugins.ts (stays in original location) | ✅ |
| ts/00_commitinfo_data.ts | ts/00_commitinfo_data.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** | | | | **Certificate Management** | | |
| ts/helpers.certificates.ts | ts/certificate/utils/certificate-helpers.ts | ❌ | | ts/helpers.certificates.ts | ts/certificate/utils/certificate-helpers.ts | ✅ |
| ts/classes.pp.certprovisioner.ts | ts/certificate/providers/cert-provisioner.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** | | | | **TLS and SNI Handling** | | |
| ts/smartproxy/classes.pp.tlsalert.ts | ts/tls/alerts/tls-alert.ts | ❌ | | ts/smartproxy/classes.pp.tlsalert.ts | ts/tls/alerts/tls-alert.ts | ✅ |
| ts/smartproxy/classes.pp.snihandler.ts | ts/tls/sni/sni-handler.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** | | | | **HTTP Components** | | |
| ts/port80handler/classes.port80handler.ts | ts/http/port80/port80-handler.ts | ❌ | | ts/port80handler/classes.port80handler.ts | ts/http/port80/port80-handler.ts | ❌ |
| ts/redirect/classes.redirect.ts | ts/http/redirects/redirect-handler.ts | ❌ | | ts/redirect/classes.redirect.ts | ts/http/redirects/redirect-handler.ts | ❌ |
@ -269,19 +282,19 @@ This component has the cleanest design, so we'll start migration here:
| **NFTablesProxy Components** | | | | **NFTablesProxy Components** | | |
| ts/nfttablesproxy/classes.nftablesproxy.ts | ts/proxies/nftables-proxy/nftables-proxy.ts | ❌ | | ts/nfttablesproxy/classes.nftablesproxy.ts | ts/proxies/nftables-proxy/nftables-proxy.ts | ❌ |
| **Forwarding System** | | | | **Forwarding System** | | |
| ts/smartproxy/types/forwarding.types.ts | ts/forwarding/config/forwarding-types.ts | | | 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-config.ts | ts/forwarding/config/domain-config.ts | |
| ts/smartproxy/forwarding/domain-manager.ts | ts/forwarding/config/domain-manager.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/forwarding.handler.ts | ts/forwarding/handlers/base-handler.ts | |
| ts/smartproxy/forwarding/http.handler.ts | ts/forwarding/handlers/http-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-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-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/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/forwarding.factory.ts | ts/forwarding/factory/forwarding-factory.ts | |
| ts/smartproxy/forwarding/index.ts | ts/forwarding/index.ts | | | ts/smartproxy/forwarding/index.ts | ts/forwarding/index.ts | |
| **Examples and Entry Points** | | | | **Examples and Entry Points** | | |
| ts/examples/forwarding-example.ts | ts/examples/forwarding-example.ts | ❌ | | ts/examples/forwarding-example.ts | ts/examples/forwarding-example.ts | ❌ |
| ts/index.ts | ts/index.ts (updated) | | | ts/index.ts | ts/index.ts (updated) | |
## Import Strategy ## Import Strategy

View File

@ -1,7 +1,9 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import type { IAcmeOptions } from './types.js'; import type { AcmeOptions } from '../models/certificate-types.js';
import { Port80Handler } from '../port80handler/classes.port80handler.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. * Factory to create a Port80Handler with common setup.
@ -10,14 +12,37 @@ import { Port80Handler } from '../port80handler/classes.port80handler.js';
* @returns A new Port80Handler instance * @returns A new Port80Handler instance
*/ */
export function buildPort80Handler( export function buildPort80Handler(
options: IAcmeOptions options: AcmeOptions
): Port80Handler { ): Port80Handler {
if (options.certificateStore) { if (options.certificateStore) {
const certStorePath = path.resolve(options.certificateStore); ensureCertificateDirectory(options.certificateStore);
if (!fs.existsSync(certStorePath)) { console.log(`Ensured certificate store directory: ${options.certificateStore}`);
fs.mkdirSync(certStorePath, { recursive: true });
console.log(`Created certificate store directory: ${certStorePath}`);
}
} }
return new Port80Handler(options); 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
};
}

View 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;
}
}

View File

@ -0,0 +1,3 @@
/**
* ACME certificate provisioning
*/

View File

@ -0,0 +1,32 @@
/**
* 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',
}
/**
* Port80Handler-specific events including certificate-related ones
*/
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
View 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
);
}

View 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 {}

View File

@ -1,27 +1,40 @@
import * as plugins from '../plugins.js'; import * as plugins from '../../plugins.js';
import type { IDomainConfig, ISmartProxyCertProvisionObject } from './classes.pp.interfaces.js'; import type { DomainConfig } from '../../forwarding/config/domain-config.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js'; import type { CertificateData, DomainForwardConfig, DomainOptions } from '../models/certificate-types.js';
import { Port80HandlerEvents } from '../common/types.js'; import { Port80HandlerEvents, CertProvisionerEvents } from '../events/certificate-events.js';
import { subscribeToPort80Handler } from '../common/eventUtils.js'; import { Port80Handler } from '../../port80handler/classes.port80handler.js';
import type { ICertificateData } from '../common/types.js'; // We need to define this interface until we migrate NetworkProxyBridge
import type { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; 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, * CertProvisioner manages certificate provisioning and renewal workflows,
* unifying static certificates and HTTP-01 challenges via Port80Handler. * unifying static certificates and HTTP-01 challenges via Port80Handler.
*/ */
export class CertProvisioner extends plugins.EventEmitter { export class CertProvisioner extends plugins.EventEmitter {
private domainConfigs: IDomainConfig[]; private domainConfigs: DomainConfig[];
private port80Handler: Port80Handler; private port80Handler: Port80Handler;
private networkProxyBridge: NetworkProxyBridge; private networkProxyBridge: NetworkProxyBridge;
private certProvisionFunction?: (domain: string) => Promise<ISmartProxyCertProvisionObject>; private certProvisionFunction?: (domain: string) => Promise<CertProvisionObject>;
private forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }>; private forwardConfigs: DomainForwardConfig[];
private renewThresholdDays: number; private renewThresholdDays: number;
private renewCheckIntervalHours: number; private renewCheckIntervalHours: number;
private autoRenew: boolean; private autoRenew: boolean;
private renewManager?: plugins.taskbuffer.TaskManager; private renewManager?: plugins.taskbuffer.TaskManager;
// Track provisioning type per domain: 'http01' or 'static' // Track provisioning type per domain
private provisionMap: Map<string, 'http01' | 'static'>; private provisionMap: Map<string, 'http01' | 'dns01' | 'static'>;
/** /**
* @param domainConfigs Array of domain configuration objects * @param domainConfigs Array of domain configuration objects
@ -31,16 +44,17 @@ export class CertProvisioner extends plugins.EventEmitter {
* @param renewThresholdDays Days before expiry to trigger renewals * @param renewThresholdDays Days before expiry to trigger renewals
* @param renewCheckIntervalHours Interval in hours to check for renewals * @param renewCheckIntervalHours Interval in hours to check for renewals
* @param autoRenew Whether to automatically schedule renewals * @param autoRenew Whether to automatically schedule renewals
* @param forwardConfigs Domain forwarding configurations for ACME challenges
*/ */
constructor( constructor(
domainConfigs: IDomainConfig[], domainConfigs: DomainConfig[],
port80Handler: Port80Handler, port80Handler: Port80Handler,
networkProxyBridge: NetworkProxyBridge, networkProxyBridge: NetworkProxyBridge,
certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>, certProvider?: (domain: string) => Promise<CertProvisionObject>,
renewThresholdDays: number = 30, renewThresholdDays: number = 30,
renewCheckIntervalHours: number = 24, renewCheckIntervalHours: number = 24,
autoRenew: boolean = true, autoRenew: boolean = true,
forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }> = [] forwardConfigs: DomainForwardConfig[] = []
) { ) {
super(); super();
this.domainConfigs = domainConfigs; this.domainConfigs = domainConfigs;
@ -59,30 +73,74 @@ export class CertProvisioner extends plugins.EventEmitter {
*/ */
public async start(): Promise<void> { public async start(): Promise<void> {
// Subscribe to Port80Handler certificate events // Subscribe to Port80Handler certificate events
subscribeToPort80Handler(this.port80Handler, { this.setupEventSubscriptions();
onCertificateIssued: (data: ICertificateData) => {
this.emit('certificate', { ...data, source: 'http01', isRenewal: false }); // Apply external forwarding for ACME challenges
}, this.setupForwardingConfigs();
onCertificateRenewed: (data: ICertificateData) => {
this.emit('certificate', { ...data, source: 'http01', isRenewal: true }); // 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 });
}); });
// Apply external forwarding for ACME challenges (e.g. Synology) this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: CertificateData) => {
for (const f of this.forwardConfigs) { this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, { ...data, source: 'http01', isRenewal: true });
this.port80Handler.addDomain({ });
domainName: f.domain,
sslRedirect: f.sslRedirect, this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (error) => {
acmeMaintenance: false, this.emit(CertProvisionerEvents.CERTIFICATE_FAILED, error);
forward: f.forwardConfig,
acmeForward: f.acmeForwardConfig
}); });
} }
// Initial provisioning for all domains
/**
* 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); const domains = this.domainConfigs.flatMap(cfg => cfg.domains);
for (const domain of 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('*'); const isWildcard = domain.includes('*');
let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01'; let provision: CertProvisionObject = 'http01';
// Try to get a certificate from the provision function
if (this.certProvisionFunction) { if (this.certProvisionFunction) {
try { try {
provision = await this.certProvisionFunction(domain); provision = await this.certProvisionFunction(domain);
@ -92,66 +150,103 @@ export class CertProvisioner extends plugins.EventEmitter {
} else if (isWildcard) { } else if (isWildcard) {
// No certProvider: cannot handle wildcard without DNS-01 support // No certProvider: cannot handle wildcard without DNS-01 support
console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`); console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`);
continue; return;
} }
// Handle different provisioning methods
if (provision === 'http01') { if (provision === 'http01') {
if (isWildcard) { if (isWildcard) {
console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`); console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`);
continue; return;
} }
this.provisionMap.set(domain, 'http01'); this.provisionMap.set(domain, 'http01');
this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true }); 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 { } else {
// Static certificate (e.g., DNS-01 provisioned or user-provided) supports wildcard domains // Static certificate (e.g., DNS-01 provisioned or user-provided)
this.provisionMap.set(domain, 'static'); this.provisionMap.set(domain, 'static');
const certObj = provision as plugins.tsclass.network.ICert; const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = { const certData: CertificateData = {
domain: certObj.domainName, domain: certObj.domainName,
certificate: certObj.publicKey, certificate: certObj.publicKey,
privateKey: certObj.privateKey, privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil) expiryDate: new Date(certObj.validUntil),
source: 'static',
isRenewal: false
}; };
this.networkProxyBridge.applyExternalCertificate(certData); this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: false }); this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData);
} }
} }
// Schedule renewals if enabled /**
if (this.autoRenew) { * Schedule certificate renewals using a task manager
*/
private scheduleRenewals(): void {
this.renewManager = new plugins.taskbuffer.TaskManager(); this.renewManager = new plugins.taskbuffer.TaskManager();
const renewTask = new plugins.taskbuffer.Task({ const renewTask = new plugins.taskbuffer.Task({
name: 'CertificateRenewals', name: 'CertificateRenewals',
taskFunction: async () => { 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()) { for (const [domain, type] of this.provisionMap.entries()) {
// Skip wildcard domains // Skip wildcard domains for HTTP-01 challenges
if (domain.includes('*')) continue; if (domain.includes('*') && type === 'http01') continue;
try { try {
if (type === 'http01') { await this.renewDomain(domain, type);
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) { } catch (err) {
console.error(`Renewal error for ${domain}:`, err); console.error(`Renewal error for ${domain}:`, err);
} }
} }
} }
});
const hours = this.renewCheckIntervalHours; /**
const cronExpr = `0 0 */${hours} * * *`; * Renew a certificate for a specific domain
this.renewManager.addAndScheduleTask(renewTask, cronExpr); * @param domain Domain to renew
this.renewManager.start(); * @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);
}
} }
} }
@ -159,7 +254,6 @@ export class CertProvisioner extends plugins.EventEmitter {
* Stop all scheduled renewal tasks. * Stop all scheduled renewal tasks.
*/ */
public async stop(): Promise<void> { public async stop(): Promise<void> {
// Stop scheduled renewals
if (this.renewManager) { if (this.renewManager) {
this.renewManager.stop(); this.renewManager.stop();
} }
@ -171,30 +265,62 @@ export class CertProvisioner extends plugins.EventEmitter {
*/ */
public async requestCertificate(domain: string): Promise<void> { public async requestCertificate(domain: string): Promise<void> {
const isWildcard = domain.includes('*'); const isWildcard = domain.includes('*');
// Determine provisioning method // Determine provisioning method
let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01'; let provision: CertProvisionObject = 'http01';
if (this.certProvisionFunction) { if (this.certProvisionFunction) {
provision = await this.certProvisionFunction(domain); provision = await this.certProvisionFunction(domain);
} else if (isWildcard) { } else if (isWildcard) {
// Cannot perform HTTP-01 on wildcard without certProvider // Cannot perform HTTP-01 on wildcard without certProvider
throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`); throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`);
} }
if (provision === 'http01') { if (provision === 'http01') {
if (isWildcard) { if (isWildcard) {
throw new Error(`Cannot request HTTP-01 certificate for wildcard domain: ${domain}`); throw new Error(`Cannot request HTTP-01 certificate for wildcard domain: ${domain}`);
} }
await this.port80Handler.renewCertificate(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 { } else {
// Static certificate (e.g., DNS-01 provisioned) supports wildcards // Static certificate (e.g., DNS-01 provisioned) supports wildcards
const certObj = provision as plugins.tsclass.network.ICert; const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = { const certData: CertificateData = {
domain: certObj.domainName, domain: certObj.domainName,
certificate: certObj.publicKey, certificate: certObj.publicKey,
privateKey: certObj.privateKey, privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil) expiryDate: new Date(certObj.validUntil),
source: 'static',
isRenewal: false
}; };
this.networkProxyBridge.applyExternalCertificate(certData); this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: false }); 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 }

View File

@ -0,0 +1,3 @@
/**
* Certificate providers
*/

View 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, '_');
}
}

View File

@ -0,0 +1,3 @@
/**
* Certificate storage mechanisms
*/

View File

@ -1,17 +1,18 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import type { Certificates } from '../models/certificate-types.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
export interface ICertificates { /**
privateKey: string; * Loads the default SSL certificates from the assets directory
publicKey: string; * @returns The certificate key pair
} */
export function loadDefaultCertificates(): Certificates {
export function loadDefaultCertificates(): ICertificates {
try { try {
const certPath = path.join(__dirname, '..', 'assets', 'certs'); // 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 privateKey = fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8');
const publicKey = fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8'); const publicKey = fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8');
@ -28,3 +29,22 @@ export function loadDefaultCertificates(): ICertificates {
throw 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 });
}
}

3
ts/core/events/index.ts Normal file
View File

@ -0,0 +1,3 @@
/**
* Common event definitions
*/

8
ts/core/index.ts Normal file
View 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';

View 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
View File

@ -0,0 +1,5 @@
/**
* Core data models and interfaces
*/
export * from './common-types.js';

View 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
View 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
View 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;
}
}

View 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-----');
}
}

View 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 {}

View 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];
}
}

View 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 {}

View File

@ -0,0 +1,7 @@
/**
* Forwarding configuration exports
*/
export * from './forwarding-types.js';
export * from './domain-config.js';
export * from './domain-manager.js';

View 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;
}
}
}

View File

@ -0,0 +1,5 @@
/**
* Forwarding factory implementations
*/
export { ForwardingHandlerFactory } from './forwarding-factory.js';

View 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
}
}

View 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();
}
}
}

View 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
});
}
}

View 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();
}
}
}

View 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();
}
}
}

View 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
View 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
};

8
ts/http/index.ts Normal file
View File

@ -0,0 +1,8 @@
/**
* HTTP functionality module
*/
// Export submodules
export * from './port80/index.js';
export * from './router/index.js';
export * from './redirects/index.js';

View 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;

View File

@ -0,0 +1,221 @@
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 SmartAcme
this.smartAcme = new plugins.smartacme.SmartAcme({
useProduction: this.useProduction,
accountEmail: this.email,
directoryUrl: this.useProduction
? 'https://acme-v02.api.letsencrypt.org/directory' // Production
: 'https://acme-staging-v02.api.letsencrypt.org/directory', // Staging
});
// Initialize HTTP-01 challenge handler
this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
this.smartAcme.useHttpChallenge(this.http01Handler);
// Ensure certificate store directory exists
await this.ensureCertificateStore();
// Subscribe to SmartAcme events
this.smartAcme.on('certificate-issued', (data: any) => {
const certData: CertificateData = {
domain: data.domain,
certificate: data.cert,
privateKey: data.key,
expiryDate: new Date(data.expiryDate),
};
this.emit(CertificateEvents.CERTIFICATE_ISSUED, certData);
});
this.smartAcme.on('certificate-renewed', (data: any) => {
const certData: CertificateData = {
domain: data.domain,
certificate: data.cert,
privateKey: data.key,
expiryDate: new Date(data.expiryDate),
};
this.emit(CertificateEvents.CERTIFICATE_RENEWED, certData);
});
this.smartAcme.on('certificate-error', (data: any) => {
const error: CertificateFailure = {
domain: data.domain,
error: data.error instanceof Error ? data.error.message : String(data.error),
isRenewal: data.isRenewal || false,
};
this.emit(CertificateEvents.CERTIFICATE_FAILED, error);
});
await this.smartAcme.initialize();
} catch (error) {
throw new Error(`Failed to initialize ACME client: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 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) {
const response = this.http01Handler.getResponse(token);
if (response) {
// This is a valid ACME challenge
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
res.writeHead(200);
res.end(response);
return true;
}
}
// Invalid ACME challenge
res.writeHead(404);
res.end('Not found');
return true;
}
return false;
}
/**
* 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 {
const result = await this.smartAcme.getCertificate(domain);
const certData: CertificateData = {
domain,
certificate: result.cert,
privateKey: result.key,
expiryDate: new Date(result.expiryDate),
};
// Emit appropriate event
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);
});
}
}
}
}

3
ts/http/port80/index.ts Normal file
View File

@ -0,0 +1,3 @@
/**
* Port 80 handling
*/

View File

@ -1,57 +1,33 @@
import * as plugins from '../plugins.js'; import * as plugins from '../../plugins.js';
import { IncomingMessage, ServerResponse } from 'http'; import { IncomingMessage, ServerResponse } from 'http';
import { Port80HandlerEvents } from '../common/types.js'; import { CertificateEvents } from '../../certificate/events/certificate-events.js';
import type { import type {
IForwardConfig, ForwardConfig,
IDomainOptions, DomainOptions,
ICertificateData, CertificateData,
ICertificateFailure, CertificateFailure,
ICertificateExpiring, CertificateExpiring,
IAcmeOptions AcmeOptions
} from '../common/types.js'; } from '../../certificate/models/certificate-types.js';
// (fs and path I/O moved to CertProvisioner) import {
HttpEvents,
HttpStatus,
HttpError,
CertificateError,
ServerError,
DomainCertificate
} from '../models/http-types.js';
import { ChallengeResponder } from './challenge-responder.js';
/** // Re-export for backward compatibility
* Custom error classes for better error handling export {
*/ HttpError as Port80HandlerError,
export class Port80HandlerError extends Error { CertificateError,
constructor(message: string) { ServerError
super(message);
this.name = 'Port80HandlerError';
}
} }
export class CertificateError extends Port80HandlerError { // Port80Handler events enum for backward compatibility
constructor( export const Port80HandlerEvents = CertificateEvents;
message: string,
public readonly domain: string,
public readonly isRenewal: boolean = false
) {
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
this.name = 'CertificateError';
}
}
export class ServerError extends Port80HandlerError {
constructor(message: string, public readonly code?: string) {
super(message);
this.name = 'ServerError';
}
}
/**
* Represents a domain configuration with certificate status information
*/
interface IDomainCertificate {
options: IDomainOptions;
certObtained: boolean;
obtainingInProgress: boolean;
certificate?: string;
privateKey?: string;
expiryDate?: Date;
lastRenewalAttempt?: Date;
}
/** /**
* Configuration options for the Port80Handler * Configuration options for the Port80Handler
@ -64,24 +40,21 @@ interface IDomainCertificate {
* Now with glob pattern support for domain matching * Now with glob pattern support for domain matching
*/ */
export class Port80Handler extends plugins.EventEmitter { export class Port80Handler extends plugins.EventEmitter {
private domainCertificates: Map<string, IDomainCertificate>; private domainCertificates: Map<string, DomainCertificate>;
// SmartAcme instance for certificate management private challengeResponder: ChallengeResponder | null = null;
private smartAcme: plugins.smartacme.SmartAcme | null = null;
private smartAcmeHttp01Handler!: plugins.smartacme.handlers.Http01MemoryHandler;
private server: plugins.http.Server | null = null; private server: plugins.http.Server | null = null;
// Renewal scheduling is handled externally by SmartProxy // Renewal scheduling is handled externally by SmartProxy
// (Removed internal renewal timer)
private isShuttingDown: boolean = false; private isShuttingDown: boolean = false;
private options: Required<IAcmeOptions>; private options: Required<AcmeOptions>;
/** /**
* Creates a new Port80Handler * Creates a new Port80Handler
* @param options Configuration options * @param options Configuration options
*/ */
constructor(options: IAcmeOptions = {}) { constructor(options: AcmeOptions = {}) {
super(); super();
this.domainCertificates = new Map<string, IDomainCertificate>(); this.domainCertificates = new Map<string, DomainCertificate>();
// Default options // Default options
this.options = { this.options = {
@ -97,6 +70,32 @@ export class Port80Handler extends plugins.EventEmitter {
autoRenew: options.autoRenew ?? true, autoRenew: options.autoRenew ?? true,
domainForwards: options.domainForwards ?? [] 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);
});
}
} }
/** /**

View File

@ -0,0 +1,3 @@
/**
* HTTP redirects
*/

3
ts/http/router/index.ts Normal file
View File

@ -0,0 +1,3 @@
/**
* HTTP routing
*/

View File

@ -1,12 +1,22 @@
/**
* SmartProxy main module exports
*/
// Legacy exports (to maintain backward compatibility)
export * from './nfttablesproxy/classes.nftablesproxy.js'; export * from './nfttablesproxy/classes.nftablesproxy.js';
export * from './networkproxy/index.js'; export * from './networkproxy/index.js';
export * from './port80handler/classes.port80handler.js'; export * from './port80handler/classes.port80handler.js';
export * from './redirect/classes.redirect.js'; export * from './redirect/classes.redirect.js';
export * from './smartproxy/classes.smartproxy.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 './smartproxy/classes.pp.interfaces.js';
export * from './common/types.js'; // Core types and utilities
export * from './core/models/common-types.js';
// Export forwarding system // Modular exports for new architecture
export * as forwarding from './smartproxy/forwarding/index.js'; export * as forwarding from './forwarding/index.js';
export * as certificate from './certificate/index.js';
export * as tls from './tls/index.js';

View File

@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
import { type INetworkProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './classes.np.types.js'; import { type INetworkProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './classes.np.types.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js'; import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from '../common/types.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 { subscribeToPort80Handler } from '../common/eventUtils.js';
import type { IDomainOptions } from '../common/types.js'; import type { IDomainOptions } from '../common/types.js';

8
ts/proxies/index.ts Normal file
View 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';

View File

@ -0,0 +1,3 @@
/**
* NetworkProxy implementation
*/

View File

@ -0,0 +1,3 @@
/**
* NetworkProxy models
*/

View File

@ -0,0 +1,3 @@
/**
* NfTablesProxy implementation
*/

View File

@ -0,0 +1,3 @@
/**
* SmartProxy implementation
*/

View File

@ -0,0 +1,3 @@
/**
* SmartProxy models
*/

View File

@ -3,7 +3,7 @@ import { NetworkProxy } from '../networkproxy/classes.np.networkproxy.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js'; import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from '../common/types.js'; import { Port80HandlerEvents } from '../common/types.js';
import { subscribeToPort80Handler } from '../common/eventUtils.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'; import type { IConnectionRecord, ISmartProxyOptions, IDomainConfig } from './classes.pp.interfaces.js';
/** /**
@ -66,7 +66,7 @@ export class NetworkProxyBridge {
/** /**
* Handle certificate issuance or renewal events * Handle certificate issuance or renewal events
*/ */
private handleCertificateEvent(data: ICertificateData): void { private handleCertificateEvent(data: CertificateData): void {
if (!this.networkProxy) return; if (!this.networkProxy) return;
console.log(`Received certificate for ${data.domain} from Port80Handler, updating NetworkProxy`); 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 * Apply an external (static) certificate into NetworkProxy
*/ */
public applyExternalCertificate(data: ICertificateData): void { public applyExternalCertificate(data: CertificateData): void {
if (!this.networkProxy) { if (!this.networkProxy) {
console.log(`NetworkProxy not initialized: cannot apply external certificate for ${data.domain}`); console.log(`NetworkProxy not initialized: cannot apply external certificate for ${data.domain}`);
return; return;

View File

@ -1,6 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import type { ISmartProxyOptions } from './classes.pp.interfaces.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 * Interface for connection information used for SNI extraction

View File

@ -9,9 +9,9 @@ import { TimeoutManager } from './classes.pp.timeoutmanager.js';
import { PortRangeManager } from './classes.pp.portrangemanager.js'; import { PortRangeManager } from './classes.pp.portrangemanager.js';
import { ConnectionHandler } from './classes.pp.connectionhandler.js'; import { ConnectionHandler } from './classes.pp.connectionhandler.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js'; import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { CertProvisioner } from './classes.pp.certprovisioner.js'; import { CertProvisioner } from '../certificate/providers/cert-provisioner.js';
import type { ICertificateData } from '../common/types.js'; import type { CertificateData } from '../certificate/models/certificate-types.js';
import { buildPort80Handler } from '../common/acmeFactory.js'; import { buildPort80Handler } from '../certificate/acme/acme-factory.js';
import type { ForwardingType } from './types/forwarding.types.js'; import type { ForwardingType } from './types/forwarding.types.js';
import { createPort80HandlerOptions } from '../common/port80-adapter.js'; import { createPort80HandlerOptions } from '../common/port80-adapter.js';
@ -470,7 +470,7 @@ export class SmartProxy extends plugins.EventEmitter {
} else { } else {
// Static certificate (e.g., DNS-01 provisioned) supports wildcards // Static certificate (e.g., DNS-01 provisioned) supports wildcards
const certObj = provision as plugins.tsclass.network.ICert; const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = { const certData: CertificateData = {
domain: certObj.domainName, domain: certObj.domainName,
certificate: certObj.publicKey, certificate: certObj.publicKey,
privateKey: certObj.privateKey, privateKey: certObj.privateKey,

3
ts/tls/alerts/index.ts Normal file
View File

@ -0,0 +1,3 @@
/**
* TLS alerts
*/

View File

@ -1,48 +1,49 @@
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 { export class TlsAlert {
// TLS Alert Levels // Use enum values from TlsAlertLevel
static readonly LEVEL_WARNING = 0x01; static readonly LEVEL_WARNING = TlsAlertLevel.WARNING;
static readonly LEVEL_FATAL = 0x02; static readonly LEVEL_FATAL = TlsAlertLevel.FATAL;
// TLS Alert Description Codes - RFC 8446 (TLS 1.3) / RFC 5246 (TLS 1.2) // Use enum values from TlsAlertDescription
static readonly CLOSE_NOTIFY = 0x00; static readonly CLOSE_NOTIFY = TlsAlertDescription.CLOSE_NOTIFY;
static readonly UNEXPECTED_MESSAGE = 0x0A; static readonly UNEXPECTED_MESSAGE = TlsAlertDescription.UNEXPECTED_MESSAGE;
static readonly BAD_RECORD_MAC = 0x14; static readonly BAD_RECORD_MAC = TlsAlertDescription.BAD_RECORD_MAC;
static readonly DECRYPTION_FAILED = 0x15; // TLS 1.0 only static readonly DECRYPTION_FAILED = TlsAlertDescription.DECRYPTION_FAILED;
static readonly RECORD_OVERFLOW = 0x16; static readonly RECORD_OVERFLOW = TlsAlertDescription.RECORD_OVERFLOW;
static readonly DECOMPRESSION_FAILURE = 0x1E; // TLS 1.2 and below static readonly DECOMPRESSION_FAILURE = TlsAlertDescription.DECOMPRESSION_FAILURE;
static readonly HANDSHAKE_FAILURE = 0x28; static readonly HANDSHAKE_FAILURE = TlsAlertDescription.HANDSHAKE_FAILURE;
static readonly NO_CERTIFICATE = 0x29; // SSLv3 only static readonly NO_CERTIFICATE = TlsAlertDescription.NO_CERTIFICATE;
static readonly BAD_CERTIFICATE = 0x2A; static readonly BAD_CERTIFICATE = TlsAlertDescription.BAD_CERTIFICATE;
static readonly UNSUPPORTED_CERTIFICATE = 0x2B; static readonly UNSUPPORTED_CERTIFICATE = TlsAlertDescription.UNSUPPORTED_CERTIFICATE;
static readonly CERTIFICATE_REVOKED = 0x2C; static readonly CERTIFICATE_REVOKED = TlsAlertDescription.CERTIFICATE_REVOKED;
static readonly CERTIFICATE_EXPIRED = 0x2F; static readonly CERTIFICATE_EXPIRED = TlsAlertDescription.CERTIFICATE_EXPIRED;
static readonly CERTIFICATE_UNKNOWN = 0x30; static readonly CERTIFICATE_UNKNOWN = TlsAlertDescription.CERTIFICATE_UNKNOWN;
static readonly ILLEGAL_PARAMETER = 0x2F; static readonly ILLEGAL_PARAMETER = TlsAlertDescription.ILLEGAL_PARAMETER;
static readonly UNKNOWN_CA = 0x30; static readonly UNKNOWN_CA = TlsAlertDescription.UNKNOWN_CA;
static readonly ACCESS_DENIED = 0x31; static readonly ACCESS_DENIED = TlsAlertDescription.ACCESS_DENIED;
static readonly DECODE_ERROR = 0x32; static readonly DECODE_ERROR = TlsAlertDescription.DECODE_ERROR;
static readonly DECRYPT_ERROR = 0x33; static readonly DECRYPT_ERROR = TlsAlertDescription.DECRYPT_ERROR;
static readonly EXPORT_RESTRICTION = 0x3C; // TLS 1.0 only static readonly EXPORT_RESTRICTION = TlsAlertDescription.EXPORT_RESTRICTION;
static readonly PROTOCOL_VERSION = 0x46; static readonly PROTOCOL_VERSION = TlsAlertDescription.PROTOCOL_VERSION;
static readonly INSUFFICIENT_SECURITY = 0x47; static readonly INSUFFICIENT_SECURITY = TlsAlertDescription.INSUFFICIENT_SECURITY;
static readonly INTERNAL_ERROR = 0x50; static readonly INTERNAL_ERROR = TlsAlertDescription.INTERNAL_ERROR;
static readonly INAPPROPRIATE_FALLBACK = 0x56; static readonly INAPPROPRIATE_FALLBACK = TlsAlertDescription.INAPPROPRIATE_FALLBACK;
static readonly USER_CANCELED = 0x5A; static readonly USER_CANCELED = TlsAlertDescription.USER_CANCELED;
static readonly NO_RENEGOTIATION = 0x64; // TLS 1.2 and below static readonly NO_RENEGOTIATION = TlsAlertDescription.NO_RENEGOTIATION;
static readonly MISSING_EXTENSION = 0x6D; // TLS 1.3 static readonly MISSING_EXTENSION = TlsAlertDescription.MISSING_EXTENSION;
static readonly UNSUPPORTED_EXTENSION = 0x6E; // TLS 1.3 static readonly UNSUPPORTED_EXTENSION = TlsAlertDescription.UNSUPPORTED_EXTENSION;
static readonly CERTIFICATE_REQUIRED = 0x6F; // TLS 1.3 static readonly CERTIFICATE_REQUIRED = TlsAlertDescription.CERTIFICATE_REQUIRED;
static readonly UNRECOGNIZED_NAME = 0x70; static readonly UNRECOGNIZED_NAME = TlsAlertDescription.UNRECOGNIZED_NAME;
static readonly BAD_CERTIFICATE_STATUS_RESPONSE = 0x71; static readonly BAD_CERTIFICATE_STATUS_RESPONSE = TlsAlertDescription.BAD_CERTIFICATE_STATUS_RESPONSE;
static readonly BAD_CERTIFICATE_HASH_VALUE = 0x72; // TLS 1.2 and below static readonly BAD_CERTIFICATE_HASH_VALUE = TlsAlertDescription.BAD_CERTIFICATE_HASH_VALUE;
static readonly UNKNOWN_PSK_IDENTITY = 0x73; static readonly UNKNOWN_PSK_IDENTITY = TlsAlertDescription.UNKNOWN_PSK_IDENTITY;
static readonly CERTIFICATE_REQUIRED_1_3 = 0x74; // TLS 1.3 static readonly CERTIFICATE_REQUIRED_1_3 = TlsAlertDescription.CERTIFICATE_REQUIRED_1_3;
static readonly NO_APPLICATION_PROTOCOL = 0x78; static readonly NO_APPLICATION_PROTOCOL = TlsAlertDescription.NO_APPLICATION_PROTOCOL;
/** /**
* Create a TLS alert buffer with the specified level and description code * Create a TLS alert buffer with the specified level and description code
@ -55,7 +56,7 @@ export class TlsAlert {
static create( static create(
level: number, level: number,
description: number, description: number,
tlsVersion: [number, number] = [0x03, 0x03] tlsVersion: [number, number] = [TlsVersion.TLS1_2[0], TlsVersion.TLS1_2[1]]
): Buffer { ): Buffer {
return Buffer.from([ return Buffer.from([
0x15, // Alert record type 0x15, // Alert record type

33
ts/tls/index.ts Normal file
View 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,
};

View 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
View File

@ -0,0 +1,3 @@
/**
* SNI handling
*/

View 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;
}
}

File diff suppressed because it is too large Load Diff

3
ts/tls/utils/index.ts Normal file
View File

@ -0,0 +1,3 @@
/**
* TLS utilities
*/

201
ts/tls/utils/tls-utils.ts Normal file
View 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}`;
}
}