Skip to content

Commit bf27e47

Browse files
authored
fix(seeder): TLS proxy CONNECT — fixes FRED fetch failures (FSI, yield curve, macro) (#2538)
* fix(seeder): use TLS for proxy CONNECT tunnel to fix FRED fetch failures Decodo gate.decodo.com:10001 requires TLS. Previous code used http.request (plain TCP) which received SOCKS5 rejection bytes instead of HTTP 200. Two issues fixed: 1. Replace http.request CONNECT with tls.connect + manual CONNECT handshake. Node.js http.request also auto-sets Host to the proxy hostname; Decodo rejects this and responds with SOCKS5 bytes (0x05 0xff). Manual CONNECT over a raw TLS socket avoids both issues. 2. Handle https:// and plain "user:pass@host:port" proxy URL formats — always uses TLS regardless of PROXY_URL prefix. _proxy-utils.cjs: resolveProxyStringConnect now preserves https:// prefix from PROXY_URL so callers can detect TLS proxies explicitly. All 24 FRED series (BAMLH0A0HYM2, FEDFUNDS, DGS10, etc.) confirmed working locally via gate.decodo.com:10001. * fix(seeder): respect http:// proxy scheme + buffer full CONNECT response Two protocol-correctness fixes: 1. http:// proxies used plain TCP before; always-TLS regressed them. Now: bare/undeclared format → TLS (Decodo requires it), explicit http:// → plain net.connect, explicit https:// → TLS. 2. CONNECT response buffered until \r\n\r\n instead of acting on the first data chunk. Fragmented proxy responses (headers split across packets) could corrupt the TLS handshake by leaving header bytes on the wire when tls.connect() was called too early. Verified locally: BAMLH0A0HYM2 → { date: 2026-03-26, value: 3.21 } * chore(seeder): remove unused http import, fix stale JSDoc - Drop `import * as http from 'node:http'` — no longer used after replacing http.request CONNECT with tls.connect + manual handshake - Update resolveProxyStringConnect() JSDoc: https.request → tls.connect
1 parent 09652ed commit bf27e47

File tree

2 files changed

+63
-25
lines changed

2 files changed

+63
-25
lines changed

scripts/_proxy-utils.cjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,15 @@ function resolveProxyString() {
8282
/**
8383
* Returns proxy as "user:pass@host:port" string for use with HTTP CONNECT tunneling.
8484
* Does NOT replace gate.decodo.com → us.decodo.com; CONNECT endpoint is gate.decodo.com.
85+
* When PROXY_URL uses https:// (TLS proxy), returns "https://user:pass@host:port" so
86+
* httpsProxyFetchJson uses tls.connect to the proxy instead of plain net.connect.
8587
* Returns empty string if no proxy configured.
8688
*/
8789
function resolveProxyStringConnect() {
8890
const cfg = resolveProxyConfigWithFallback();
8991
if (!cfg) return '';
90-
return cfg.auth ? `${cfg.auth}@${cfg.host}:${cfg.port}` : `${cfg.host}:${cfg.port}`;
92+
const base = cfg.auth ? `${cfg.auth}@${cfg.host}:${cfg.port}` : `${cfg.host}:${cfg.port}`;
93+
return cfg.tls ? `https://${base}` : base;
9194
}
9295

9396
module.exports = { parseProxyConfig, resolveProxyConfig, resolveProxyConfigWithFallback, resolveProxyString, resolveProxyStringConnect };

scripts/_seed-utils.mjs

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
55
import { dirname, join } from 'node:path';
66
import { fileURLToPath } from 'node:url';
77
import { createRequire } from 'node:module';
8-
import * as http from 'node:http';
8+
import * as net from 'node:net';
99
import * as tls from 'node:tls';
1010
import * as https from 'node:https';
1111
import { promisify } from 'node:util';
@@ -342,43 +342,78 @@ export function curlFetch(url, proxyAuth, headers = {}) {
342342
return raw.slice(0, nl);
343343
}
344344

345-
// Pure Node.js HTTPS-through-HTTP-proxy (CONNECT tunnel).
346-
// Replaces curlFetch for seeder scripts running in containers without curl.
347-
// proxyAuth format: "user:pass@host:port"
345+
// Pure Node.js HTTPS-through-proxy (CONNECT tunnel).
346+
// proxyAuth format: "user:pass@host:port" (bare/Decodo → TLS) OR
347+
// "https://user:pass@host:port" (explicit TLS) OR
348+
// "http://user:pass@host:port" (explicit plain TCP)
349+
// Bare/undeclared-scheme proxies always use TLS (Decodo gate.decodo.com requires it).
350+
// Explicit http:// proxies use plain TCP to avoid breaking non-TLS setups.
348351
async function httpsProxyFetchJson(url, proxyAuth) {
349352
const targetUrl = new URL(url);
350-
const atIdx = proxyAuth.lastIndexOf('@');
351-
const credentials = atIdx >= 0 ? proxyAuth.slice(0, atIdx) : '';
352-
const hostPort = atIdx >= 0 ? proxyAuth.slice(atIdx + 1) : proxyAuth;
353+
354+
// Detect whether the proxy URL specifies http:// explicitly (plain TCP) or not
355+
// (bare format or https:// → TLS). User instruction: bare → always TLS.
356+
const explicitHttp = proxyAuth.startsWith('http://') && !proxyAuth.startsWith('https://');
357+
const useTls = !explicitHttp;
358+
359+
// Strip scheme prefix, parse user:pass@host:port.
360+
let proxyAuthStr = proxyAuth;
361+
if (proxyAuth.startsWith('https://') || proxyAuth.startsWith('http://')) {
362+
const u = new URL(proxyAuth);
363+
proxyAuthStr = (u.username ? `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}@` : '') + `${u.hostname}:${u.port}`;
364+
}
365+
366+
const atIdx = proxyAuthStr.lastIndexOf('@');
367+
const credentials = atIdx >= 0 ? proxyAuthStr.slice(0, atIdx) : '';
368+
const hostPort = atIdx >= 0 ? proxyAuthStr.slice(atIdx + 1) : proxyAuthStr;
353369
const colonIdx = hostPort.lastIndexOf(':');
354370
const proxyHost = hostPort.slice(0, colonIdx);
355371
const proxyPort = parseInt(hostPort.slice(colonIdx + 1), 10);
356372

357-
const connectHeaders = {};
358-
if (credentials) {
359-
connectHeaders['Proxy-Authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`;
360-
}
373+
// Step 1: Open socket to proxy (TLS for https:// or bare, plain TCP for http://).
374+
const proxySock = await new Promise((resolve, reject) => {
375+
if (useTls) {
376+
const s = tls.connect({ host: proxyHost, port: proxyPort, servername: proxyHost, ALPNProtocols: ['http/1.1'] }, () => resolve(s));
377+
s.on('error', reject);
378+
} else {
379+
const s = net.connect({ host: proxyHost, port: proxyPort }, () => resolve(s));
380+
s.on('error', reject);
381+
}
382+
});
361383

362-
const { socket } = await new Promise((resolve, reject) => {
363-
http.request({
364-
host: proxyHost, port: proxyPort,
365-
method: 'CONNECT',
366-
path: `${targetUrl.hostname}:443`,
367-
headers: connectHeaders,
368-
}).on('connect', (res, socket) => {
369-
if (res.statusCode !== 200) {
370-
socket.destroy();
371-
return reject(Object.assign(new Error(`Proxy CONNECT: ${res.statusCode}`), { status: res.statusCode }));
384+
// Step 2: Send HTTP CONNECT manually (avoids Node.js http.request auto-setting
385+
// Host to the proxy hostname, which Decodo rejects with SOCKS5 bytes).
386+
const authHeader = credentials ? `\r\nProxy-Authorization: Basic ${Buffer.from(credentials).toString('base64')}` : '';
387+
proxySock.write(`CONNECT ${targetUrl.hostname}:443 HTTP/1.1\r\nHost: ${targetUrl.hostname}:443${authHeader}\r\n\r\n`);
388+
389+
// Step 3: Buffer until the full CONNECT response headers arrive (\r\n\r\n).
390+
// Using a single 'data' event is not safe — headers may arrive fragmented across
391+
// multiple packets, leaving unread bytes that corrupt the subsequent TLS handshake.
392+
await new Promise((resolve, reject) => {
393+
let buf = '';
394+
const onData = (chunk) => {
395+
buf += chunk.toString('ascii');
396+
if (!buf.includes('\r\n\r\n')) return;
397+
proxySock.removeListener('data', onData);
398+
const statusLine = buf.split('\r\n')[0];
399+
if (!statusLine.startsWith('HTTP/1.1 200') && !statusLine.startsWith('HTTP/1.0 200')) {
400+
proxySock.destroy();
401+
return reject(Object.assign(new Error(`Proxy CONNECT: ${statusLine}`), { status: parseInt(statusLine.split(' ')[1]) || 0 }));
372402
}
373-
resolve({ socket });
374-
}).on('error', reject).end();
403+
proxySock.pause();
404+
resolve();
405+
};
406+
proxySock.on('data', onData);
407+
proxySock.on('error', reject);
375408
});
376409

377-
const tlsSock = tls.connect({ socket, servername: targetUrl.hostname, ALPNProtocols: ['http/1.1'] });
410+
// Step 4: TLS over the proxy tunnel (TLS-in-TLS) to reach the target server.
411+
const tlsSock = tls.connect({ socket: proxySock, servername: targetUrl.hostname, ALPNProtocols: ['http/1.1'] });
378412
await new Promise((resolve, reject) => {
379413
tlsSock.on('secureConnect', resolve);
380414
tlsSock.on('error', reject);
381415
});
416+
proxySock.resume();
382417

383418
return new Promise((resolve, reject) => {
384419
const timer = setTimeout(() => { tlsSock.destroy(); reject(new Error('FRED proxy fetch timeout')); }, 20000);

0 commit comments

Comments
 (0)