Compare commits

...

4 Commits

11 changed files with 266 additions and 39 deletions

View File

@ -1,5 +1,17 @@
# Changelog
## 2025-02-24 - 3.10.5 - fix(portproxy)
Fix incorrect import path in test file
- Change import path from '../ts/smartproxy.portproxy.js' to '../ts/classes.portproxy.js' in test/test.portproxy.ts
## 2025-02-23 - 3.10.4 - fix(PortProxy)
Refactor connection tracking to utilize unified records in PortProxy
- Implemented a unified record system for tracking incoming and outgoing connections.
- Replaced individual connection tracking sets with a Set of IConnectionRecord.
- Improved logging of connection activities and statistics.
## 2025-02-23 - 3.10.3 - fix(PortProxy)
Refactor and optimize PortProxy for improved readability and maintainability

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "3.10.3",
"version": "3.10.5",
"private": false,
"description": "a proxy for handling high workloads of proxying",
"main": "dist_ts/index.js",

View File

@ -1,6 +1,6 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as net from 'net';
import { PortProxy } from '../ts/smartproxy.portproxy.js';
import { PortProxy } from '../ts/classes.portproxy.js';
let testServer: net.Server;
let portProxy: PortProxy;

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '3.10.3',
version: '3.10.5',
description: 'a proxy for handling high workloads of proxying'
}

View File

@ -1,5 +1,5 @@
import * as plugins from './plugins.js';
import { ProxyRouter } from './smartproxy.classes.router.js';
import { ProxyRouter } from './classes.router.js';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';

212
ts/classes.port80handler.ts Normal file
View File

