- Add full Telegram bot functionality with Z.AI API integration
- Implement 4 tools: Bash, FileEdit, WebSearch, Git
- Add 3 agents: Code Reviewer, Architect, DevOps Engineer
- Add 6 skills for common coding tasks
- Add systemd service file for 24/7 operation
- Add nginx configuration for HTTPS webhook
- Add comprehensive documentation
- Implement WebSocket server for real-time updates
- Add logging system with Winston
- Add environment validation
🤖 zCode CLI X - Agentic coder with Z.AI + Telegram integration
232 lines
10 KiB
JavaScript
232 lines
10 KiB
JavaScript
import { Agent, createServer } from 'node:http';
|
|
import { request as httpRequest } from 'node:http';
|
|
import { request as httpsRequest } from 'node:https';
|
|
import { connect } from 'node:net';
|
|
import { URL } from 'node:url';
|
|
import { logForDebugging } from '../utils/debug.js';
|
|
import { connectViaParentProxy, dialDirect, openConnectTunnel, proxyAuthHeader, selectParentProxyUrl, shouldBypassParentProxy, stripBrackets, stripHopByHop, } from './parent-proxy.js';
|
|
export function createHttpProxyServer(options) {
|
|
const server = createServer();
|
|
// Handle CONNECT requests for HTTPS traffic
|
|
server.on('connect', async (req, socket, head) => {
|
|
// Attach error handler immediately to prevent unhandled errors
|
|
socket.on('error', err => {
|
|
logForDebugging(`Client socket error: ${err.message}`, { level: 'error' });
|
|
});
|
|
// Track client liveness so we can abort the upstream dial if they bail.
|
|
let clientGone = false;
|
|
socket.once('close', () => {
|
|
clientGone = true;
|
|
});
|
|
try {
|
|
const target = parseConnectTarget(req.url);
|
|
if (!target) {
|
|
logForDebugging(`Invalid CONNECT request: ${req.url}`, {
|
|
level: 'error',
|
|
});
|
|
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
return;
|
|
}
|
|
const { hostname, port } = target;
|
|
const allowed = await options.filter(port, hostname, socket);
|
|
if (!allowed) {
|
|
logForDebugging(`Connection blocked to ${hostname}:${port}`, {
|
|
level: 'error',
|
|
});
|
|
socket.end('HTTP/1.1 403 Forbidden\r\n' +
|
|
'Content-Type: text/plain\r\n' +
|
|
'X-Proxy-Error: blocked-by-allowlist\r\n' +
|
|
'\r\n' +
|
|
'Connection blocked by network allowlist');
|
|
return;
|
|
}
|
|
// Decide upstream route: MITM unix socket > parent HTTP proxy > direct.
|
|
const mitmSocketPath = options.getMitmSocketPath?.(hostname);
|
|
const parentUrl = !mitmSocketPath &&
|
|
options.parentProxy &&
|
|
!shouldBypassParentProxy(options.parentProxy, hostname)
|
|
? selectParentProxyUrl(options.parentProxy, { isHttps: true })
|
|
: undefined;
|
|
let upstream;
|
|
try {
|
|
if (mitmSocketPath) {
|
|
logForDebugging(`Routing CONNECT ${hostname}:${port} through MITM proxy at ${mitmSocketPath}`);
|
|
upstream = await openConnectTunnel({
|
|
dial: () => connect({ path: mitmSocketPath }),
|
|
readyEvent: 'connect',
|
|
destHost: hostname,
|
|
destPort: port,
|
|
});
|
|
}
|
|
else if (parentUrl) {
|
|
upstream = await connectViaParentProxy(parentUrl, hostname, port);
|
|
}
|
|
else {
|
|
upstream = await dialDirect(hostname, port);
|
|
}
|
|
}
|
|
catch (err) {
|
|
logForDebugging(`CONNECT tunnel failed: ${err.message}`, {
|
|
level: 'error',
|
|
});
|
|
socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
|
return;
|
|
}
|
|
if (clientGone) {
|
|
upstream.on('error', () => { }); // swallow post-resolve errors
|
|
upstream.destroy();
|
|
return;
|
|
}
|
|
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
// Forward any bytes the client sent in the same packet as the CONNECT
|
|
// (Node delivers these as the `head` buffer, not via the socket stream).
|
|
if (head.length)
|
|
upstream.write(head);
|
|
upstream.pipe(socket);
|
|
socket.pipe(upstream);
|
|
upstream.on('error', err => {
|
|
logForDebugging(`CONNECT tunnel failed: ${err.message}`, {
|
|
level: 'error',
|
|
});
|
|
socket.destroy();
|
|
});
|
|
socket.on('close', () => upstream.destroy());
|
|
upstream.on('close', () => socket.destroy());
|
|
}
|
|
catch (err) {
|
|
logForDebugging(`Error handling CONNECT: ${err}`, { level: 'error' });
|
|
socket.end('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
|
}
|
|
});
|
|
// Handle regular HTTP requests
|
|
server.on('request', async (req, res) => {
|
|
try {
|
|
const url = new URL(req.url);
|
|
const hostname = stripBrackets(url.hostname);
|
|
const port = url.port
|
|
? parseInt(url.port, 10)
|
|
: url.protocol === 'https:'
|
|
? 443
|
|
: 80;
|
|
const allowed = await options.filter(port, hostname, req.socket);
|
|
if (!allowed) {
|
|
logForDebugging(`HTTP request blocked to ${hostname}:${port}`, {
|
|
level: 'error',
|
|
});
|
|
res.writeHead(403, {
|
|
'Content-Type': 'text/plain',
|
|
'X-Proxy-Error': 'blocked-by-allowlist',
|
|
});
|
|
res.end('Connection blocked by network allowlist');
|
|
return;
|
|
}
|
|
// Client may have disconnected while we awaited the filter; bail now
|
|
// rather than dialing an upstream nobody will read from.
|
|
if (req.socket.destroyed)
|
|
return;
|
|
const fwdHeaders = { ...stripHopByHop(req.headers), host: url.host };
|
|
// Decide upstream route: MITM unix socket > parent HTTP proxy > direct.
|
|
const mitmSocketPath = options.getMitmSocketPath?.(hostname);
|
|
const parentUrl = !mitmSocketPath &&
|
|
options.parentProxy &&
|
|
!shouldBypassParentProxy(options.parentProxy, hostname)
|
|
? selectParentProxyUrl(options.parentProxy, {
|
|
isHttps: url.protocol === 'https:',
|
|
})
|
|
: undefined;
|
|
// Reconstruct the absolute URI from parsed components rather than
|
|
// forwarding the client's raw req.url. This ensures the upstream proxy
|
|
// sees exactly the host we allowlist-checked, closing URL-parser
|
|
// differential bypasses.
|
|
const absUrl = `${url.protocol}//${url.host}${url.pathname}${url.search}`;
|
|
let proxyReq;
|
|
if (mitmSocketPath) {
|
|
logForDebugging(`Routing HTTP ${req.method} ${hostname}:${port} through MITM proxy at ${mitmSocketPath}`);
|
|
const mitmAgent = new Agent({
|
|
// @ts-expect-error - socketPath is valid but not in types
|
|
socketPath: mitmSocketPath,
|
|
});
|
|
proxyReq = httpRequest({
|
|
agent: mitmAgent,
|
|
path: absUrl,
|
|
method: req.method,
|
|
headers: fwdHeaders,
|
|
}, proxyRes => {
|
|
res.writeHead(proxyRes.statusCode, stripHopByHop(proxyRes.headers));
|
|
proxyRes.pipe(res);
|
|
});
|
|
}
|
|
else if (parentUrl) {
|
|
const parentHost = stripBrackets(parentUrl.hostname);
|
|
const parentPort = Number(parentUrl.port) || (parentUrl.protocol === 'https:' ? 443 : 80);
|
|
const auth = proxyAuthHeader(parentUrl);
|
|
const requestFn = parentUrl.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
proxyReq = requestFn({
|
|
hostname: parentHost,
|
|
port: parentPort,
|
|
path: absUrl,
|
|
method: req.method,
|
|
headers: auth
|
|
? { ...fwdHeaders, 'proxy-authorization': auth }
|
|
: fwdHeaders,
|
|
}, proxyRes => {
|
|
res.writeHead(proxyRes.statusCode, stripHopByHop(proxyRes.headers));
|
|
proxyRes.pipe(res);
|
|
});
|
|
}
|
|
else {
|
|
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
proxyReq = requestFn({
|
|
hostname,
|
|
port,
|
|
path: url.pathname + url.search,
|
|
method: req.method,
|
|
headers: fwdHeaders,
|
|
}, proxyRes => {
|
|
res.writeHead(proxyRes.statusCode, stripHopByHop(proxyRes.headers));
|
|
proxyRes.pipe(res);
|
|
});
|
|
}
|
|
proxyReq.on('error', err => {
|
|
logForDebugging(`Proxy request failed: ${err.message}`, {
|
|
level: 'error',
|
|
});
|
|
if (!res.headersSent) {
|
|
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
res.end('Bad Gateway');
|
|
}
|
|
else {
|
|
res.destroy();
|
|
}
|
|
});
|
|
// Tear down the upstream request if the client goes away mid-flight.
|
|
res.on('close', () => proxyReq.destroy());
|
|
req.pipe(proxyReq);
|
|
}
|
|
catch (err) {
|
|
logForDebugging(`Error handling HTTP request: ${err}`, { level: 'error' });
|
|
if (!res.headersSent) {
|
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
res.end('Internal Server Error');
|
|
}
|
|
else {
|
|
res.destroy();
|
|
}
|
|
}
|
|
});
|
|
return server;
|
|
}
|
|
/**
|
|
* Parse a CONNECT request-target into host + port. Handles both plain
|
|
* `host:port` and bracketed IPv6 `[::1]:port`.
|
|
*/
|
|
function parseConnectTarget(target) {
|
|
const m = /^\[([^\]]+)\]:(\d+)$/.exec(target) ?? /^([^:]+):(\d+)$/.exec(target);
|
|
if (!m)
|
|
return undefined;
|
|
const port = Number(m[2]);
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
return undefined;
|
|
return { hostname: m[1], port };
|
|
}
|
|
//# sourceMappingURL=http-proxy.js.map
|