- 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
438 lines
17 KiB
JavaScript
438 lines
17 KiB
JavaScript
/**
|
|
* Parent/upstream HTTP proxy support.
|
|
*
|
|
* When SRT runs in an environment that requires an HTTP proxy for outbound
|
|
* internet access (e.g. inside a VM on a host behind a corporate proxy),
|
|
* SRT's own proxies must chain through that upstream rather than connecting
|
|
* directly.
|
|
*
|
|
* This module provides:
|
|
* - config resolution (explicit config -> HTTP_PROXY/HTTPS_PROXY/NO_PROXY env)
|
|
* - NO_PROXY matching (hostname suffix + CIDR via net.BlockList). Follows
|
|
* golang.org/x/net/http/httpproxy semantics for suffix matching. Note:
|
|
* port-specific NO_PROXY entries (e.g. `host:8080`) are matched by host
|
|
* only; the port is ignored.
|
|
* - a generic CONNECT-tunnel helper that works over Unix socket, TCP, or TLS
|
|
*/
|
|
import { BlockList, connect as netConnect, isIP } from 'node:net';
|
|
import { connect as tlsConnect } from 'node:tls';
|
|
import { URL } from 'node:url';
|
|
import { logForDebugging } from '../utils/debug.js';
|
|
const CONNECT_TIMEOUT_MS = 30000;
|
|
/**
|
|
* Hop-by-hop headers per RFC 7230 §6.1, plus proxy-specific headers that
|
|
* MUST NOT be forwarded to the upstream. `transfer-encoding` is included
|
|
* because we re-frame bodies via Node's client; Content-Length is preserved
|
|
* end-to-end (Node's llhttp already rejects the TE+CL smuggling vector).
|
|
*/
|
|
const HOP_BY_HOP = new Set([
|
|
'connection',
|
|
'keep-alive',
|
|
'proxy-authenticate',
|
|
'proxy-authorization',
|
|
'proxy-connection',
|
|
'te',
|
|
'trailer',
|
|
'transfer-encoding',
|
|
'upgrade',
|
|
]);
|
|
/**
|
|
* Resolve the parent proxy config, falling back to the SRT process's own
|
|
* environment. Note: SRT later overwrites HTTP_PROXY etc. in the *sandboxed
|
|
* child's* environment to point at itself — but process.env here reflects the
|
|
* environment SRT itself was launched with, which is what we want.
|
|
*/
|
|
export function resolveParentProxy(cfg) {
|
|
const http = cfg?.http ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? undefined;
|
|
const https = cfg?.https ??
|
|
process.env.HTTPS_PROXY ??
|
|
process.env.https_proxy ??
|
|
// Fall back to HTTP_PROXY for HTTPS if HTTPS_PROXY is unset — this is
|
|
// the de-facto behaviour of curl and most tooling.
|
|
http;
|
|
const noProxyRaw = cfg?.noProxy ?? process.env.NO_PROXY ?? process.env.no_proxy ?? '';
|
|
if (!http && !https)
|
|
return undefined;
|
|
const parse = (u) => {
|
|
if (!u)
|
|
return undefined;
|
|
// Accept schemeless `host:port` like curl does, but reject any scheme
|
|
// other than http/https.
|
|
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(u);
|
|
const withScheme = hasScheme ? u : `http://${u}`;
|
|
try {
|
|
const parsed = new URL(withScheme);
|
|
if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
|
!parsed.hostname) {
|
|
throw new Error('unsupported scheme or empty host');
|
|
}
|
|
return parsed;
|
|
}
|
|
catch {
|
|
logForDebugging(`Invalid parent proxy URL, ignoring: ${redactUserinfo(u)}`, { level: 'error' });
|
|
return undefined;
|
|
}
|
|
};
|
|
const httpUrl = parse(http);
|
|
const httpsUrl = parse(https);
|
|
// If both parsed to undefined, behave as if no parent proxy was configured
|
|
// rather than returning a husk object that makes callers do bypass checks
|
|
// for nothing.
|
|
if (!httpUrl && !httpsUrl)
|
|
return undefined;
|
|
return { httpUrl, httpsUrl, noProxy: parseNoProxy(noProxyRaw) };
|
|
}
|
|
function parseNoProxy(raw) {
|
|
const rules = {
|
|
all: false,
|
|
suffixes: [],
|
|
cidr: new BlockList(),
|
|
};
|
|
for (let entry of raw.split(',')) {
|
|
entry = entry.trim();
|
|
if (!entry)
|
|
continue;
|
|
if (entry === '*') {
|
|
rules.all = true;
|
|
continue;
|
|
}
|
|
// CIDR?
|
|
const slash = entry.indexOf('/');
|
|
if (slash !== -1) {
|
|
const ip = entry.slice(0, slash);
|
|
const prefixStr = entry.slice(slash + 1);
|
|
const fam = isIP(ip);
|
|
if (fam && prefixStr !== '' && /^\d+$/.test(prefixStr)) {
|
|
const prefix = Number(prefixStr);
|
|
const max = fam === 6 ? 128 : 32;
|
|
if (prefix >= 0 && prefix <= max) {
|
|
try {
|
|
rules.cidr.addSubnet(ip, prefix, fam === 6 ? 'ipv6' : 'ipv4');
|
|
}
|
|
catch {
|
|
// BlockList rejected it — ignore this entry.
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
// malformed CIDR → ignore (do NOT treat as suffix; `/` isn't a valid
|
|
// hostname char)
|
|
continue;
|
|
}
|
|
// Hostname suffix. Normalise: lowercase, strip brackets (handling the
|
|
// `[v6]:port` form), strip leading `*.`, strip a trailing `:port` (unless
|
|
// the entry is an IP literal — IPv6 addresses contain colons).
|
|
let v = entry.toLowerCase();
|
|
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(v);
|
|
if (bracketed)
|
|
v = bracketed[1];
|
|
if (v.startsWith('*.'))
|
|
v = v.slice(1);
|
|
const bareFam = isIP(v);
|
|
if (!bareFam) {
|
|
const colon = v.lastIndexOf(':');
|
|
if (colon !== -1 && /^\d+$/.test(v.slice(colon + 1))) {
|
|
v = v.slice(0, colon);
|
|
}
|
|
}
|
|
else {
|
|
// Bare IP literal — store as an exact-match /32 or /128 CIDR so that
|
|
// lookups go through BlockList rather than string suffix matching.
|
|
try {
|
|
rules.cidr.addAddress(v, bareFam === 6 ? 'ipv6' : 'ipv4');
|
|
continue;
|
|
}
|
|
catch {
|
|
// fall through to suffix push
|
|
}
|
|
}
|
|
rules.suffixes.push(v);
|
|
}
|
|
return rules;
|
|
}
|
|
/**
|
|
* Returns true if the given host should bypass the parent proxy and connect
|
|
* directly. Always bypasses loopback.
|
|
*
|
|
* NB: the port is not consulted. NO_PROXY entries of the form `host:port` are
|
|
* matched by host only (the port suffix is stripped during parsing).
|
|
*/
|
|
export function shouldBypassParentProxy(resolved, host) {
|
|
const h = stripBrackets(host.toLowerCase().replace(/\.$/, ''));
|
|
// Always bypass loopback — chaining localhost through an upstream proxy is
|
|
// never what you want. Covers the whole 127/8 block and IPv4-mapped forms.
|
|
if (h === 'localhost')
|
|
return true;
|
|
const fam = isIP(h);
|
|
if (fam) {
|
|
if (LOOPBACK.check(h, fam === 6 ? 'ipv6' : 'ipv4'))
|
|
return true;
|
|
}
|
|
if (resolved.noProxy.all)
|
|
return true;
|
|
if (fam) {
|
|
if (resolved.noProxy.cidr.check(h, fam === 6 ? 'ipv6' : 'ipv4'))
|
|
return true;
|
|
}
|
|
for (const v of resolved.noProxy.suffixes) {
|
|
if (v.startsWith('.')) {
|
|
// .example.com matches foo.example.com and example.com
|
|
if (h === v.slice(1) || h.endsWith(v))
|
|
return true;
|
|
}
|
|
else {
|
|
// example.com matches example.com and foo.example.com (golang semantics)
|
|
if (h === v || h.endsWith('.' + v))
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
const LOOPBACK = (() => {
|
|
const bl = new BlockList();
|
|
bl.addSubnet('127.0.0.0', 8, 'ipv4');
|
|
bl.addAddress('::1', 'ipv6');
|
|
bl.addSubnet('::ffff:127.0.0.0', 104, 'ipv6'); // v4-mapped loopback
|
|
return bl;
|
|
})();
|
|
/**
|
|
* Pick which parent proxy URL to use for a given destination.
|
|
*/
|
|
export function selectParentProxyUrl(resolved, opts) {
|
|
if (opts.isHttps)
|
|
return resolved.httpsUrl ?? resolved.httpUrl;
|
|
// For plain HTTP we only fall back to HTTPS_PROXY if it was explicitly set
|
|
// — matches curl's behaviour where HTTP requests go direct if only
|
|
// HTTPS_PROXY is configured.
|
|
return resolved.httpUrl;
|
|
}
|
|
/**
|
|
* Generic CONNECT-tunnel: dial a proxy transport (unix/tcp/tls), send
|
|
* `CONNECT host:port`, wait for a 2xx, and resolve with the tunnelled socket.
|
|
* Validates destHost to prevent CRLF injection from untrusted callers.
|
|
*/
|
|
export function openConnectTunnel(opts) {
|
|
const { destHost, destPort } = opts;
|
|
// CRLF-injection guard: destHost may originate from an untrusted SOCKS5
|
|
// DOMAINNAME field. Reject anything that isn't a plain hostname or IP.
|
|
const bare = stripBrackets(destHost);
|
|
if (!isValidHost(bare)) {
|
|
return Promise.reject(new Error(`Invalid destination host for CONNECT: ${JSON.stringify(destHost)}`));
|
|
}
|
|
if (!Number.isInteger(destPort) || destPort < 1 || destPort > 65535) {
|
|
return Promise.reject(new Error(`Invalid destination port: ${destPort}`));
|
|
}
|
|
const authority = isIP(bare) === 6 ? `[${bare}]:${destPort}` : `${bare}:${destPort}`;
|
|
return new Promise((resolve, reject) => {
|
|
const sock = opts.dial();
|
|
let settled = false;
|
|
const fail = (err) => {
|
|
if (settled)
|
|
return;
|
|
settled = true;
|
|
sock.destroy();
|
|
reject(err);
|
|
};
|
|
const onClose = () => fail(new Error('Proxy closed during CONNECT handshake'));
|
|
sock.setTimeout(opts.timeoutMs ?? CONNECT_TIMEOUT_MS, () => fail(new Error('CONNECT handshake timed out')));
|
|
sock.once('error', fail);
|
|
sock.once('close', onClose);
|
|
sock.once(opts.readyEvent, () => {
|
|
sock.write(`CONNECT ${authority} HTTP/1.1\r\n` +
|
|
`Host: ${authority}\r\n` +
|
|
(opts.authHeader
|
|
? `Proxy-Authorization: ${opts.authHeader}\r\n`
|
|
: '') +
|
|
'\r\n');
|
|
let buf = '';
|
|
const onData = (chunk) => {
|
|
buf += chunk.toString('latin1');
|
|
const end = buf.indexOf('\r\n\r\n');
|
|
if (end === -1) {
|
|
// Cap header size to avoid unbounded buffering on a misbehaving proxy.
|
|
if (buf.length > 16 * 1024)
|
|
fail(new Error('CONNECT response header too large'));
|
|
return;
|
|
}
|
|
// Pause before detaching the data listener so the stream stops
|
|
// flowing — otherwise the unshift below (or any bytes arriving
|
|
// between now and the caller's pipe()) would be dropped.
|
|
sock.pause();
|
|
sock.removeListener('data', onData);
|
|
const statusLine = buf.slice(0, buf.indexOf('\r\n'));
|
|
if (!/^HTTP\/1\.[01] 2\d\d(?:\s|$)/.test(statusLine)) {
|
|
return fail(new Error(`Proxy refused CONNECT: ${statusLine.trim()}`));
|
|
}
|
|
// Re-emit any bytes that arrived after the header terminator.
|
|
const rest = buf.slice(end + 4);
|
|
if (rest.length)
|
|
sock.unshift(Buffer.from(rest, 'latin1'));
|
|
settled = true;
|
|
sock.setTimeout(0);
|
|
sock.removeListener('error', fail);
|
|
sock.removeListener('close', onClose);
|
|
resolve(sock);
|
|
};
|
|
sock.on('data', onData);
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* Open a CONNECT tunnel through a parent HTTP(S) proxy specified by URL.
|
|
* Thin wrapper around openConnectTunnel that dials TCP or TLS based on the
|
|
* proxy URL scheme.
|
|
*/
|
|
export function connectViaParentProxy(proxyUrl, destHost, destPort) {
|
|
const proxyHost = stripBrackets(proxyUrl.hostname);
|
|
const proxyPort = Number(proxyUrl.port) || (proxyUrl.protocol === 'https:' ? 443 : 80);
|
|
const useTls = proxyUrl.protocol === 'https:';
|
|
return openConnectTunnel({
|
|
destHost,
|
|
destPort,
|
|
authHeader: proxyAuthHeader(proxyUrl),
|
|
readyEvent: useTls ? 'secureConnect' : 'connect',
|
|
dial: () => useTls
|
|
? tlsConnect({
|
|
host: proxyHost,
|
|
port: proxyPort,
|
|
// SNI must be a hostname, never an IP literal (RFC 6066 §3).
|
|
...(isIP(proxyHost) ? {} : { servername: proxyHost }),
|
|
})
|
|
: netConnect(proxyPort, proxyHost),
|
|
});
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// Utilities
|
|
// ---------------------------------------------------------------------------
|
|
export function proxyAuthHeader(proxyUrl) {
|
|
if (!proxyUrl.username && !proxyUrl.password)
|
|
return undefined;
|
|
try {
|
|
const creds = `${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`;
|
|
return `Basic ${Buffer.from(creds).toString('base64')}`;
|
|
}
|
|
catch {
|
|
// Malformed percent-encoding in userinfo — fall back to raw values
|
|
// rather than throwing synchronously into the caller.
|
|
const creds = `${proxyUrl.username}:${proxyUrl.password}`;
|
|
return `Basic ${Buffer.from(creds).toString('base64')}`;
|
|
}
|
|
}
|
|
/**
|
|
* Strip hop-by-hop and proxy-specific headers before forwarding upstream.
|
|
* Also strips any headers named in the incoming `Connection` header, per
|
|
* RFC 7230 §6.1.
|
|
*/
|
|
export function stripHopByHop(h) {
|
|
const extra = new Set();
|
|
const connHeader = h.connection;
|
|
if (connHeader) {
|
|
for (const tok of String(connHeader).split(',')) {
|
|
extra.add(tok.trim().toLowerCase());
|
|
}
|
|
}
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(h)) {
|
|
const lk = k.toLowerCase();
|
|
if (!HOP_BY_HOP.has(lk) && !extra.has(lk))
|
|
out[k] = v;
|
|
}
|
|
return out;
|
|
}
|
|
/** Remove surrounding square brackets from an IPv6 literal. */
|
|
export function stripBrackets(host) {
|
|
return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
}
|
|
/** Redact userinfo from a URL for safe logging. */
|
|
export function redactUrl(u) {
|
|
if (!u)
|
|
return '-';
|
|
if (!u.username && !u.password)
|
|
return u.href;
|
|
const c = new URL(u.href);
|
|
c.username = '***';
|
|
c.password = '***';
|
|
return c.href;
|
|
}
|
|
function redactUserinfo(raw) {
|
|
// Best-effort redaction for unparseable URLs.
|
|
return raw.replace(/\/\/[^@/]*@/, '//***:***@');
|
|
}
|
|
/**
|
|
* Hostname validation: accepts DNS names and IP literals (without zone IDs).
|
|
* Primary purpose is to block control characters (CRLF injection, null-byte
|
|
* DNS truncation) and zone-identifier allowlist bypasses from reaching the
|
|
* wire or the allowlist matcher.
|
|
*
|
|
* IPv6 zone IDs (`fe80::1%eth0`) are rejected because `isIP` accepts a very
|
|
* permissive zone charset including dots — `::ffff:1.2.3.4%x.allowed.com`
|
|
* would pass `isIP`, pass a `.endsWith('.allowed.com')` wildcard check, and
|
|
* then connect to 1.2.3.4 when the OS discards the bogus scope.
|
|
*/
|
|
export function isValidHost(h) {
|
|
if (!h || h.length > 255)
|
|
return false;
|
|
const bare = stripBrackets(h);
|
|
// Reject zone identifiers outright (see doc comment).
|
|
if (bare.includes('%'))
|
|
return false;
|
|
if (isIP(bare))
|
|
return true;
|
|
// DNS label charset. Underscore is permitted for compatibility with real-
|
|
// world DNS records (_dmarc, _acme-challenge, etc.).
|
|
return /^[A-Za-z0-9._-]+$/.test(bare);
|
|
}
|
|
/**
|
|
* Canonicalize a host string via the WHATWG URL parser so that string
|
|
* comparisons in the allowlist agree with what `net.connect()`/`getaddrinfo()`
|
|
* will actually dial. This normalizes:
|
|
* - inet_aton shorthand (`127.1` → `127.0.0.1`, `2130706433` → `127.0.0.1`)
|
|
* - hex/octal octets (`0x7f.0.0.1` → `127.0.0.1`)
|
|
* - IPv6 compression (`0:0:0:0:0:0:0:1` → `::1`)
|
|
* - trailing dots, case, brackets
|
|
*
|
|
* Returns undefined if the input is not a valid URL host.
|
|
*/
|
|
export function canonicalizeHost(h) {
|
|
try {
|
|
const bare = stripBrackets(h);
|
|
// WHATWG URL rejects zone IDs and most garbage; it normalizes inet_aton
|
|
// forms and IPv6 compression. It does NOT strip trailing dots or IPv6
|
|
// brackets from the output, so we do that ourselves.
|
|
const bracketed = isIP(bare) === 6 ? `[${bare}]` : bare;
|
|
const out = new URL(`http://${bracketed}/`).hostname;
|
|
return stripBrackets(out).replace(/\.$/, '');
|
|
}
|
|
catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
/**
|
|
* Dial `host:port` directly with a bounded timeout. Shared by the HTTP and
|
|
* SOCKS direct-connect paths so they get the same timeout behaviour as the
|
|
* CONNECT-tunnelled paths.
|
|
*/
|
|
export function dialDirect(host, port, timeoutMs = CONNECT_TIMEOUT_MS) {
|
|
return new Promise((resolve, reject) => {
|
|
const s = netConnect(port, host);
|
|
let settled = false;
|
|
const done = (err) => {
|
|
if (settled)
|
|
return;
|
|
settled = true;
|
|
s.setTimeout(0);
|
|
if (err) {
|
|
s.destroy();
|
|
reject(err);
|
|
}
|
|
else {
|
|
resolve(s);
|
|
}
|
|
};
|
|
s.setTimeout(timeoutMs, () => done(new Error('connect timed out')));
|
|
s.once('connect', () => done());
|
|
s.once('error', done);
|
|
s.once('close', () => done(new Error('socket closed before connect')));
|
|
});
|
|
}
|
|
//# sourceMappingURL=parent-proxy.js.map
|