import * as plugins from './smartproxy.plugins.js'; export interface DomainConfig { domain: string; // glob pattern for domain allowedIPs: string[]; // glob patterns for IPs allowed to access this domain } export interface ProxySettings extends plugins.tls.TlsOptions { // Port configuration fromPort: number; toPort: number; toHost?: string; // Target host to proxy to, defaults to 'localhost' // Domain and security settings domains: DomainConfig[]; sniEnabled?: boolean; defaultAllowedIPs?: string[]; // Optional default IP patterns if no matching domain found } export class PortProxy { netServer: plugins.net.Server | plugins.tls.Server; settings: ProxySettings; constructor(settings: ProxySettings) { this.settings = { ...settings, toHost: settings.toHost || 'localhost' }; } public async start() { const cleanUpSockets = (from: plugins.net.Socket, to: plugins.net.Socket) => { from.end(); to.end(); from.removeAllListeners(); to.removeAllListeners(); from.unpipe(); to.unpipe(); from.destroy(); to.destroy(); }; const normalizeIP = (ip: string): string[] => { // Handle IPv4-mapped IPv6 addresses if (ip.startsWith('::ffff:')) { const ipv4 = ip.slice(7); // Remove '::ffff:' prefix return [ip, ipv4]; } // Handle IPv4 addresses by adding IPv4-mapped IPv6 variant if (ip.match(/^\d{1,3}(\.\d{1,3}){3}$/)) { return [ip, `::ffff:${ip}`]; } return [ip]; }; const isAllowed = (value: string, patterns: string[]): boolean => { // Expand patterns to include both IPv4 and IPv6 variants const expandedPatterns = patterns.flatMap(normalizeIP); // Check if any variant of the IP matches any expanded pattern return normalizeIP(value).some(ip => expandedPatterns.some(pattern => plugins.minimatch(ip, pattern)) ); }; const findMatchingDomain = (serverName: string): DomainConfig | undefined => { return this.settings.domains.find(config => plugins.minimatch(serverName, config.domain)); }; const server = this.settings.sniEnabled ? plugins.tls.createServer({ ...this.settings, SNICallback: (serverName: string, cb: (err: Error | null, ctx?: plugins.tls.SecureContext) => void) => { console.log(`SNI request for domain: ${serverName}`); const domainConfig = findMatchingDomain(serverName); if (!domainConfig) { console.log(`SNI rejected: No matching domain config for ${serverName}`); cb(new Error(`No configuration for domain: ${serverName}`)); return; } // Create context with the provided TLS settings const ctx = plugins.tls.createSecureContext(this.settings); cb(null, ctx); } }) : plugins.net.createServer(); const handleConnection = (from: plugins.net.Socket | plugins.tls.TLSSocket) => { const remoteIP = from.remoteAddress || ''; let serverName = ''; if (this.settings.sniEnabled && from instanceof plugins.tls.TLSSocket) { serverName = (from as any).servername || ''; console.log(`TLS Connection from ${remoteIP} for domain: ${serverName}`); } // For TLS connections, we've already validated the domain in SNICallback if (!this.settings.sniEnabled || from instanceof plugins.tls.TLSSocket) { const domainConfig = serverName ? findMatchingDomain(serverName) : undefined; if (!domainConfig) { // If no matching domain config found, check default IPs if available if (!this.settings.defaultAllowedIPs || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) { console.log(`Connection rejected: No matching domain config for ${serverName || 'non-SNI'} from IP ${remoteIP}`); from.end(); return; } } else { // Check if IP is allowed for this domain if (!isAllowed(remoteIP, domainConfig.allowedIPs)) { console.log(`Connection rejected: IP ${remoteIP} not allowed for domain ${serverName}`); from.end(); return; } } } else if (!this.settings.defaultAllowedIPs || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) { console.log(`Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`); from.end(); return; } const to = plugins.net.createConnection({ host: this.settings.toHost!, port: this.settings.toPort, }); console.log(`Connection established: ${remoteIP} -> ${this.settings.toHost}:${this.settings.toPort}${serverName ? ` (SNI: ${serverName})` : ''}`); from.setTimeout(120000); from.pipe(to); to.pipe(from); from.on('error', () => { cleanUpSockets(from, to); }); to.on('error', () => { cleanUpSockets(from, to); }); from.on('close', () => { cleanUpSockets(from, to); }); to.on('close', () => { cleanUpSockets(from, to); }); from.on('timeout', () => { cleanUpSockets(from, to); }); to.on('timeout', () => { cleanUpSockets(from, to); }); from.on('end', () => { cleanUpSockets(from, to); }); to.on('end', () => { cleanUpSockets(from, to); }); this.netServer = server .on('connection', handleConnection) .on('secureConnection', handleConnection) .on('tlsClientError', (err, tlsSocket) => { console.log(`TLS Client Error: ${err.message}`); }) .on('error', (err) => { console.log(`Server Error: ${err.message}`); }) .listen(this.settings.fromPort); console.log(`PortProxy -> OK: Now listening on port ${this.settings.fromPort}${this.settings.sniEnabled ? ' (SNI enabled)' : ''}`); } public async stop() { const done = plugins.smartpromise.defer(); this.netServer.close(() => { done.resolve(); }); await done.promise; } }