@ -0,0 +1,212 @@
import * as http from 'http';
import * as acme from 'acme-client';
interface IDomainCertificate {
certObtained: boolean;
obtainingInProgress: boolean;
certificate?: string;
privateKey?: string;
challengeToken?: string;
challengeKeyAuthorization?: string;
}
export class Port80Handler {
private domainCertificates: Map<string, IDomainCertificate>;
private server: http.Server;
private acmeClient: acme.Client | null = null;
private accountKey: string | null = null;
constructor() {
this.domainCertificates = new Map<string, IDomainCertificate>();
// Create and start an HTTP server on port 80.
this.server = http.createServer((req, res) => this.handleRequest(req, res));
this.server.listen(80, () => {
console.log('Port80Handler is listening on port 80');
});
}
/**
* Adds a domain to be managed.
* @param domain The domain to add.
*/
public addDomain(domain: string): void {
if (!this.domainCertificates.has(domain)) {
this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false });
console.log(`Domain added: ${domain}`);
}
}
/**
* Removes a domain from management.
* @param domain The domain to remove.
*/
public removeDomain(domain: string): void {
if (this.domainCertificates.delete(domain)) {
console.log(`Domain removed: ${domain}`);
}
}
/**
* Lazy initialization of the ACME client.
* Uses Lets Encrypts production directory (for testing you might switch to staging).
*/
private async getAcmeClient(): Promise<acme.Client> {
if (this.acmeClient) {
return this.acmeClient;
}
// Generate a new account key.
this.accountKey = await acme.forge.createPrivateKey();
this.acmeClient = new acme.Client({
directoryUrl: acme.directory.letsencrypt.production, // Use production for a real certificate
// For testing, you could use:
// directoryUrl: acme.directory.letsencrypt.staging,
accountKey: this.accountKey,
});
// Create a new account. Make sure to update the contact email.
await this.acmeClient.createAccount({
termsOfServiceAgreed: true,
contact: ['mailto:admin@example.com'],
});
return this.acmeClient;
}
/**
* Handles incoming HTTP requests on port 80.
* If the request is for an ACME challenge, it responds with the key authorization.
* If the domain has a certificate, it redirects to HTTPS; otherwise, it initiates certificate issuance.
*/
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
const hostHeader = req.headers.host;
if (!hostHeader) {
res.statusCode = 400;
res.end('Bad Request: Host header is missing');
return;
}
// Extract domain (ignoring any port in the Host header)
const domain = hostHeader.split(':')[0];
// If the request is for an ACME HTTP-01 challenge, handle it.
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
this.handleAcmeChallenge(req, res, domain);
return;
}
if (!this.domainCertificates.has(domain)) {
res.statusCode = 404;
res.end('Domain not configured');
return;
}
const domainInfo = this.domainCertificates.get(domain)!;
// If certificate exists, redirect to HTTPS on port 443.
if (domainInfo.certObtained) {
const redirectUrl = `https://${domain}:443${req.url}`;
res.statusCode = 301;
res.setHeader('Location', redirectUrl);
res.end(`Redirecting to ${redirectUrl}`);
} else {
// Trigger certificate issuance if not already running.
if (!domainInfo.obtainingInProgress) {
domainInfo.obtainingInProgress = true;
this.obtainCertificate(domain).catch(err => {
console.error(`Error obtaining certificate for ${domain}:`, err);
});
}
res.statusCode = 503;
res.end('Certificate issuance in progress, please try again later.');
}
}
/**
* Serves the ACME HTTP-01 challenge response.
*/
private handleAcmeChallenge(req: http.IncomingMessage, res: http.ServerResponse, domain: string): void {
const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) {
res.statusCode = 404;
res.end('Domain not configured');
return;
}
// The token is the last part of the URL.
const urlParts = req.url?.split('/');
const token = urlParts ? urlParts[urlParts.length - 1] : '';
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(domainInfo.challengeKeyAuthorization);
console.log(`Served ACME challenge response for ${domain}`);
} else {
res.statusCode = 404;
res.end('Challenge token not found');
}
}
/**
* Uses acme-client to perform a full ACME HTTP-01 challenge to obtain a certificate.
* On success, it stores the certificate and key in memory and clears challenge data.
*/
private async obtainCertificate(domain: string): Promise<void> {
try {
const client = await this.getAcmeClient();
// Create a new order for the domain.
const order = await client.createOrder({
identifiers: [{ type: 'dns', value: domain }],
});
// Get the authorizations for the order.
const authorizations = await client.getAuthorizations(order);
for (const authz of authorizations) {
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
if (!challenge) {
throw new Error('HTTP-01 challenge not found');
}
// Get the key authorization for the challenge.
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
const domainInfo = this.domainCertificates.get(domain)!;
domainInfo.challengeToken = challenge.token;
domainInfo.challengeKeyAuthorization = keyAuthorization;
// Notify the ACME server that the challenge is ready.
await client.verifyChallenge(authz, challenge, keyAuthorization);
await client.completeChallenge(challenge);
// Wait until the challenge is validated.
await client.waitForValidStatus(challenge);
console.log(`HTTP-01 challenge completed for ${domain}`);
}
// Generate a CSR and a new private key for the domain.
const [csr, privateKey] = await acme.forge.createCsr({
commonName: domain,
});
// Finalize the order and obtain the certificate.
await client.finalizeOrder(order, csr);
const certificate = await client.getCertificate(order);
const domainInfo = this.domainCertificates.get(domain)!;
domainInfo.certificate = certificate;
domainInfo.privateKey = privateKey;
domainInfo.certObtained = true;
domainInfo.obtainingInProgress = false;
delete domainInfo.challengeToken;
delete domainInfo.challengeKeyAuthorization;
console.log(`Certificate obtained for ${domain}`);
// In a real application, you would persist the certificate and key,
// then reload your TLS server with the new credentials.
} catch (error) {
console.error(`Error during certificate issuance for ${domain}:`, error);
const domainInfo = this.domainCertificates.get(domain);
if (domainInfo) {
domainInfo.obtainingInProgress = false;
}
}
}
}
// Example usage:
// const handler = new Port80Handler();
// handler.addDomain('example.com');

View File

