Files
minisrv/zefie_wtvp_minisrv/includes/classes/WTV-MSNTV2.js
2026-05-01 19:07:47 -04:00

1775 lines
82 KiB
JavaScript

'use strict';
const fs = require('fs');
const path = require('path');
const url = require('url');
const tls = require('tls');
const crypto = require('crypto');
const RC4 = require('rc4-crypto');
const forge = require('node-forge');
const { http, https } = require('follow-redirects');
const { raw } = require('express');
class WTVMSNTV2 {
constructor(minisrv_config, service_name, wtvshared, sendToClient, net, runScriptInVM, handlePHP, handleCGI, ssid_sessions, WTVClientSessionData, socket_sessions) {
this.minisrv_config = minisrv_config;
this.service_name = service_name;
this.service_config = minisrv_config.services[service_name] || {};
this.wtvshared = wtvshared;
this.sendToClient = sendToClient;
this.net = net;
this.runScriptInVM = runScriptInVM || null;
this.handlePHP = handlePHP || null;
this.handleCGI = handleCGI || null;
this.ssid_sessions = ssid_sessions || [];
this.socket_sessions = socket_sessions || [];
this.WTVClientSessionData = WTVClientSessionData || null;
this.tlsContext = this.loadTlsContext();
this.forgeTlsCredentials = this.loadForgeTlsCredentials();
this.server = net.createServer((socket) => this.handleConnection(socket));
this.mimeTypes = {
html: 'text/html',
htm: 'text/html',
css: 'text/css',
js: 'application/javascript',
json: 'application/json',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
ico: 'image/x-icon',
txt: 'text/plain',
xml: 'application/xml',
csv: 'text/csv',
svgz: 'image/svg+xml',
mp3: 'audio/mpeg',
wav: 'audio/wav',
pdf: 'application/pdf'
};
}
listen(port, host = '0.0.0.0') {
this.server.listen(port, host);
console.log(` * MSNTV2 Proxy listening on ${host}:${port}`);
return this.server;
}
// Set sslv2_debug: true in the service config to enable SSL/TLS protocol-level
// debug logging (handshake stages, cipher setup, record enc/dec, write previews).
// Defaults to false so normal operation is not flooded with crypto noise.
get sslv2Debug() {
return this.service_config.sslv2_debug === true;
}
handleConnection(socket) {
socket.buffer = Buffer.alloc(0);
socket.id = `msntv2-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
socket.ssid = null;
socket.connectIntercept = null;
socket.tlsSocket = null;
socket.rawDataListener = (chunk) => this.handleData(socket, chunk);
socket.on('data', socket.rawDataListener);
socket.on('error', (err) => {
if (this.service_config.show_verbose_errors) console.error('[WTV-MSNTV2] socket error:', err.message);
});
}
handleData(socket, chunk) {
socket.buffer = Buffer.concat([socket.buffer, chunk]);
const headerEnd = socket.buffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return;
const headerBlock = socket.buffer.slice(0, headerEnd).toString('utf8');
const headerLines = headerBlock.split('\r\n');
const requestLine = headerLines.shift();
const requestParts = requestLine.split(' ');
if (requestParts.length < 3) {
this.writeError(socket, 400, 'Bad Request');
return;
}
const [method, requestUrl, protocol] = requestParts;
const headers = {};
const rawHeaders = [];
headerLines.forEach((line) => {
const idx = line.indexOf(':');
if (idx > -1) {
const name = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
headers[name] = value;
headers[name.toLowerCase()] = value;
rawHeaders.push(`${name}: ${value}`);
}
});
const contentLength = parseInt(headers['content-length'] || '0', 10) || 0;
const requestLength = headerEnd + 4 + contentLength;
if (socket.buffer.length < requestLength) return;
const body = socket.buffer.slice(headerEnd + 4, requestLength);
const remaining = socket.buffer.slice(requestLength);
socket.buffer = remaining;
if (method.toUpperCase() === 'CONNECT') {
if (remaining.length > 0) {
socket.unshift(remaining);
}
socket.buffer = Buffer.alloc(0);
this.handleConnect(socket, requestUrl);
return;
}
const request_headers = {
request: requestLine,
request_url: requestUrl,
raw_headers: `Request: ${requestLine}\r\n${rawHeaders.join('\r\n')}\r\n\r\n`,
post_data: body.length ? body : null
};
Object.assign(request_headers, headers);
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
if (verbose) {
console.log('[WTV-MSNTV2] incoming request:', requestLine);
if (body.length) {
console.log('[WTV-MSNTV2] request body length:', body.length);
console.log('[WTV-MSNTV2] request body (first 1024 bytes):', body.slice(0, 1024).toString('utf8'));
}
}
console.log(" * MSNTV2 %s for %s on socket %s", method, requestUrl, socket.id);
if (requestUrl.includes('?')) {
try {
const qs = requestUrl.slice(requestUrl.indexOf('?') + 1);
const params = {};
qs.split('&').forEach(p => {
const eq = p.indexOf('=');
if (eq > 0) params[decodeURIComponent(p.slice(0, eq))] = decodeURIComponent(p.slice(eq + 1).replace(/\+/g, ' '));
else if (p) params[decodeURIComponent(p)] = null;
});
console.log(' * MSNTV2 query params on socket %s', socket.id, params);
} catch (_) {}
}
const { domainIntercepted, filePath } = this.interceptRequest(requestUrl, socket.connectIntercept);
if (verbose) {
console.log('[WTV-MSNTV2] intercept check for:', requestUrl, '->', domainIntercepted ? (filePath ? 'local file' : 'intercepted/404') : 'proxy');
}
if (domainIntercepted) {
if (filePath) {
this.sendLocalFile(socket, filePath, request_headers);
} else {
console.warn(" * MSNTV2 404 for %s on socket %s (intercepted domain, missing local file)", requestUrl, socket.id);
this.writeError(socket, 404, 'Not Found', request_headers);
}
} else {
this.proxyRequest(socket, method, requestUrl, request_headers);
}
}
handleConnect(socket, requestUrl) {
const [host, portString] = requestUrl.split(':');
const port = parseInt(portString, 10) || 443;
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
const connectIntercept = this.getConnectIntercept(host);
if (verbose) console.log('[WTV-MSNTV2] CONNECT request:', requestUrl, 'intercept:', !!connectIntercept);
if (connectIntercept) {
if (!this.forgeTlsCredentials && !this.tlsContext) {
console.error('[WTV-MSNTV2] TLS intercept requested but no cert/key available');
this.writeError(socket, 502, 'Bad Gateway');
return;
}
socket.connectIntercept = connectIntercept;
socket.removeListener('data', socket.rawDataListener);
socket.write('HTTP/1.1 200 Connection Established\r\nProxy-agent: WTV-MSNTV2\r\n\r\n');
if (verbose) console.log('[WTV-MSNTV2] CONNECT intercepted for host', host, '-> local_dir=', connectIntercept.localDir);
this.setupSslv2Probe(socket, connectIntercept);
return;
}
const remote = this.net.connect(port, host, () => {
if (verbose) console.log('[WTV-MSNTV2] CONNECT tunnel established to', host + ':' + port);
socket.write('HTTP/1.1 200 Connection Established\r\nProxy-agent: WTV-MSNTV2\r\n\r\n');
socket.pipe(remote);
remote.pipe(socket);
});
remote.on('error', (err) => {
if (this.service_config.show_verbose_errors) console.error('[WTV-MSNTV2] CONNECT error:', err.message);
this.writeError(socket, 502, 'Bad Gateway');
});
}
setupTlsSocket(tlsSocket) {
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
const sslDebug = this.sslv2Debug;
tlsSocket.on('secureConnect', () => {
if (sslDebug) console.log('[WTV-MSNTV2] TLS handshake complete for intercepted CONNECT', tlsSocket.connectIntercept.match);
});
tlsSocket.on('data', (chunk) => this.handleTlsData(tlsSocket, chunk));
tlsSocket.on('error', (err) => {
if (verbose) console.error('[WTV-MSNTV2] TLS socket error:', err.message);
try { tlsSocket.destroy(); } catch (_) {}
});
tlsSocket.on('end', () => {
if (sslDebug) console.log('[WTV-MSNTV2] TLS socket ended:', tlsSocket.id);
tlsSocket.end();
});
tlsSocket.on('close', () => {
if (sslDebug) console.log('[WTV-MSNTV2] TLS socket closed:', tlsSocket.id);
});
}
setupForgeTls(socket, connectIntercept) {
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
const sslDebug = this.sslv2Debug;
const creds = this.forgeTlsCredentials;
if (!creds) {
console.error('[WTV-MSNTV2] missing forge TLS credentials');
this.writeError(socket, 502, 'Bad Gateway');
return;
}
const forgeConnection = forge.tls.createConnection({
server: true,
caStore: [],
sessionCache: {},
cipherSuites: [
forge.tls.CipherSuites.TLS_RSA_WITH_AES_128_CBC_SHA,
forge.tls.CipherSuites.TLS_RSA_WITH_AES_256_CBC_SHA,
forge.tls.CipherSuites.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
forge.tls.CipherSuites.TLS_RSA_WITH_RC4_128_SHA,
forge.tls.CipherSuites.TLS_RSA_WITH_RC4_128_MD5
],
getCertificate: (connection, hint) => creds.cert,
getPrivateKey: (connection, cert) => creds.key,
verify: (connection, verified, depth, certs) => true,
connected: (connection) => {
if (sslDebug) console.log('[WTV-MSNTV2] forge TLS handshake complete');
},
tlsDataReady: (connection) => {
const data = connection.tlsData.getBytes();
socket.write(Buffer.from(data, 'binary'));
},
dataReady: (connection) => {
const data = connection.data.getBytes();
if (sslDebug) console.log('[WTV-MSNTV2] forge decrypted data length:', data.length);
handleForgeData(connection, data);
},
closed: () => {
if (sslDebug) console.log('[WTV-MSNTV2] forge TLS connection closed');
},
error: (connection, error) => {
console.error('[WTV-MSNTV2] forge TLS error:', error && error.message ? error.message : error);
if (connection && connection.close) {
try { connection.close(); } catch (_) {}
}
if (socket && !socket.destroyed) {
try { socket.destroy(); } catch (_) {}
}
}
});
const handleForgeData = (connection, data) => {
connection.buffer = Buffer.concat([connection.buffer || Buffer.alloc(0), Buffer.from(data, 'binary')]);
while (true) {
const headerEnd = connection.buffer.indexOf('\r\n\r\n');
if (headerEnd < 0) break;
const headerBlock = connection.buffer.slice(0, headerEnd).toString('utf8');
const headerLines = headerBlock.split('\r\n');
const requestLine = headerLines.shift();
const requestParts = requestLine.split(' ');
if (requestParts.length < 3) {
if (verbose) {
console.warn('[WTV-MSNTV2] forge TLS invalid request line:', requestLine);
console.warn('[WTV-MSNTV2] forge TLS raw header block:', headerBlock);
}
this.writeError(socket, 400, 'Bad Request');
return;
}
const [method, requestUrl, protocol] = requestParts;
const headers = {};
const rawHeaders = [];
headerLines.forEach((line) => {
const idx = line.indexOf(':');
if (idx > -1) {
const name = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
headers[name] = value;
headers[name.toLowerCase()] = value;
rawHeaders.push(`${name}: ${value}`);
}
});
const contentLength = parseInt(headers['content-length'] || '0', 10) || 0;
const requestLength = headerEnd + 4 + contentLength;
if (connection.buffer.length < requestLength) break;
const body = connection.buffer.slice(headerEnd + 4, requestLength);
connection.buffer = connection.buffer.slice(requestLength);
const request_headers = {
request: requestLine,
request_url: requestUrl,
raw_headers: `Request: ${requestLine}\r\n${rawHeaders.join('\r\n')}\r\n\r\n`,
post_data: body.length ? body : null
};
Object.assign(request_headers, headers);
if (verbose) {
console.log('[WTV-MSNTV2] forge decrypted request:', requestLine);
console.log('[WTV-MSNTV2] forge decrypted headers:\n' + rawHeaders.join('\r\n'));
}
console.log(" * MSNTV2(Forge) %s for %s on socket %s", method, requestUrl, socket.id);
if (requestUrl.includes('?')) {
try {
const qs = requestUrl.slice(requestUrl.indexOf('?') + 1);
const params = {};
qs.split('&').forEach(p => {
const eq = p.indexOf('=');
if (eq > 0) params[decodeURIComponent(p.slice(0, eq))] = decodeURIComponent(p.slice(eq + 1).replace(/\+/g, ' '));
else if (p) params[decodeURIComponent(p)] = null;
});
console.log(' * MSNTV2(Forge) query params on socket %s', socket.id, params);
} catch (_) {}
}
const { domainIntercepted: di, filePath: fp } = this.interceptRequest(requestUrl, connectIntercept);
if (di) {
if (fp) {
this.sendLocalFile(socket, fp, request_headers);
} else {
console.warn(" * MSNTV2(Forge) 404 for %s on socket %s (intercepted domain, missing local file)", requestUrl, socket.id);
this.writeError(socket, 404, 'Not Found', request_headers);
}
} else {
this.proxyRequest(socket, method, requestUrl, request_headers);
}
}
};
socket.on('data', (chunk) => {
forgeConnection.process(chunk.toString('binary'));
});
socket.forgeTls = forgeConnection;
}
setupSslv2Probe(socket, connectIntercept) {
socket.sslv2Probe = true;
socket.sslv2Buffer = Buffer.alloc(0);
socket.sslv2ConnectIntercept = connectIntercept;
socket.sslv2ProbeListener = (chunk) => this.handleSslv2Probe(socket, chunk);
socket.on('data', socket.sslv2ProbeListener);
}
handleSslv2Probe(socket, chunk) {
socket.sslv2Buffer = Buffer.concat([socket.sslv2Buffer, chunk]);
const header = this.parseSslv2Header(socket.sslv2Buffer);
if (!header || socket.sslv2Buffer.length < header.headerLength + header.length) return;
const payload = socket.sslv2Buffer.slice(header.headerLength, header.headerLength + header.length);
const type = payload[0];
const sslDebug = this.sslv2Debug;
if (sslDebug) {
console.log('[WTV-MSNTV2] SSLv2 probe header:', header, 'type:', type);
}
if (type === 1) {
socket.removeListener('data', socket.sslv2ProbeListener);
socket.sslv2Probe = false;
this.setupLegacySslv2(socket, socket.sslv2ConnectIntercept, socket.sslv2Buffer);
return;
}
socket.removeListener('data', socket.sslv2ProbeListener);
socket.sslv2Probe = false;
if (this.forgeTlsCredentials) {
this.setupForgeTls(socket, socket.sslv2ConnectIntercept);
if (socket.sslv2Buffer.length) {
socket.forgeTls.process(socket.sslv2Buffer.toString('binary'));
}
} else if (this.tlsContext) {
const tlsSocket = new tls.TLSSocket(socket, {
isServer: true,
secureContext: this.tlsContext,
requestCert: false,
rejectUnauthorized: false,
secureProtocol: 'TLS_method',
minVersion: 'SSLv1',
maxVersion: 'TLSv1.3',
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT || 0
});
tlsSocket.connectIntercept = socket.sslv2ConnectIntercept;
tlsSocket.id = socket.id;
this.setupTlsSocket(tlsSocket);
socket.tlsSocket = tlsSocket;
if (socket.sslv2Buffer.length) {
tlsSocket.emit('data', socket.sslv2Buffer);
}
} else {
this.writeError(socket, 502, 'Bad Gateway');
}
}
setupLegacySslv2(socket, connectIntercept, initialPayload) {
const verbose = this.sslv2Debug;
const creds = this.forgeTlsCredentials;
if (!creds) {
console.error('[WTV-MSNTV2] missing SSLv2 TLS credentials');
this.writeError(socket, 502, 'Bad Gateway');
return;
}
const certBytes = Buffer.from(forge.pem.decode(creds.certPem)[0].body, 'binary');
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 legacy handshake start, initial payload length:', initialPayload ? initialPayload.length : 0);
}
socket.sslv2 = {
stage: 'hello',
buffer: Buffer.from(initialPayload || Buffer.alloc(0)),
connectIntercept,
clientChallenge: null,
serverChallenge: crypto.randomBytes(16),
cipherSpec: null,
cipherInfo: null,
certBytes,
connectionId: crypto.randomBytes(16),
sessionId: crypto.randomBytes(16),
keyPem: creds.keyPem,
certPem: creds.certPem,
clientCipher: null,
serverCipher: null,
clientMacKey: null,
serverMacKey: null,
masterSecret: null,
clientSequence: 0,
serverSequence: 0
};
socket.on('data', (chunk) => this.handleSslv2Data(socket, chunk));
socket.on('error', (err) => {
if (verbose) console.error('[WTV-MSNTV2] SSLv2 socket error:', err.message);
try { socket.destroy(); } catch (_) {}
});
socket.on('close', () => {
if (verbose) console.log('[WTV-MSNTV2] SSLv2 socket closed:', socket.id);
});
this.handleSslv2Data(socket, Buffer.alloc(0));
}
parseSslv2Header(buffer) {
if (buffer.length < 2) return null;
const first = buffer[0];
if (first & 0x80) {
return {
headerLength: 2,
length: ((first & 0x7f) << 8) | buffer[1],
padding: 0,
isEscape: false
};
}
if (buffer.length < 3) return null;
return {
headerLength: 3,
length: ((first & 0x3f) << 8) | buffer[1],
padding: buffer[2],
isEscape: (first & 0x40) !== 0
};
}
parseSslv2ClientHello(payload) {
if (payload.length < 9 || payload[0] !== 1) return null;
const cipherSpecLength = payload.readUInt16BE(3);
const sessionIdLength = payload.readUInt16BE(5);
const challengeLength = payload.readUInt16BE(7);
const totalLength = 9 + cipherSpecLength + sessionIdLength + challengeLength;
if (payload.length < totalLength) return null;
const cipherSpecs = [];
let offset = 9;
for (let i = 0; i < cipherSpecLength; i += 3) {
cipherSpecs.push(payload.slice(offset + i, offset + i + 3));
}
offset += cipherSpecLength;
const sessionId = payload.slice(offset, offset + sessionIdLength);
offset += sessionIdLength;
const challenge = payload.slice(offset, offset + challengeLength);
return { cipherSpecs, sessionId, challenge, totalLength };
}
parseSslv2ClientMasterKey(payload) {
if (payload.length < 10 || payload[0] !== 2) return null;
const cipherKind = payload.slice(1, 4);
const clearKeyLength = payload.readUInt16BE(4);
const encryptedKeyLength = payload.readUInt16BE(6);
const keyArgLength = payload.readUInt16BE(8);
const totalLength = 10 + clearKeyLength + encryptedKeyLength + keyArgLength;
if (payload.length < totalLength) return null;
let offset = 10;
const clearKey = payload.slice(offset, offset + clearKeyLength);
offset += clearKeyLength;
const encryptedKey = payload.slice(offset, offset + encryptedKeyLength);
offset += encryptedKeyLength;
const keyArg = payload.slice(offset, offset + keyArgLength);
return { cipherKind, clearKey, encryptedKey, keyArg, totalLength };
}
parseSslv2ClientFinished(payload) {
if (payload.length < 2 || payload[0] !== 3) return null;
return { finished: payload.slice(1) };
}
pkcs1Type2Unpad(block) {
if (!block || block.length < 11) return null;
if (block[0] !== 0x00 || block[1] !== 0x02) return null;
let idx = 2;
while (idx < block.length && block[idx] !== 0x00) {
idx += 1;
}
if (idx >= block.length) return null;
return block.slice(idx + 1);
}
pkcs1Type1Pad(data, blockSize) {
const msg = Buffer.from(data || Buffer.alloc(0));
if (msg.length > blockSize - 3) {
throw new Error('PKCS#1 type-1 message too long');
}
const psLength = blockSize - msg.length - 3;
const ps = Buffer.alloc(psLength, 0xff);
return Buffer.concat([Buffer.from([0x00, 0x01]), ps, Buffer.from([0x00]), msg]);
}
privateEncryptPkcs1Compat(privateKeyPem, data) {
try {
return crypto.privateEncrypt({
key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_PADDING
}, data);
} catch (err) {
const keyObj = crypto.createPrivateKey(privateKeyPem);
const modulusBits = keyObj.asymmetricKeyDetails && keyObj.asymmetricKeyDetails.modulusLength
? keyObj.asymmetricKeyDetails.modulusLength
: 2048;
const blockSize = Math.ceil(modulusBits / 8);
const block = this.pkcs1Type1Pad(data, blockSize);
return crypto.privateEncrypt({
key: privateKeyPem,
padding: crypto.constants.RSA_NO_PADDING
}, block);
}
}
decryptSslv2PreMaster(state, encryptedKey) {
try {
return crypto.privateDecrypt({
key: state.keyPem,
padding: crypto.constants.RSA_PKCS1_PADDING
}, encryptedKey);
} catch (err) {
const msg = err && err.message ? err.message : '';
if (!msg.includes('RSA_PKCS1_PADDING is no longer supported for private decryption')) {
throw err;
}
const rawBlock = crypto.privateDecrypt({
key: state.keyPem,
padding: crypto.constants.RSA_NO_PADDING
}, encryptedKey);
const unpadded = this.pkcs1Type2Unpad(rawBlock);
if (!unpadded) {
throw new Error('Failed PKCS#1 type-2 unpad for SSLv2 ClientMasterKey');
}
return unpadded;
}
}
buildSslv2Record(payload) {
const header = Buffer.alloc(2);
const length = payload.length;
header[0] = 0x80 | ((length >> 8) & 0x7f);
header[1] = length & 0xff;
return Buffer.concat([header, payload]);
}
sslv2SequenceBuffer(sequence) {
const out = Buffer.alloc(4);
out.writeUInt32BE((sequence >>> 0), 0);
return out;
}
sslv2Mac(macKey, payload, sequence, paddingData) {
// SSLv2 MAC: HASH(secret, actual-data, padding-data, sequence-number)
return crypto.createHash('md5')
.update(macKey)
.update(payload)
.update(paddingData || Buffer.alloc(0))
.update(this.sslv2SequenceBuffer(sequence))
.digest();
}
buildSslv2EncryptedRecord(state, payload) {
const mac = this.sslv2Mac(state.serverMacKey, payload, state.serverSequence, Buffer.alloc(0));
const plain = Buffer.concat([mac, payload]);
const encrypted = state.serverCipher.update(plain);
const length = encrypted.length;
let header;
if (state.cipherInfo && state.cipherInfo.ivLength === 0) {
// For stream ciphers (RC4), legacy peers commonly use 2-byte SSLv2 record headers.
header = Buffer.alloc(2);
header[0] = 0x80 | ((length >> 8) & 0x7f);
header[1] = length & 0xff;
} else {
header = Buffer.alloc(3);
header[0] = (length >> 8) & 0x3f;
header[1] = length & 0xff;
header[2] = 0x00;
}
const verbose = this.sslv2Debug;
if (verbose && payload.length <= 20) {
console.log('[WTV-MSNTV2] buildSslv2EncryptedRecord: serverSeq=' + state.serverSequence + ', payloadLen=' + payload.length + ', payload=' + payload.toString('hex') + ', macLen=16');
}
state.serverSequence += 1;
return Buffer.concat([header, encrypted]);
}
decodeSslv2EncryptedRecord(state, encryptedRecord, header) {
const verbose = this.sslv2Debug;
const decrypted = state.clientCipher.update(encryptedRecord);
const macLen = state.cipherInfo && state.cipherInfo.macLength ? state.cipherInfo.macLength : 16;
if (!decrypted || decrypted.length < macLen) {
return null;
}
const receivedMac = decrypted.slice(0, macLen);
let payload = decrypted.slice(macLen);
if (header.padding && payload.length >= header.padding) {
payload = payload.slice(0, payload.length - header.padding);
}
const expectedMac = this.sslv2Mac(state.clientMacKey, payload, state.clientSequence, Buffer.alloc(0));
if (verbose && payload.length <= 20) {
console.log('[WTV-MSNTV2] decodeSslv2EncryptedRecord: clientSeq=' + state.clientSequence + ', payloadLen=' + payload.length + ', payload=' + payload.toString('hex') + ', receivedMac=' + receivedMac.toString('hex') + ', expectedMac=' + expectedMac.toString('hex'));
}
if (!crypto.timingSafeEqual(receivedMac, expectedMac)) {
if (verbose) {
console.warn('[WTV-MSNTV2] SSLv2 MAC mismatch on encrypted record seq=', state.clientSequence);
}
return null;
}
state.clientSequence += 1;
return payload;
}
buildSslv2ServerHello(state) {
const sessionIdHit = Buffer.from([0x00]);
const certificateTypeX509 = Buffer.from([0x01]);
const version = Buffer.from([0x00, 0x02]);
const certLength = Buffer.alloc(2);
certLength.writeUInt16BE(state.certBytes.length, 0);
const cipherSpecLength = Buffer.alloc(2);
cipherSpecLength.writeUInt16BE(state.cipherSpec.length, 0);
const sessionIdLength = Buffer.alloc(2);
sessionIdLength.writeUInt16BE(state.connectionId.length, 0);
const payload = Buffer.concat([
Buffer.from([4]),
sessionIdHit,
certificateTypeX509,
version,
certLength,
cipherSpecLength,
sessionIdLength,
state.certBytes,
state.cipherSpec,
state.connectionId
]);
return this.buildSslv2Record(payload);
}
setupSslv2Cipher(state, masterKey) {
if (state.cipherInfo.algorithm === 'rc4') {
// SSLv2 RC4 key schedule:
// KM0 = MD5(master_key, '0', challenge, connection_id)
// KM1 = MD5(master_key, '1', challenge, connection_id)
// client_write = KM1, server_write = KM0
const km0 = crypto.createHash('md5')
.update(masterKey)
.update(Buffer.from('0', 'ascii'))
.update(state.clientChallenge)
.update(state.connectionId)
.digest();
const km1 = crypto.createHash('md5')
.update(masterKey)
.update(Buffer.from('1', 'ascii'))
.update(state.clientChallenge)
.update(state.connectionId)
.digest();
const clientWriteKey = km1.slice(0, 16);
const serverWriteKey = km0.slice(0, 16);
const clientRc4 = new RC4.RC4(clientWriteKey);
const serverRc4 = new RC4.RC4(serverWriteKey);
state.clientCipher = {
update: (data) => {
const out = clientRc4.updateFromBuffer(Buffer.from(data));
return Buffer.isBuffer(out) ? out : Buffer.from(out);
}
};
state.serverCipher = {
update: (data) => {
const out = serverRc4.updateFromBuffer(Buffer.from(data));
return Buffer.isBuffer(out) ? out : Buffer.from(out);
}
};
// For these SSLv2 MD5 suites, the write keys are also the MAC secrets.
state.clientMacKey = clientWriteKey;
state.serverMacKey = serverWriteKey;
} else {
const keyLen = state.cipherInfo.keyLength;
const ivLen = state.cipherInfo.ivLength || 0;
const seed = Buffer.concat([state.clientChallenge, state.connectionId, state.keyArg || Buffer.alloc(0)]);
const required = keyLen * 2 + ivLen * 2;
let data = Buffer.alloc(0);
let counter = 1;
while (data.length < required) {
const pad = Buffer.alloc(counter, counter);
data = Buffer.concat([data, crypto.createHash('md5').update(masterKey).update(pad).update(seed).digest()]);
counter += 1;
}
const clientKey = data.slice(0, keyLen);
const serverKey = data.slice(keyLen, keyLen * 2);
const clientIv = ivLen ? data.slice(keyLen * 2, keyLen * 2 + ivLen) : null;
const serverIv = ivLen ? data.slice(keyLen * 2 + ivLen, keyLen * 2 + ivLen * 2) : null;
state.clientCipher = crypto.createDecipheriv(state.cipherInfo.algorithm, clientKey, clientIv || Buffer.alloc(0));
state.serverCipher = crypto.createCipheriv(state.cipherInfo.algorithm, serverKey, serverIv || Buffer.alloc(0));
state.clientMacKey = clientKey;
state.serverMacKey = serverKey;
}
}
handleSslv2Data(socket, chunk) {
const state = socket.sslv2;
const verbose = this.sslv2Debug;
if (!state) return;
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 handleSslv2Data stage=', state.stage, 'incoming chunk=', chunk.length, 'buffer before=', state.buffer.length);
}
state.buffer = Buffer.concat([state.buffer, chunk]);
while (true) {
const header = this.parseSslv2Header(state.buffer);
if (!header || state.buffer.length < header.headerLength + header.length) break;
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 parsed record header=', header);
}
const record = state.buffer.slice(header.headerLength, header.headerLength + header.length);
state.buffer = state.buffer.slice(header.headerLength + header.length);
let plainRecord = record;
const shouldDecrypt = !!state.clientCipher && state.stage !== 'hello' && state.stage !== 'master_key';
if (shouldDecrypt) {
if (!state.clientCipher || !state.clientMacKey) {
console.error('[WTV-MSNTV2] SSLv2 received encrypted record before cipher setup');
this.writeError(socket, 400, 'Bad SSLv2 Encrypted Record');
return;
}
plainRecord = this.decodeSslv2EncryptedRecord(state, record, header);
if (!plainRecord) {
console.error('[WTV-MSNTV2] SSLv2 failed to decode encrypted record');
this.writeError(socket, 400, 'Bad SSLv2 Encrypted Record');
return;
}
} else if (header.padding) {
plainRecord = record.slice(0, record.length - header.padding);
}
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 record length=', plainRecord.length, 'remaining buffer=', state.buffer.length);
}
if (state.stage === 'hello') {
const hello = this.parseSslv2ClientHello(plainRecord);
if (!hello) {
if (verbose) console.log('[WTV-MSNTV2] SSLv2 failed to parse ClientHello');
this.writeError(socket, 400, 'Bad SSLv2 Hello');
return;
}
state.clientChallenge = hello.challenge;
state.clientSessionId = hello.sessionId;
const specsHex = hello.cipherSpecs.map((spec) => spec.toString('hex'));
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 client hello cipher specs:', specsHex);
}
state.cipherSpec = hello.cipherSpecs.find((spec) => this.sslv2CipherInfo(spec));
if (!state.cipherSpec) {
console.error('[WTV-MSNTV2] unsupported SSLv2 cipher spec list:', specsHex.join(', '));
this.writeError(socket, 502, 'Unsupported Cipher');
return;
}
state.cipherInfo = this.sslv2CipherInfo(state.cipherSpec);
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 selected cipher spec:', state.cipherSpec.toString('hex'), 'info:', state.cipherInfo);
}
const serverHelloRecord = this.buildSslv2ServerHello(state);
socket.write(serverHelloRecord);
// Count plaintext ServerHello in each direction's sequence tracking so
// ServerVerify uses seq=1 for legacy WinCE Schannel behavior.
state.serverSequence += 1;
state.clientSequence += 1;
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 wrote ServerHello');
console.log('[WTV-MSNTV2] SSLv2 ServerHello bytes (first 96):', serverHelloRecord.slice(0, 96).toString('hex'));
}
state.stage = 'master_key';
continue;
}
if (state.stage === 'master_key') {
const masterKey = this.parseSslv2ClientMasterKey(plainRecord);
if (!masterKey) {
if (verbose) console.log('[WTV-MSNTV2] SSLv2 failed to parse ClientMasterKey');
this.writeError(socket, 400, 'Bad SSLv2 MasterKey');
return;
}
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 BEFORE ClientMasterKey: clientSeq=' + state.clientSequence + ', serverSeq=' + state.serverSequence);
}
const cipherKindHex = masterKey.cipherKind.toString('hex');
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 ClientMasterKey cipherKind=', cipherKindHex, 'clearKeyLen=', masterKey.clearKey.length, 'encryptedKeyLen=', masterKey.encryptedKey.length, 'keyArgLen=', masterKey.keyArg.length);
}
state.keyArg = masterKey.keyArg;
let secret = masterKey.clearKey;
if (secret.length === 0 && masterKey.encryptedKey.length > 0) {
try {
secret = this.decryptSslv2PreMaster(state, masterKey.encryptedKey);
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 decrypted pre-master key length=', secret.length);
}
} catch (err) {
console.error('[WTV-MSNTV2] SSLv2 private decrypt failed:', err.message);
this.writeError(socket, 502, 'Bad Gateway');
return;
}
}
if (!secret || secret.length === 0) {
console.error('[WTV-MSNTV2] SSLv2 missing secret');
this.writeError(socket, 502, 'Bad Gateway');
return;
}
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 masterKey=', secret.toString('hex'));
}
state.masterSecret = secret;
this.setupSslv2Cipher(state, secret);
const serverVerifyRecord = this.buildSslv2ServerVerify(state);
socket.write(serverVerifyRecord);
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 wrote ServerVerify seq=', state.serverSequence - 1);
console.log('[WTV-MSNTV2] SSLv2 ServerVerify bytes (first 64):', serverVerifyRecord.slice(0, 64).toString('hex'));
}
// Per OpenSSL s2_srvr.c: server sends ServerVerify AND ServerFinished
// BEFORE waiting for ClientFinished. WinCE Schannel's state machine
// expects ServerFinished to arrive before it sends ClientFinished.
// Sending ServerFinished late (after ClientFinished) causes the client
// to be in 'open' state already, treating ServerFinished as application
// data, which fails and triggers a RST.
const serverFinishedRecord = this.buildSslv2ServerFinished(state);
socket.write(serverFinishedRecord);
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 wrote ServerFinished seq=', state.serverSequence - 1);
console.log('[WTV-MSNTV2] SSLv2 ServerFinished mode=', state.lastServerFinishedMode || 'server', 'clientSessionIdLen=', state.clientSessionId ? state.clientSessionId.length : 0);
console.log('[WTV-MSNTV2] SSLv2 ServerFinished payload data:', (state.lastServerFinishedData || Buffer.alloc(0)).toString('hex'));
console.log('[WTV-MSNTV2] SSLv2 ServerFinished bytes (first 64):', serverFinishedRecord.slice(0, 64).toString('hex'));
}
state.clientSequence += 1;
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 after sending ServerVerify+ServerFinished: clientSeq=' + state.clientSequence + ', serverSeq=' + state.serverSequence);
}
state.stage = 'client_finished';
continue;
}
if (state.stage === 'client_finished') {
const finished = this.parseSslv2ClientFinished(plainRecord);
if (!finished) {
if (verbose) console.log('[WTV-MSNTV2] SSLv2 failed to parse ClientFinished');
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 ClientFinished first byte=', plainRecord.length ? plainRecord[0] : null, 'hex(first 48)=', plainRecord.slice(0, 48).toString('hex'));
}
this.writeError(socket, 400, 'Bad SSLv2 Finished');
return;
}
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 received ClientFinished length=', finished.finished.length, 'clientSeq=', state.clientSequence);
}
if (!finished.finished.equals(state.connectionId)) {
console.error('[WTV-MSNTV2] SSLv2 ClientFinished mismatch with connection-id');
this.writeError(socket, 400, 'Bad SSLv2 Finished');
return;
}
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 handshake complete, transitioning to open');
}
state.stage = 'open';
continue;
}
if (state.stage === 'open') {
if (verbose) {
console.log('[WTV-MSNTV2] SSLv2 application record length:', plainRecord.length);
}
if (plainRecord && plainRecord.length) {
this.handleSslv2ApplicationData(socket, plainRecord);
}
continue;
}
break;
}
}
sslv2CipherInfo(cipherSpec) {
const cipherKey = cipherSpec.toString('hex');
const mapping = {
'010080': { algorithm: 'rc4', keyLength: 16, ivLength: 0, macLength: 16 },
'070080': { algorithm: 'des-ede3-cbc', keyLength: 24, ivLength: 8, macLength: 16 },
'060080': { algorithm: 'des-cbc', keyLength: 8, ivLength: 8, macLength: 16 }
};
return mapping[cipherKey] || null;
}
buildSslv2ServerVerify(state) {
// SSLv2 SERVER-VERIFY echoes the client challenge bytes.
const payload = Buffer.concat([Buffer.from([5]), state.clientChallenge || Buffer.alloc(0)]);
if (state.serverCipher && state.serverMacKey) {
return this.buildSslv2EncryptedRecord(state, payload);
}
return this.buildSslv2Record(payload);
}
buildSslv2ServerFinished(state) {
// Compatibility selector for WinCE Schannel variants:
// - default/server: use server-generated session id (spec behavior)
// - client: echo ClientHello session id (if 16 bytes)
// - connection/connid: reuse ServerHello connection id
// - auto: prefer client session id if 16 bytes, else spec behavior
const configuredMode = String(this.service_config.sslv2_serverfinished_mode || 'server').toLowerCase();
let selectedMode = configuredMode;
let finishedData = state.sessionId;
if (configuredMode === 'client') {
if (state.clientSessionId && state.clientSessionId.length === 16) {
finishedData = state.clientSessionId;
} else {
selectedMode = 'server-fallback';
}
} else if (configuredMode === 'connection' || configuredMode === 'connid') {
finishedData = state.connectionId;
} else if (configuredMode === 'auto') {
if (state.clientSessionId && state.clientSessionId.length === 16) {
finishedData = state.clientSessionId;
selectedMode = 'client-auto';
} else {
selectedMode = 'server-auto';
}
}
state.lastServerFinishedMode = selectedMode;
state.lastServerFinishedData = finishedData;
const payload = Buffer.concat([Buffer.from([6]), finishedData]);
if (state.serverCipher && state.serverMacKey) {
return this.buildSslv2EncryptedRecord(state, payload);
}
return this.buildSslv2Record(payload);
}
_logSslv2RequestHeaders(socket, requestLine, rawHeaders) {
try {
console.log(' * MSNTV2(SSLv2) request headers on socket %s', socket.id);
console.log(' ' + requestLine);
console.log(rawHeaders);
const qStart = requestLine.indexOf('?');
if (qStart !== -1) {
const qEnd = requestLine.lastIndexOf(' ');
const qs = requestLine.slice(qStart + 1, qEnd > qStart ? qEnd : undefined);
if (qs) {
const params = {};
qs.split('&').forEach(p => {
const eq = p.indexOf('=');
if (eq > 0) params[decodeURIComponent(p.slice(0, eq))] = decodeURIComponent(p.slice(eq + 1).replace(/\+/g, ' '));
else if (p) params[decodeURIComponent(p)] = null;
});
console.log(' * MSNTV2(SSLv2) query params on socket %s', socket.id, params);
}
}
} catch (_) { /* ignore logging failure */ }
}
_logSslv2ResponseHeaders(socket, payload) {
try {
const text = Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload);
if (!text.startsWith('HTTP/')) return;
const headerEnd = text.indexOf('\r\n\r\n') >= 0
? text.indexOf('\r\n\r\n')
: text.indexOf('\n\n');
const headerBlock = headerEnd >= 0 ? text.slice(0, headerEnd) : text;
const lines = headerBlock.replace(/\r/g, '').split('\n').filter(Boolean);
if (!lines.length) return;
console.log(' * MSNTV2(SSLv2) response headers on socket %s', socket.id);
console.log(lines);
} catch (_) { /* ignore logging failure */ }
}
handleSslv2ApplicationData(socket, data) {
socket.sslv2AppBuffer = Buffer.concat([socket.sslv2AppBuffer || Buffer.alloc(0), data]);
while (true) {
const headerEnd = socket.sslv2AppBuffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return;
const headerBlock = socket.sslv2AppBuffer.slice(0, headerEnd).toString('utf8');
const headerLines = headerBlock.split('\r\n');
const requestLine = headerLines.shift();
const requestParts = requestLine.split(' ');
if (requestParts.length < 3) {
this.writeError(socket, 400, 'Bad Request');
return;
}
const [method, requestUrl, protocol] = requestParts;
const headers = {};
const rawHeaders = [];
headerLines.forEach((line) => {
const idx = line.indexOf(':');
if (idx > -1) {
const name = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
headers[name] = value;
headers[name.toLowerCase()] = value;
rawHeaders.push(`${name}: ${value}`);
}
});
const contentLength = parseInt(headers['content-length'] || '0', 10) || 0;
const requestLength = headerEnd + 4 + contentLength;
if (socket.sslv2AppBuffer.length < requestLength) return;
const body = socket.sslv2AppBuffer.slice(headerEnd + 4, requestLength);
const remaining = socket.sslv2AppBuffer.slice(requestLength);
socket.sslv2AppBuffer = remaining;
const request_headers = {
request: requestLine,
request_url: requestUrl,
raw_headers: `Request: ${requestLine}\r\n${rawHeaders.join('\r\n')}\r\n\r\n`,
post_data: body.length ? body : null
};
this._populateQuery(request_headers);
Object.assign(request_headers, headers);
console.log(" * MSNTV2(SSLv2) %s for %s on socket %s", method, requestUrl, socket.id);
this._logSslv2RequestHeaders(socket, requestLine, rawHeaders);
const { domainIntercepted: sslDi, filePath: sslFp } = this.interceptRequest(requestUrl, socket.sslv2.connectIntercept);
if (sslDi) {
if (sslFp) {
this.sendLocalFile(socket, sslFp, request_headers);
} else {
console.warn(" * MSNTV2(SSLv2) 404 for %s on socket %s (intercepted domain, missing local file)", requestUrl, socket.id);
this.writeError(socket, 404, 'Not Found', request_headers);
}
} else {
this.proxyRequest(socket, method, requestUrl, request_headers);
}
}
}
loadTlsContext() {
try {
const certCandidates = [
['msntv2/msn_domains.crt', 'msntv2/msn_domains.key']
];
let certFile = null;
let keyFile = null;
for (const [certPath, keyPath] of certCandidates) {
const candidateCert = this.wtvshared.getServiceDep(certPath, true);
const candidateKey = this.wtvshared.getServiceDep(keyPath, true);
if (candidateCert && candidateKey) {
certFile = candidateCert;
keyFile = candidateKey;
break;
}
}
if (!certFile || !keyFile) return null;
const certPem = fs.readFileSync(certFile);
const keyPem = fs.readFileSync(keyFile);
return tls.createSecureContext({
cert: certPem,
key: keyPem,
ciphers: 'DEFAULT@SECLEVEL=0',
minVersion: 'TLSv1',
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT || 0
});
} catch (err) {
console.error('[WTV-MSNTV2] failed to load TLS cert/key:', err.message);
return null;
}
}
loadForgeTlsCredentials() {
try {
const certCandidates = [
['msntv2/msn_domains.crt', 'msntv2/msn_domains.key']
];
let certFile = null;
let keyFile = null;
for (const [certPath, keyPath] of certCandidates) {
const candidateCert = this.wtvshared.getServiceDep(certPath, true);
const candidateKey = this.wtvshared.getServiceDep(keyPath, true);
if (candidateCert && candidateKey) {
certFile = candidateCert;
keyFile = candidateKey;
break;
}
}
if (!certFile || !keyFile) return null;
const certPem = fs.readFileSync(certFile, 'utf8');
const keyPem = fs.readFileSync(keyFile, 'utf8');
return {
certPem,
keyPem,
cert: forge.pki.certificateFromPem(certPem),
key: forge.pki.privateKeyFromPem(keyPem)
};
} catch (err) {
console.error('[WTV-MSNTV2] failed to load forge TLS cert/key:', err.message);
return null;
}
}
handleTlsData(tlsSocket, chunk) {
tlsSocket.buffer = Buffer.concat([tlsSocket.buffer || Buffer.alloc(0), chunk]);
const headerEnd = tlsSocket.buffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return;
const headerBlock = tlsSocket.buffer.slice(0, headerEnd).toString('utf8');
const headerLines = headerBlock.split('\r\n');
const requestLine = headerLines.shift();
const requestParts = requestLine.split(' ');
if (requestParts.length < 3) {
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
if (verbose) {
console.warn('[WTV-MSNTV2] TLS invalid request line:', requestLine);
console.warn('[WTV-MSNTV2] TLS raw header block:', headerBlock);
}
this.writeError(tlsSocket, 400, 'Bad Request');
return;
}
const [method, requestUrl, protocol] = requestParts;
const headers = {};
const rawHeaders = [];
headerLines.forEach((line) => {
const idx = line.indexOf(':');
if (idx > -1) {
const name = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
headers[name] = value;
headers[name.toLowerCase()] = value;
rawHeaders.push(`${name}: ${value}`);
}
});
const contentLength = parseInt(headers['content-length'] || '0', 10) || 0;
const requestLength = headerEnd + 4 + contentLength;
if (tlsSocket.buffer.length < requestLength) return;
const body = tlsSocket.buffer.slice(headerEnd + 4, requestLength);
const remaining = tlsSocket.buffer.slice(requestLength);
tlsSocket.buffer = remaining;
const request_headers = {
request: requestLine,
request_url: requestUrl,
raw_headers: `Request: ${requestLine}\r\n${rawHeaders.join('\r\n')}\r\n\r\n`,
post_data: body.length ? body : null
};
Object.assign(request_headers, headers);
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
if (verbose) {
console.log('[WTV-MSNTV2] decrypted request:', requestLine);
console.log('[WTV-MSNTV2] decrypted headers:\n' + rawHeaders.join('\r\n'));
if (body.length) {
console.log('[WTV-MSNTV2] decrypted body length:', body.length);
}
}
console.log(" * MSNTV2(TLS) %s for %s on socket %s", method, requestUrl, tlsSocket.id);
if (requestUrl.includes('?')) {
try {
const qs = requestUrl.slice(requestUrl.indexOf('?') + 1);
const params = {};
qs.split('&').forEach(p => {
const eq = p.indexOf('=');
if (eq > 0) params[decodeURIComponent(p.slice(0, eq))] = decodeURIComponent(p.slice(eq + 1).replace(/\+/g, ' '));
else if (p) params[decodeURIComponent(p)] = null;
});
console.log(' * MSNTV2(TLS) query params on socket %s', tlsSocket.id, params);
} catch (_) {}
}
const { domainIntercepted: tlsDi, filePath: tlsFp } = this.interceptRequest(requestUrl, tlsSocket.connectIntercept);
if (verbose) {
console.log('[WTV-MSNTV2] decrypted intercept check for:', requestUrl, '->', tlsDi ? (tlsFp ? 'local file' : 'intercepted/404') : 'proxy');
}
if (tlsDi) {
if (tlsFp) {
this.sendLocalFile(tlsSocket, tlsFp, request_headers);
} else {
console.warn(" * MSNTV2(TLS) 404 for %s on socket %s (intercepted domain, missing local file)", requestUrl, tlsSocket.id);
this.writeError(tlsSocket, 404, 'Not Found', request_headers);
}
} else {
this.proxyRequest(tlsSocket, method, requestUrl, request_headers);
}
}
getConnectIntercept(host) {
if (!host) return null;
const interceptUrls = this.service_config.intercept_urls || [];
const lowerHost = host.toLowerCase();
for (const entry of interceptUrls) {
if (!entry) continue;
let match;
let localDir = '';
if (typeof entry === 'string') {
match = entry;
} else if (typeof entry === 'object') {
match = entry.match;
localDir = entry.local_dir || entry.localDir || '';
} else {
continue;
}
if (!match) continue;
const lowerMatch = match.toLowerCase();
if (!lowerMatch.includes('://') && !lowerMatch.includes('/')) {
if (lowerHost === lowerMatch) {
return { match, localDir };
}
}
}
return null;
}
// Returns { domainIntercepted: bool, filePath: string|null }.
// domainIntercepted=true means the host is in intercept_urls; filePath may
// still be null if the requested file does not exist locally.
interceptRequest(requestUrl, connectState = null) {
const interceptUrls = this.service_config.intercept_urls || [];
const lowerUrl = requestUrl.toLowerCase();
let parsedUrl = null;
const defaultLocalDir = connectState?.localDir || '';
if (connectState && requestUrl.startsWith('/')) {
// Came through an intercepted CONNECT tunnel — domain always intercepted.
let localPath = requestUrl.split('?')[0];
if (!localPath || localPath === '/' || localPath.endsWith('/')) {
localPath = (defaultLocalDir ? defaultLocalDir.replace(/[\\/]+$/, '') + '/' : '') + 'index.html';
} else {
localPath = localPath.replace(/^\/+/, '');
if (defaultLocalDir) {
localPath = defaultLocalDir.replace(/[\\/]+$/, '') + '/' + localPath;
}
}
return { domainIntercepted: true, filePath: this.resolveLocalFile(localPath) };
}
try {
parsedUrl = new url.URL(requestUrl);
} catch (err) {
// not all request lines are valid URL objects, so fall back to prefix matching
}
for (const entry of interceptUrls) {
if (!entry) continue;
let match;
let localDir = '';
if (typeof entry === 'string') {
match = entry;
} else if (typeof entry === 'object') {
match = entry.match;
localDir = entry.local_dir || entry.localDir || '';
} else {
continue;
}
if (!match) continue;
const lowerMatch = match.toLowerCase();
let localPath = null;
if (parsedUrl) {
if (lowerMatch.includes('://')) {
if (lowerUrl.startsWith(lowerMatch)) {
localPath = parsedUrl.pathname;
}
} else if (lowerMatch.includes('/')) {
if (lowerUrl.startsWith(lowerMatch)) {
localPath = parsedUrl.pathname;
}
} else {
if (parsedUrl.host && parsedUrl.host.toLowerCase() === lowerMatch) {
localPath = parsedUrl.pathname;
}
}
} else {
if (lowerUrl.startsWith(lowerMatch)) {
localPath = requestUrl.slice(match.length);
}
}
if (localPath !== null) {
if (!localPath || localPath === '/' || localPath.endsWith('/')) {
localPath = (localDir ? localDir.replace(/[\\/]+$/, '') + '/' : '') + 'index.html';
} else {
localPath = localPath.replace(/^\/+/, '');
if (localDir) {
localPath = localDir.replace(/[\\/]+$/, '') + '/' + localPath;
}
}
return { domainIntercepted: true, filePath: this.resolveLocalFile(localPath) };
}
}
return { domainIntercepted: false, filePath: null };
}
resolveLocalFile(requestedPath) {
if (!requestedPath) return null;
const serviceVaultDir = this.service_config.servicevault_dir || this.service_name;
const vaults = this.minisrv_config.config.ServiceVaults || [];
const normalizedPath = requestedPath.replace(/[\\/]+/g, '/').replace(/^\/+/, '');
// Dynamic handler suffixes to probe in order, matching app.js behaviour
const dynSuffixes = ['.js', '.php', '.cgi'];
for (const vault of vaults) {
const base = this.wtvshared.getAbsolutePath(path.join(serviceVaultDir, normalizedPath), vault);
const candidate = this.wtvshared.makeSafePath(base, '');
// Exact match first
if (candidate && fs.existsSync(candidate) && fs.lstatSync(candidate).isFile()) {
if (this.service_config.show_verbose_errors) console.log('[WTV-MSNTV2] local intercept:', candidate);
return candidate;
}
// Dynamic suffixes: e.g. kickstart.aspx -> kickstart.aspx.js
for (const suffix of dynSuffixes) {
const dynCandidate = this.wtvshared.makeSafePath(base + suffix, '');
if (dynCandidate && fs.existsSync(dynCandidate) && fs.lstatSync(dynCandidate).isFile()) {
if (this.service_config.show_verbose_errors) console.log('[WTV-MSNTV2] local intercept (dynamic):', dynCandidate);
return dynCandidate;
}
}
}
return null;
}
// Populate request_headers.query from URL query string + POST body,
// mirroring what app.js does before running service vault scripts.
_populateQuery(request_headers) {
if (request_headers.query) return;
request_headers.query = {};
const url = request_headers.request_url || '';
if (url.includes('?')) {
const qraw = url.split('?')[1];
if (qraw) {
qraw.split('&').forEach(param => {
const qraw_split = param.split('=');
if (qraw_split.length === 2) {
const k = qraw_split[0];
const value = decodeURIComponent(qraw_split[1].replace(/\+/g, '%20'));
request_headers.query[k] = value;
} else if (param.length >= 1) {
request_headers.query[param] = null;
}
});
}
}
if (request_headers.post_data) {
try {
const post_str = Buffer.isBuffer(request_headers.post_data)
? request_headers.post_data.toString('utf8')
: String(request_headers.post_data);
if (post_str.includes('=')) {
post_str.split('&').forEach(pair => {
const idx = pair.indexOf('=');
if (idx > 0) {
const k = pair.slice(0, idx);
const v = decodeURIComponent(pair.slice(idx + 1).replace(/\+/g, '%20'));
if (!request_headers.query[k]) request_headers.query[k] = v;
}
});
}
} catch (e) { /* ignore */ }
}
}
sendLocalFile(socket, filepath, request_headers) {
this._populateQuery(request_headers);
try {
const ext = path.extname(filepath).slice(1).toLowerCase();
const serviceVaultDir = this.service_config.servicevault_dir || this.service_name;
const vaults = this.minisrv_config.config.ServiceVaults || [];
const vaultBase = vaults.length > 0 ? this.wtvshared.getAbsolutePath(serviceVaultDir, vaults[0]) : null;
// .js — only run through VM when explicitly marked as a minisrv service file.
// Otherwise, treat it as a normal static JavaScript file and serve raw bytes.
if (ext === 'js') {
const scriptData = fs.readFileSync(filepath).toString('utf8');
const firstLine = scriptData.split(/\r?\n/, 1)[0].replace(/^\uFEFF/, '').trim();
const isMiniSrvServiceFile = /^(const|let|var)\s+minisrv_service_file\s*=\s*true\s*;?\s*$/.test(firstLine);
if (!(isMiniSrvServiceFile && this.runScriptInVM)) {
// Not a service script marker (or VM unavailable): fall through to static handler.
} else {
// Resolve socket.ssid from query params before running the script.
// Priority: BoxID (direct SSID) > SessionID (looked up in ssid_sessions).
if (socket.ssid === null && this.ssid_sessions) {
const boxId = request_headers.query.BoxID || request_headers.query.boxid || null;
const sessionId = request_headers.query.SessionID || request_headers.query.sessionid || null;
if (boxId) {
socket.ssid = this.wtvshared.makeSafeSSID(boxId);
} else if (sessionId) {
// Find the ssid whose session_data has a matching stored session_id
const match = Object.keys(this.ssid_sessions).find(
k => this.ssid_sessions[k] && this.ssid_sessions[k].get && this.ssid_sessions[k].get('session_id') === sessionId
);
if (match) socket.ssid = match;
}
if (socket.ssid && !this.ssid_sessions[socket.ssid]) {
this.ssid_sessions[socket.ssid] = new this.WTVClientSessionData(this.minisrv_config, socket.ssid);
this.ssid_sessions[socket.ssid].SaveIfRegistered();
}
}
const self = this;
const responseCookies = [];
const contextObj = {
socket,
request_headers,
service_name: this.service_name,
headers: null,
data: null,
request_is_async: false,
session_data: (socket.ssid && this.ssid_sessions) ? this.ssid_sessions[socket.ssid] : null,
ssid_sessions: this.ssid_sessions,
WTVClientSessionData: this.WTVClientSessionData,
// Scripts may call sendToClient directly for async mode;
// wrap it so the response goes through SSLv2 encryption.
sendToClient: (sock, hdrs, dat) => self._sendScriptResult(sock, request_headers, hdrs, dat),
minisrv_config: this.minisrv_config,
wtvshared: this.wtvshared,
cwd: path.dirname(filepath),
// Cookie helpers available to scripts
response_cookies: responseCookies,
setCookie(name, value, opts) {
opts = opts || {};
let s = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
if (opts.path != null) s += `; Path=${opts.path}`;
if (opts.domain != null) s += `; Domain=${opts.domain}`;
if (opts.maxAge != null) s += `; Max-Age=${opts.maxAge}`;
if (opts.expires != null) s += `; Expires=${new Date(opts.expires).toUTCString()}`;
if (opts.sameSite != null) s += `; SameSite=${opts.sameSite}`;
if (opts.secure) s += `; Secure`;
if (opts.httpOnly) s += `; HttpOnly`;
responseCookies.push(s);
},
// Remove a cookie by expiring it immediately.
// opts may include path/domain to match the original cookie's scope.
deleteCookie(name, opts) {
opts = opts || {};
let s = `${encodeURIComponent(name)}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;
if (opts.path != null) s += `; Path=${opts.path}`;
if (opts.domain != null) s += `; Domain=${opts.domain}`;
if (opts.httpOnly) s += `; HttpOnly`;
responseCookies.push(s);
},
// Update an existing queued cookie's value (and optionally its opts).
// If no matching cookie is queued yet, behaves like setCookie.
updateCookie(name, value, opts) {
const encoded = encodeURIComponent(name);
const idx = responseCookies.findIndex(c => c.startsWith(encoded + '=') || c.startsWith(encoded + ';'));
if (idx !== -1) responseCookies.splice(idx, 1);
opts = opts || {};
let s = `${encoded}=${encodeURIComponent(value)}`;
if (opts.path != null) s += `; Path=${opts.path}`;
if (opts.domain != null) s += `; Domain=${opts.domain}`;
if (opts.maxAge != null) s += `; Max-Age=${opts.maxAge}`;
if (opts.expires != null) s += `; Expires=${new Date(opts.expires).toUTCString()}`;
if (opts.sameSite != null) s += `; SameSite=${opts.sameSite}`;
if (opts.secure) s += `; Secure`;
if (opts.httpOnly) s += `; HttpOnly`;
responseCookies.push(s);
},
// Convenience UUID generator (replaces uuidv4() from C# artifacts)
uuidv4() { return crypto.randomUUID(); }
};
const vmResult = this.runScriptInVM(scriptData, contextObj, true, filepath);
// Write session_data back to ssid_sessions (mirrors app.js updateFromVM for non-privileged scripts;
// use socket.ssid as set by the script itself via BoxID or header).
if (socket.ssid && this.ssid_sessions && vmResult.session_data !== undefined) {
this.ssid_sessions[socket.ssid] = vmResult.session_data;
}
if (!vmResult.request_is_async) {
this._sendScriptResult(socket, request_headers, vmResult.headers, vmResult.data, responseCookies);
}
return;
}
}
// .php — run through PHP CGI (same as normal service vault)
if (ext === 'php' && this.handlePHP && this.minisrv_config.config.php_enabled && this.minisrv_config.config.php_binpath) {
this.handlePHP(socket, request_headers, filepath, vaultBase || path.dirname(filepath), this.service_name, null);
return;
}
// .cgi — run through CGI handler
if (ext === 'cgi' && this.handleCGI) {
this.handleCGI(filepath, filepath, socket, request_headers, vaultBase || path.dirname(filepath), this.service_name, null);
return;
}
// Static file fallback
const fileContents = fs.readFileSync(filepath);
const contentType = this.mimeTypes[ext] || 'application/octet-stream';
const responseHeaders = [];
responseHeaders.push('HTTP/1.1 200 OK');
responseHeaders.push(`Content-Type: ${contentType}`);
responseHeaders.push(`Content-Length: ${fileContents.length}`);
const closeClientConnection = this._shouldCloseClientConnection(request_headers);
responseHeaders.push(`Connection: ${closeClientConnection ? 'close' : 'Keep-Alive'}`);
responseHeaders.push('');
responseHeaders.push('');
this.writeToSocket(socket, responseHeaders.join('\r\n'));
this.writeToSocket(socket, fileContents, closeClientConnection ? () => {
this.endSocket(socket);
} : undefined);
} catch (err) {
console.error('[WTV-MSNTV2] local file serve error:', (err && err.stack) ? err.stack : err);
this.writeError(socket, 404, 'Not Found', request_headers);
}
}
_shouldCloseClientConnection(request_headers, headerLines = []) {
const existingConnection = headerLines.find(line => line.toLowerCase().startsWith('connection:'));
if (existingConnection) {
return existingConnection.split(':').slice(1).join(':').trim().toLowerCase() === 'close';
}
const requestConnection = request_headers && request_headers.connection;
return String(requestConnection || '').toLowerCase() === 'close';
}
// Convert script (VM/PHP/CGI) headers+data output to an HTTP response and
// send it through writeToSocket so SSLv2 encryption is applied.
// cookies: optional array of pre-formatted Set-Cookie strings from setCookie()
_sendScriptResult(socket, request_headers, headers, data, cookies) {
let statusCode = 200;
let statusMessage = 'OK';
const headerLines = [];
if (typeof headers === 'string') {
// String form: "Content-type: text/html\nStatus: 200 OK\n\n"
const lines = headers.replace(/\r/g, '').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
const colonIdx = line.indexOf(':');
if (colonIdx < 0) continue;
const name = line.slice(0, colonIdx).trim();
const value = line.slice(colonIdx + 1).trim();
if (name.toLowerCase() === 'status') {
const parts = value.split(' ');
statusCode = parseInt(parts[0], 10) || 200;
statusMessage = parts.slice(1).join(' ') || 'OK';
} else {
headerLines.push(`${name}: ${value}`);
}
}
} else if (headers && typeof headers === 'object') {
const skip = new Set(['request', 'request_url', 'raw_headers', 'post_data']);
for (const [k, v] of Object.entries(headers)) {
if (skip.has(k)) continue;
if (k.toLowerCase() === 'status') {
const parts = String(v).split(' ');
statusCode = parseInt(parts[0], 10) || 200;
statusMessage = parts.slice(1).join(' ') || 'OK';
} else {
headerLines.push(`${k}: ${v}`);
}
}
}
const body = data
? (Buffer.isBuffer(data) ? data : Buffer.from(String(data)))
: Buffer.alloc(0);
// Inject Set-Cookie headers from setCookie() calls in scripts
if (Array.isArray(cookies)) {
for (const c of cookies) headerLines.push(`Set-Cookie: ${c}`);
}
if (!headerLines.some(l => l.toLowerCase().startsWith('content-length'))) {
headerLines.push(`Content-Length: ${body.length}`);
}
const closeClientConnection = this._shouldCloseClientConnection(request_headers, headerLines);
if (!headerLines.some(l => l.toLowerCase().startsWith('connection:'))) {
headerLines.push(`Connection: ${closeClientConnection ? 'close' : 'Keep-Alive'}`);
}
const responseHeader = [`HTTP/1.1 ${statusCode} ${statusMessage}`, ...headerLines, '', ''].join('\r\n');
this.writeToSocket(socket, responseHeader);
if (body.length) {
this.writeToSocket(socket, body, closeClientConnection ? () => this.endSocket(socket) : undefined);
} else if (closeClientConnection) {
this.endSocket(socket);
}
}
proxyRequest(socket, method, requestUrl, request_headers) {
let target;
try {
if (requestUrl.startsWith('/')) {
const hostHeader = request_headers.host || (socket.connectIntercept && socket.connectIntercept.match);
if (!hostHeader) throw new Error('Missing host for tunneled request');
target = new url.URL(`https://${hostHeader}${requestUrl}`);
} else {
target = new url.URL(requestUrl);
}
} catch (err) {
if (this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3) {
console.error('[WTV-MSNTV2] invalid URL:', requestUrl, err.message);
}
this.writeError(socket, 400, 'Bad Request', request_headers);
return;
}
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
const isHttps = target.protocol === 'https:';
const agent = isHttps ? https : http;
const requestPath = target.pathname + (target.search || '');
if (verbose) {
console.log('[WTV-MSNTV2] proxying request:', method.toUpperCase(), requestUrl);
console.log('[WTV-MSNTV2] proxy target:', target.protocol, target.hostname, 'port', target.port || (isHttps ? 443 : 80), 'path', requestPath);
}
const headers = {};
Object.keys(request_headers).forEach((name) => {
if (name.toLowerCase() === 'proxy-connection') return;
if (['request', 'request_url', 'raw_headers', 'post_data'].includes(name)) return;
if (name.toLowerCase() === 'connection') return;
headers[name] = request_headers[name];
});
headers.Host = target.host;
headers.Connection = 'close';
const options = {
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
path: requestPath,
method: method.toUpperCase(),
headers,
followAllRedirects: true,
maxBodyLength: 1024 * 1024 * 64
};
const proxyReq = agent.request(options, (res) => {
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
if (verbose) {
console.log('[WTV-MSNTV2] upstream response:', res.statusCode, res.statusMessage);
console.log('[WTV-MSNTV2] upstream response headers:', JSON.stringify(res.headers));
}
const responseHeaders = [];
const closeClientConnection = this._shouldCloseClientConnection(request_headers);
responseHeaders.push(`HTTP/1.1 ${res.statusCode} ${res.statusMessage}`);
Object.keys(res.headers).forEach((name) => {
if (name.toLowerCase() === 'connection') return;
if (name.toLowerCase() === 'transfer-encoding') return;
responseHeaders.push(`${name}: ${res.headers[name]}`);
});
responseHeaders.push(`Connection: ${closeClientConnection ? 'close' : 'Keep-Alive'}`);
responseHeaders.push('');
responseHeaders.push('');
this.writeToSocket(socket, responseHeaders.join('\r\n'));
res.on('data', (chunk) => this.writeToSocket(socket, chunk));
res.on('end', () => {
if (!socket.destroyed && closeClientConnection) this.endSocket(socket);
});
});
proxyReq.on('error', (err) => {
if (verbose) console.error('[WTV-MSNTV2] proxy request error:', err.message, 'for', method.toUpperCase(), requestUrl);
this.writeError(socket, 502, 'Bad Gateway', request_headers);
});
if (request_headers.post_data && request_headers.post_data.length) {
proxyReq.write(request_headers.post_data);
}
proxyReq.end();
}
writeError(socket, statusCode, message, request_headers = null) {
const body = `<html><body><h1>${statusCode} ${message}</h1></body></html>`;
const headers = [];
const closeClientConnection = this._shouldCloseClientConnection(request_headers);
headers.push(`HTTP/1.1 ${statusCode} ${message}`);
headers.push('Content-Type: text/html');
headers.push(`Content-Length: ${Buffer.byteLength(body)}`);
headers.push(`Connection: ${closeClientConnection ? 'close' : 'Keep-Alive'}`);
headers.push('');
headers.push('');
this.writeToSocket(socket, headers.join('\r\n'));
this.writeToSocket(socket, body, closeClientConnection ? () => {
this.endSocket(socket);
} : undefined);
}
writeToSocket(socket, data, callback) {
const verbose = this.sslv2Debug;
if (socket.sslv2 && socket.sslv2.stage === 'open' && socket.sslv2.serverCipher) {
// SSLv2 open state: encrypt outbound data
const payload = Buffer.isBuffer(data) ? data : Buffer.from(data, 'binary');
this._logSslv2ResponseHeaders(socket, payload);
const seqBefore = socket.sslv2.serverSequence;
if (verbose) {
const preview = payload.slice(0, 128).toString('utf8').replace(/[^\x20-\x7e\r\n]/g, '.');
console.log('[WTV-MSNTV2] writeToSocket(sslv2) seq=' + seqBefore + ' len=' + payload.length + ' preview: ' + preview);
}
if (payload.length === 0) {
if (callback) callback();
return;
}
const encrypted = this.buildSslv2EncryptedRecord(socket.sslv2, payload);
if (verbose) {
console.log('[WTV-MSNTV2] writeToSocket(sslv2) seq advanced to ' + socket.sslv2.serverSequence);
}
socket.write(encrypted, callback);
} else if (socket.forgeTls) {
const binary = Buffer.isBuffer(data) ? data.toString('binary') : data;
if (verbose) {
const preview = binary.slice(0, 128).replace(/[^\x20-\x7e\r\n]/g, '.');
console.log('[WTV-MSNTV2] writeToSocket(forgeTls) len=' + binary.length + ' preview: ' + preview);
}
socket.forgeTls.prepare(binary);
if (callback) callback();
} else {
const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (verbose) {
const preview = payload.slice(0, 128).toString('utf8').replace(/[^\x20-\x7e\r\n]/g, '.');
console.log('[WTV-MSNTV2] writeToSocket(plain) len=' + payload.length + ' preview: ' + preview);
}
socket.write(data, callback);
}
}
endSocket(socket) {
if (socket.forgeTls) {
try {
socket.forgeTls.close();
} catch (_) {}
if (socket.end && !socket.destroyed) {
socket.end();
}
} else if (socket.destroy && !socket.destroyed) {
socket.end();
}
}
}
module.exports = WTVMSNTV2;