@ -79,12 +79,19 @@ function extractSNI(buffer: Buffer): string | undefined {
return undefined;
}
interface IConnectionRecord {
incoming: plugins.net.Socket;
outgoing: plugins.net.Socket | null;
incomingStartTime: number;
outgoingStartTime?: number;
connectionClosed: boolean;
}
export class PortProxy {
netServer: plugins.net.Server;
settings: IProxySettings;
private activeConnections: Set<plugins.net.Socket> = new Set();
private incomingConnectionTimes: Map<plugins.net.Socket, number> = new Map();
private outgoingConnectionTimes: Map<plugins.net.Socket, number> = new Map();
// Unified record tracking each connection pair.
private connectionRecords: Set<IConnectionRecord> = new Set();
private connectionLogger: NodeJS.Timeout | null = null;
private terminationStats: {
@ -140,29 +147,26 @@ export class PortProxy {
this.netServer = plugins.net.createServer((socket: plugins.net.Socket) => {
const remoteIP = socket.remoteAddress || '';
this.activeConnections.add(socket);
this.incomingConnectionTimes.set(socket, Date.now());
console.log(`New connection from ${remoteIP}. Active connections: ${this.activeConnections.size}`);
const connectionRecord: IConnectionRecord = {
incoming: socket,
outgoing: null,
incomingStartTime: Date.now(),
connectionClosed: false,
};
this.connectionRecords.add(connectionRecord);
console.log(`New connection from ${remoteIP}. Active connections: ${this.connectionRecords.size}`);
let initialDataReceived = false;
let incomingTerminationReason: string | null = null;
let outgoingTerminationReason: string | null = null;
let targetSocket: plugins.net.Socket | null = null;
let connectionClosed = false;
// Ensure cleanup happens only once.
// Ensure cleanup happens only once for the entire connection record.
const cleanupOnce = () => {
if (!connectionClosed) {
connectionClosed = true;
cleanUpSockets(socket, targetSocket || undefined);
this.incomingConnectionTimes.delete(socket);
if (targetSocket) {
this.outgoingConnectionTimes.delete(targetSocket);
}
if (this.activeConnections.has(socket)) {
this.activeConnections.delete(socket);
console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.activeConnections.size}`);
}
if (!connectionRecord.connectionClosed) {
connectionRecord.connectionClosed = true;
cleanUpSockets(connectionRecord.incoming, connectionRecord.outgoing || undefined);
this.connectionRecords.delete(connectionRecord);
console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.connectionRecords.size}`);
}
};
@ -242,10 +246,10 @@ export class PortProxy {
connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
}
targetSocket = plugins.net.connect(connectionOptions);
if (targetSocket) {
this.outgoingConnectionTimes.set(targetSocket, Date.now());
}
const targetSocket = plugins.net.connect(connectionOptions);
connectionRecord.outgoing = targetSocket;
connectionRecord.outgoingStartTime = Date.now();
console.log(
`Connection established: ${remoteIP} -> ${targetHost}:${this.settings.toPort}` +
`${serverName ? ` (SNI: ${serverName})` : ''}`
@ -314,20 +318,19 @@ export class PortProxy {
);
});
// Log active connection count, longest running connection durations,
// and termination statistics every 10 seconds.
// Every 10 seconds log active connection count and longest running durations.
this.connectionLogger = setInterval(() => {
const now = Date.now();
let maxIncoming = 0;
for (const startTime of this.incomingConnectionTimes.values()) {
maxIncoming = Math.max(maxIncoming, now - startTime);
}
let maxOutgoing = 0;
for (const startTime of this.outgoingConnectionTimes.values()) {
maxOutgoing = Math.max(maxOutgoing, now - startTime);
for (const record of this.connectionRecords) {
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
if (record.outgoingStartTime) {
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
}
}
console.log(
`(Interval Log) Active connections: ${this.activeConnections.size}. ` +
`(Interval Log) Active connections: ${this.connectionRecords.size}. ` +
`Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. ` +
`Termination stats (incoming): ${JSON.stringify(this.terminationStats.incoming)}, ` +
`(outgoing): ${JSON.stringify(this.terminationStats.outgoing)}`

View File

@ -1,3 +1,3 @@
export * from './smartproxy.classes.networkproxy.js';
export * from './smartproxy.portproxy.js';
export * from './smartproxy.classes.sslredirect.js';
export * from './classes.networkproxy.js';
export * from './classes.portproxy.js';
export * from './classes.sslredirect.js';