aere-quantum/pq-gateway/pq-gateway.mjs

585 lines
28 KiB
JavaScript

#!/usr/bin/env node
// pq-gateway.mjs, Aere Cloud, Quantum Security Gateway (randul 1 din lista Cloud).
//
// Ce face: termina TLS 1.3 cu schimb de chei hibrid X25519MLKEM768 (ML-KEM-768 + X25519) si trimite cererile HTTP/1.1
// mai departe la serviciul clientului (UPSTREAM), cu corpul in flux. Numai module node:*, fara nicio dependinta; cere
// Node legat de OpenSSL >= 3.5 (verificat la pornire, altfel refuza sa porneasca).
//
// Cele doua moduri:
// hybrid-only ofera NUMAI X25519MLKEM768. Un client fara el e refuzat la strangerea de mana. Garantia e
// STRUCTURALA: nu exista alt grup pe care sa se poata negocia.
// hybrid-preferred X25519MLKEM768, apoi X25519 si P-256. Preferinta e scrisa cu TUPLE OpenSSL 3.5
// ('X25519MLKEM768/X25519:P-256'): un client care stie hibridul dar a trimis doar o cota X25519
// primeste un HelloRetryRequest si ajunge pe hibrid. Masurat 2026-09-25 pe Node 24.14.1 +
// OpenSSL 3.5.5: cu lista PLATA 'X25519MLKEM768:X25519:P-256' acelasi client ramanea pe X25519,
// deci "intai hibridul" scris ca lista plata nu era adevarat.
//
// Ce NU afirma, si de ce: pe partea de SERVER Node nu expune grupul negociat (getEphemeralKeyInfo e numai pentru
// client), deci gateway-ul nu spune per conexiune ce grup s-a folosit. In hybrid-only nu are nevoie (e singurul grup);
// in hybrid-preferred punctul de stare spune explicit ca nu poate spune, si nu numara conexiuni "hibride".
//
// Configuratie: variabile AERE_PQGW_* (lista in CHEI, mai jos, si in README), optional un fisier JSON dat prin
// AERE_PQGW_CONFIG; mediul are prioritate fata de fisier. O cheie necunoscuta in fisier e o eroare, nu o tacere.
//
// Oprire: SIGTERM/SIGINT inchid ascultatorul, lasa cererile in curs sa se termine (cel mult AERE_PQGW_SHUTDOWN_GRACE_MS),
// pun process.exitCode si lasa bucla de evenimente sa se goleasca; niciun process.exit() dupa I/O.
import https from 'node:https';
import http from 'node:http';
import fs from 'node:fs';
import net from 'node:net';
import crypto from 'node:crypto';
import { pathToFileURL } from 'node:url';
export const VERSIUNE = '1.0.0';
export const CALE_STARE = '/.well-known/aere-pq-gateway';
// Grupurile oferite, pe mod. '/' separa tuple de preferinta (OpenSSL 3.5), ':' separa grupuri in acelasi tuplu.
export const GRUPURI = {
'hybrid-only': 'X25519MLKEM768',
'hybrid-preferred': 'X25519MLKEM768/X25519:P-256',
};
// Antete hop-by-hop (RFC 9110 7.6.1), plus Proxy-* scoase dupa prefix si orice nume enumerat in Connection.
const HOP = new Set(['connection', 'keep-alive', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
// Antete pe care le scrie gateway-ul insusi; ce trimite clientul sub aceste nume nu trece, altfel s-ar putea falsifica.
// Expect: 100-continue e deja raspuns de serverul Node inaintea handler-ului, deci nu se mai cere o data upstream-ului.
const PROPRII = new Set(['x-aere-pq-gateway', 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-real-ip', 'forwarded', 'host', 'expect']);
// Antete de IDENTITATE pe care un upstream le crede venite de la un proxy de incredere (nginx realip, cadre web, mTLS terminat
// in fata): clientul nu are voie sa le scrie. Masurat 2026-09-25 (revizuire): X-Real-IP si X-Client-Cert treceau neatinse.
const PREFIXE_IDENTITATE = ['x-client-', 'x-ssl-'];
// Variabila de mediu -> cheia din fisierul JSON.
const CHEI = {
AERE_PQGW_LISTEN: 'listen',
AERE_PQGW_CERT: 'cert',
AERE_PQGW_KEY: 'key',
AERE_PQGW_UPSTREAM: 'upstream',
AERE_PQGW_UPSTREAM_CA: 'upstreamCa',
AERE_PQGW_MODE: 'mode',
AERE_PQGW_CONNECT_TIMEOUT_MS: 'connectTimeoutMs',
AERE_PQGW_REQUEST_TIMEOUT_MS: 'requestTimeoutMs',
AERE_PQGW_HANDSHAKE_TIMEOUT_MS: 'handshakeTimeoutMs',
AERE_PQGW_SHUTDOWN_GRACE_MS: 'shutdownGraceMs',
AERE_PQGW_MAX_HEADER_SIZE: 'maxHeaderSize',
AERE_PQGW_TRUST_FORWARDED: 'trustForwarded',
AERE_PQGW_PRESERVE_HOST: 'preserveHost',
AERE_PQGW_REQUIRE_PQ_AUTH: 'requirePqAuth',
};
function intreg(nume, v, min, max) {
const n = typeof v === 'number' ? v : Number(String(v).trim());
if (!Number.isInteger(n) || n < min || n > max) throw new Error(`${nume} must be an integer between ${min} and ${max}`);
return n;
}
function boolean(nume, v) {
if (typeof v === 'boolean') return v;
const s = String(v).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(s)) return true;
if (['0', 'false', 'no', 'off', ''].includes(s)) return false;
throw new Error(`${nume} must be true or false`);
}
export function citesteAdresa(s) {
const t = String(s).trim();
let m = t.match(/^\[([0-9a-fA-F:.]+)\]:(\d+)$/);
if (m) return { host: m[1], port: intreg('LISTEN port', m[2], 0, 65535) };
m = t.match(/^([^:[\]]*):(\d+)$/);
if (m) return { host: m[1] || undefined, port: intreg('LISTEN port', m[2], 0, 65535) };
throw new Error('LISTEN must be host:port, [ipv6]:port or :port');
}
// Valorile se taie (trim) la citire: un fisier de mediu cu CRLF nu are voie sa lipeasca un CR in cale sau in URL.
export function citesteConfig(env = process.env) {
let f = {};
if (env.AERE_PQGW_CONFIG && String(env.AERE_PQGW_CONFIG).trim()) {
let brut;
try { brut = fs.readFileSync(String(env.AERE_PQGW_CONFIG).trim(), 'utf8'); } catch (e) { throw new Error(`cannot read AERE_PQGW_CONFIG (${e.code || 'error'})`); }
try { f = JSON.parse(brut); } catch { throw new Error('AERE_PQGW_CONFIG is not valid JSON'); }
if (!f || typeof f !== 'object' || Array.isArray(f)) throw new Error('AERE_PQGW_CONFIG must contain a JSON object');
const stiute = new Set(Object.values(CHEI));
for (const k of Object.keys(f)) if (!stiute.has(k)) throw new Error(`unknown key in config file: ${k}`);
}
const v = (numeEnv, implicit) => {
const e = env[numeEnv];
if (e !== undefined && String(e).trim() !== '') return String(e).trim();
const k = CHEI[numeEnv];
if (f[k] !== undefined && f[k] !== null) return typeof f[k] === 'string' ? f[k].trim() : f[k];
return implicit;
};
const mode = v('AERE_PQGW_MODE', null);
if (!mode) throw new Error('MODE is required: hybrid-only or hybrid-preferred');
if (!Object.prototype.hasOwnProperty.call(GRUPURI, mode)) throw new Error('MODE must be hybrid-only or hybrid-preferred');
const cert = v('AERE_PQGW_CERT', null);
const key = v('AERE_PQGW_KEY', null);
if (!cert) throw new Error('CERT is required (PEM certificate chain)');
if (!key) throw new Error('KEY is required (PEM private key)');
const upstream = v('AERE_PQGW_UPSTREAM', null);
if (!upstream) throw new Error('UPSTREAM is required (http://... or https://...)');
let u;
try { u = new URL(upstream); } catch { throw new Error('UPSTREAM is not a valid URL'); }
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('UPSTREAM must start with http:// or https://');
if (u.username || u.password) throw new Error('UPSTREAM must not contain credentials');
if (u.search || u.hash) throw new Error('UPSTREAM must not contain a query string or fragment');
const { host, port } = citesteAdresa(v('AERE_PQGW_LISTEN', ':8443'));
const maxHeaderSize = v('AERE_PQGW_MAX_HEADER_SIZE', null);
return {
host, port, cert, key, mode,
upstream: u.href,
upstreamCa: v('AERE_PQGW_UPSTREAM_CA', null),
connectTimeoutMs: intreg('CONNECT_TIMEOUT_MS', v('AERE_PQGW_CONNECT_TIMEOUT_MS', 5000), 100, 600000),
requestTimeoutMs: intreg('REQUEST_TIMEOUT_MS', v('AERE_PQGW_REQUEST_TIMEOUT_MS', 60000), 100, 3600000),
handshakeTimeoutMs: intreg('HANDSHAKE_TIMEOUT_MS', v('AERE_PQGW_HANDSHAKE_TIMEOUT_MS', 10000), 100, 600000),
shutdownGraceMs: intreg('SHUTDOWN_GRACE_MS', v('AERE_PQGW_SHUTDOWN_GRACE_MS', 10000), 0, 600000),
maxHeaderSize: maxHeaderSize === null ? null : intreg('MAX_HEADER_SIZE', maxHeaderSize, 1024, 1048576),
trustForwarded: boolean('TRUST_FORWARDED', v('AERE_PQGW_TRUST_FORWARDED', false)),
preserveHost: boolean('PRESERVE_HOST', v('AERE_PQGW_PRESERVE_HOST', false)),
requirePqAuth: boolean('REQUIRE_PQ_AUTH', v('AERE_PQGW_REQUIRE_PQ_AUTH', false)),
};
}
export function grupuriOferite(mode) {
return GRUPURI[mode].split(/[:/]/).filter(Boolean);
}
// Motivul unei strangeri de mana esuate, din codul OpenSSL pe care il pune Node pe eroare.
// Masurat 2026-09-25: client numai X25519 fata de hybrid-only -> ERR_SSL_NO_SUITABLE_KEY_SHARE;
// client TLS 1.2 -> ERR_SSL_UNSUPPORTED_PROTOCOL.
export function motivRefuz(e) {
const c = String((e && e.code) || '');
if (c === 'ERR_SSL_NO_SUITABLE_KEY_SHARE' || c === 'ERR_SSL_NO_SHARED_GROUPS' || c === 'ERR_SSL_NO_SUITABLE_GROUPS') return 'no shared group';
if (c === 'ERR_SSL_UNSUPPORTED_PROTOCOL') return 'unsupported protocol';
if (c === 'ERR_SSL_NO_SHARED_CIPHER') return 'no shared cipher';
// cu un lant ML-DSA, refuzul cel mai frecvent: clientul nu ofera un algoritm de semnatura post-cuantic (browserele de azi)
if (c === 'ERR_SSL_NO_SUITABLE_SIGNATURE_ALGORITHM') return 'no shared signature algorithm';
if (c === 'ERR_TLS_HANDSHAKE_TIMEOUT') return 'handshake timeout';
return 'other';
}
// Scoate antetele hop-by-hop dintr-o lista bruta [nume, valoare, nume, valoare, ...].
export function filtreazaAnteturi(brute) {
const numite = new Set();
for (let i = 0; i < brute.length; i += 2) {
if (brute[i].toLowerCase() !== 'connection') continue;
for (const t of String(brute[i + 1]).split(',')) { const n = t.trim().toLowerCase(); if (n) numite.add(n); }
}
const out = [];
for (let i = 0; i < brute.length; i += 2) {
const n = brute[i].toLowerCase();
if (HOP.has(n) || n.startsWith('proxy-') || numite.has(n)) continue;
out.push(brute[i], brute[i + 1]);
}
return out;
}
// Pe un ascultator dual-stack o adresa IPv4 vine ca ::ffff:a.b.c.d; upstream-ul primeste forma IPv4.
export function adresaClient(brut) {
const s = String(brut || '');
return s.toLowerCase().startsWith('::ffff:') && net.isIPv4(s.slice(7)) ? s.slice(7) : s;
}
function versiuneOpenssl() {
const m = String(process.versions.openssl || '').match(/^(\d+)\.(\d+)/);
return m ? [Number(m[1]), Number(m[2])] : [0, 0];
}
function scrieJurnal(o) {
process.stdout.write(JSON.stringify({ time: new Date().toISOString(), ...o }) + '\n');
}
// ------------------------------------------------------------------------------------------------ autentificarea post-cuantica
// Schimbul de chei hibrid apara secretul sesiunii; AUTENTIFICAREA serverului o da semnatura din CertificateVerify (cu cheia
// frunzei) si semnaturile de pe lant. Un lant e "post-quantum" numai daca FIECARE certificat servit are cheie ML-DSA si e semnat
// ML-DSA (algoritmul exterior citit din DER); radacina nu se serveste, deci semnatura ei de pe ultimul certificat servit e citita
// ca oricare alta. Altfel "classical" (nimic ML-DSA) sau "mixed". Cu REQUIRE_PQ_AUTH gateway-ul refuza sa porneasca fara el.
const ML_DSA_OID = new Map([['0609608648016503040311', 'ML-DSA-44'], ['0609608648016503040312', 'ML-DSA-65'], ['0609608648016503040313', 'ML-DSA-87']]);
function lungimeDer(b, o) {
let l = b[o + 1], h = 2;
if (l & 0x80) { const n = l & 0x7f; if (n < 1 || n > 4) throw new Error('bad DER length'); l = 0; for (let i = 0; i < n; i++) l = l * 256 + b[o + 2 + i]; h = 2 + n; }
return { h, l };
}
function algSemnaturaDer(der) {
// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm AlgorithmIdentifier, signatureValue }
const top = lungimeDer(der, 0);
let o = top.h;
const tbs = lungimeDer(der, o); o += tbs.h + tbs.l;
const alg = lungimeDer(der, o);
const oid = der.subarray(o + alg.h, o + alg.h + alg.l);
const e = lungimeDer(oid, 0);
return ML_DSA_OID.get(Buffer.from(oid.subarray(0, e.h + e.l)).toString('hex')) || 'classical';
}
export function analizaLant(certPem) {
const blocuri = String(certPem).match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [];
const lant = blocuri.map((p) => {
const x = new crypto.X509Certificate(p);
const cheie = String(x.publicKey.asymmetricKeyType || 'unknown').toUpperCase();
return { subject: x.subject, publicKeyAlgorithm: cheie, signatureAlgorithm: algSemnaturaDer(x.raw) };
});
const pq = (c) => /^ML-DSA-/.test(c.publicKeyAlgorithm) && /^ML-DSA-/.test(c.signatureAlgorithm);
const autentificare = lant.length && lant.every(pq) ? 'post-quantum' : lant.some((c) => /^ML-DSA-/.test(c.publicKeyAlgorithm) || /^ML-DSA-/.test(c.signatureAlgorithm)) ? 'mixed' : 'classical';
return { lant, autentificare };
}
export function pornesteGateway(cfg, { jurnal = scrieJurnal } = {}) {
const [maj, min] = versiuneOpenssl();
if (maj < 3 || (maj === 3 && min < 5)) throw new Error(`OpenSSL >= 3.5 is required for X25519MLKEM768; this Node is linked to OpenSSL ${process.versions.openssl}`);
let certPem, cheiePem;
try { certPem = fs.readFileSync(cfg.cert); } catch (e) { throw new Error(`cannot read CERT (${e.code || 'error'})`); }
try { cheiePem = fs.readFileSync(cfg.key); } catch (e) { throw new Error(`cannot read KEY (${e.code || 'error'})`); }
let x509, cheie;
try { x509 = new crypto.X509Certificate(certPem); } catch { throw new Error('CERT does not contain a PEM certificate'); }
try { cheie = crypto.createPrivateKey(cheiePem); } catch { throw new Error('KEY does not contain a readable private key'); }
if (!x509.checkPrivateKey(cheie)) throw new Error('KEY does not match the first certificate in CERT');
let lantCert;
try { lantCert = analizaLant(certPem); } catch { throw new Error('CERT contains a certificate that cannot be read'); }
if (cfg.requirePqAuth && lantCert.autentificare !== 'post-quantum') {
const rau = lantCert.lant.find((c) => !/^ML-DSA-/.test(c.publicKeyAlgorithm) || !/^ML-DSA-/.test(c.signatureAlgorithm));
throw new Error(`REQUIRE_PQ_AUTH is set but the served chain is ${lantCert.autentificare}: "${rau ? rau.subject.replace(/\n/g, ', ') : '?'}" has a ${rau ? rau.publicKeyAlgorithm : '?'} key signed with ${rau ? rau.signatureAlgorithm : '?'}; issue an ML-DSA chain (for example with aere-pq-pki)`);
}
const sus = new URL(cfg.upstream);
const susHttps = sus.protocol === 'https:';
const modul = susHttps ? https : http;
const susPort = sus.port || (susHttps ? 443 : 80);
const prefix = sus.pathname.replace(/\/+$/, '');
let caSus = null;
if (susHttps && cfg.upstreamCa) {
try { caSus = fs.readFileSync(cfg.upstreamCa); } catch (e) { throw new Error(`cannot read UPSTREAM_CA (${e.code || 'error'})`); }
}
// Si drumul catre upstream, cand e https, prefera hibridul; nu se afirma nimic despre el in stare.
const agent = susHttps
? new https.Agent({ keepAlive: true, minVersion: 'TLSv1.2', ecdhCurve: GRUPURI['hybrid-preferred'], ...(caSus ? { ca: caSus } : {}) })
: new http.Agent({ keepAlive: true });
const caUpgrade = caSus ? { ca: caSus } : {};
const c = {
conexiuniAcceptate: 0,
refuzuri: { 'no shared group': 0, 'unsupported protocol': 0, 'no shared cipher': 0, 'no shared signature algorithm': 0, 'handshake timeout': 0, other: 0 },
cereriTrimise: 0, tuneluri: 0, r502: 0, r504: 0, cereriStare: 0,
};
const refuzuriPeCod = new Map();
const pornitLa = new Date().toISOString();
const tuneluriDeschise = new Set();
let seInchide = false;
function corpStare() {
const hibridOnly = cfg.mode === 'hybrid-only';
return {
service: 'aere-pq-gateway',
version: VERSIUNE,
mode: cfg.mode,
groupsOffered: grupuriOferite(cfg.mode),
groupPreference: GRUPURI[cfg.mode],
minTlsVersion: 'TLSv1.3',
alpn: ['http/1.1'],
guarantee: hibridOnly
? {
hybridKeyExchangeOnEveryConnection: true,
basis: 'structural: X25519MLKEM768 is the only key exchange group this listener offers, so a handshake that does not use it fails',
}
: {
hybridKeyExchangeOnEveryConnection: false,
basis: 'X25519MLKEM768 is preferred and requested with a HelloRetryRequest when the client supports it; clients without it fall back to X25519 or P-256',
},
negotiatedGroupPerConnection: 'not reported: the Node.js TLS server API does not expose the negotiated group (getEphemeralKeyInfo is client-side only), so this gateway makes no per-connection claim',
certificate: { sha256: x509.fingerprint256, subject: x509.subject, notAfter: x509.validTo,
authentication: lantCert.autentificare, chain: lantCert.lant },
upstream: { scheme: susHttps ? 'https' : 'http', encrypted: susHttps },
runtime: { node: process.version, openssl: process.versions.openssl },
startedAt: pornitLa,
counters: {
connectionsAccepted: c.conexiuniAcceptate,
handshakesRefused: { ...c.refuzuri },
handshakesRefusedByCode: Object.fromEntries(refuzuriPeCod),
requestsForwarded: c.cereriTrimise,
upgradesTunneled: c.tuneluri,
responses502: c.r502,
responses504: c.r504,
statusRequests: c.cereriStare,
},
};
}
function esteCaleaStarii(url) {
const q = url.indexOf('?');
return (q === -1 ? url : url.slice(0, q)) === CALE_STARE;
}
function raspundeJson(req, res, cod, obj, inchide = false) {
const corp = JSON.stringify(obj) + '\n';
const h = { 'content-type': 'application/json', 'content-length': Buffer.byteLength(corp), 'cache-control': 'no-store' };
if (inchide || seInchide) h.connection = 'close';
res.writeHead(cod, h);
res.end(req.method === 'HEAD' ? undefined : corp);
}
// Raspuns scris direct pe un socket (drumul de upgrade nu are ServerResponse).
function scrieBrut(socket, cod, obj) {
const corp = JSON.stringify(obj) + '\n';
socket.end(`HTTP/1.1 ${cod} ${http.STATUS_CODES[cod]}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(corp)}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n${corp}`);
}
function anteturiCatreUpstream(req, upgrade) {
const h = filtreazaAnteturi(req.rawHeaders);
const out = [];
let xffVechi = null;
for (let i = 0; i < h.length; i += 2) {
const n = h[i].toLowerCase();
if (n === 'x-forwarded-for') {
if (cfg.trustForwarded) xffVechi = xffVechi ? `${xffVechi}, ${h[i + 1]}` : h[i + 1];
continue;
}
if (n === 'forwarded' && cfg.trustForwarded) { out.push(h[i], h[i + 1]); continue; }
if (PROPRII.has(n) || PREFIXE_IDENTITATE.some((p) => n.startsWith(p))) continue;
out.push(h[i], h[i + 1]);
}
const hostOriginal = req.headers.host;
out.push('Host', cfg.preserveHost && hostOriginal ? hostOriginal : sus.host);
const ip = adresaClient(req.socket.remoteAddress);
out.push('X-Forwarded-For', xffVechi ? `${xffVechi}, ${ip}` : ip);
out.push('X-Real-IP', ip);
out.push('X-Forwarded-Proto', 'https');
if (hostOriginal) out.push('X-Forwarded-Host', hostOriginal);
out.push('x-aere-pq-gateway', cfg.mode);
if (upgrade) out.push('Connection', 'Upgrade', 'Upgrade', String(req.headers.upgrade));
else if (req.headers['transfer-encoding'] !== undefined) out.push('Transfer-Encoding', 'chunked');
return out;
}
function optiuniUpstream(req, anteturi, peUpgrade) {
return {
protocol: sus.protocol, hostname: sus.hostname, port: susPort,
method: req.method, path: req.url === '*' ? '*' : prefix + req.url,
headers: anteturi, setHost: false,
agent: peUpgrade ? false : agent,
...(peUpgrade && susHttps ? { minVersion: 'TLSv1.2', ecdhCurve: GRUPURI['hybrid-preferred'], ...caUpgrade } : {}),
};
}
// Doua cronometre: conectarea la upstream (TCP, plus TLS cand e https) si raspunsul lui, numarat de cand cererea a
// fost trimisa intreaga. Oricare expira -> 504. Un socket refolosit din bazin nu mai are faza de conectare.
// Un upstream poate raspunde INAINTE sa fi primit tot corpul; atunci 'finish' vine dupa raspuns si nu mai porneste
// nimic. Masurat 2026-09-25: fara garda, un raspuns care curgea inca a fost taiat la REQUEST_TIMEOUT dupa 'finish'.
function puneCronometre(upReq) {
let tConectare = null, tRaspuns = null, raspunsPrimit = false;
const expirat = (tip) => Object.assign(new Error(`upstream ${tip} timeout`), { aereTimeout: tip });
upReq.on('socket', (s) => {
if (!s.connecting) return;
tConectare = setTimeout(() => upReq.destroy(expirat('connect')), cfg.connectTimeoutMs);
s.once(susHttps ? 'secureConnect' : 'connect', () => { clearTimeout(tConectare); tConectare = null; });
});
upReq.on('finish', () => {
if (raspunsPrimit) return;
tRaspuns = setTimeout(() => upReq.destroy(expirat('response')), cfg.requestTimeoutMs);
});
const opreste = () => { raspunsPrimit = true; clearTimeout(tConectare); clearTimeout(tRaspuns); };
upReq.on('close', opreste);
return { opreste };
}
function corpEroare(cod, e) {
if (cod === 504) return { error: 'gateway_timeout', detail: e && e.aereTimeout === 'connect' ? 'upstream connect timeout' : 'upstream response timeout' };
return { error: 'bad_gateway', detail: 'upstream unreachable or connection failed' };
}
function numaraEroare(e) {
const cod = e && e.aereTimeout ? 504 : 502;
if (cod === 504) c.r504++; else c.r502++;
jurnal({ event: 'upstream_error', status: cod, code: (e && (e.aereTimeout || e.code)) || 'unknown' });
return cod;
}
function laCerere(req, res) {
if (esteCaleaStarii(req.url)) {
c.cereriStare++;
req.resume();
if (req.method !== 'GET' && req.method !== 'HEAD') { res.setHeader('allow', 'GET, HEAD'); return raspundeJson(req, res, 405, { error: 'method_not_allowed' }); }
return raspundeJson(req, res, 200, corpStare());
}
if (!(req.url.startsWith('/') || (req.method === 'OPTIONS' && req.url === '*'))) {
req.resume();
return raspundeJson(req, res, 400, { error: 'bad_request', detail: 'request target must be origin-form' }, true);
}
const te = req.headers['transfer-encoding'];
if (te !== undefined && String(te).trim().toLowerCase() !== 'chunked') {
req.resume();
return raspundeJson(req, res, 501, { error: 'not_implemented', detail: 'only chunked transfer-encoding is supported' }, true);
}
const upReq = modul.request(optiuniUpstream(req, anteturiCatreUpstream(req, false), false));
c.cereriTrimise++;
const cron = puneCronometre(upReq);
let raspuns = false;
upReq.on('response', (upRes) => {
cron.opreste();
if (raspuns) { upRes.resume(); return; }
raspuns = true;
const h = filtreazaAnteturi(upRes.rawHeaders);
if (seInchide) h.push('Connection', 'close');
try {
res.writeHead(upRes.statusCode, upRes.statusMessage, h);
} catch (e) {
jurnal({ event: 'upstream_bad_response', code: e.code || 'error' });
upRes.destroy();
res.destroy();
return;
}
upRes.on('error', () => res.destroy());
upRes.on('close', () => { if (!upRes.complete) res.destroy(); });
upRes.pipe(res);
});
upReq.on('error', (e) => {
if (raspuns) { res.destroy(); return; }
raspuns = true;
req.unpipe(upReq);
req.resume();
// Clientul a plecat inainte de raspuns: cererea catre upstream a fost taiata de noi, nu e o eroare a upstream-ului.
// Masurat 2026-09-25: fara garda asta, doua plecari de client au iesit "responses502 +2".
if (res.headersSent || res.destroyed) { res.destroy(); return; }
const cod = numaraEroare(e);
raspundeJson(req, res, cod, corpEroare(cod, e), true);
});
res.on('close', () => { if (!res.writableFinished) upReq.destroy(); });
req.pipe(upReq);
}
const server = https.createServer({
key: cheiePem,
cert: certPem,
minVersion: 'TLSv1.3',
ecdhCurve: GRUPURI[cfg.mode],
ALPNProtocols: ['http/1.1'],
handshakeTimeout: cfg.handshakeTimeoutMs,
...(cfg.maxHeaderSize ? { maxHeaderSize: cfg.maxHeaderSize } : {}),
}, laCerere);
server.on('secureConnection', () => { c.conexiuniAcceptate++; });
server.on('tlsClientError', (e, sock) => {
c.refuzuri[motivRefuz(e)]++;
const cod = String((e && e.code) || 'UNKNOWN').slice(0, 64);
const cheieCod = refuzuriPeCod.has(cod) || refuzuriPeCod.size < 31 ? cod : '(other codes)';
refuzuriPeCod.set(cheieCod, (refuzuriPeCod.get(cheieCod) || 0) + 1);
if (sock && !sock.destroyed) sock.destroy();
});
server.on('upgrade', (req, socket, head) => {
socket.on('error', () => {});
if (esteCaleaStarii(req.url) || !req.url.startsWith('/')) { scrieBrut(socket, 400, { error: 'bad_request', detail: 'upgrade not allowed on this path' }); return; }
const upReq = modul.request(optiuniUpstream(req, anteturiCatreUpstream(req, true), true));
c.cereriTrimise++;
const cron = puneCronometre(upReq);
let gata = false;
upReq.on('upgrade', (upRes, upSock, upHead) => {
cron.opreste();
gata = true;
upSock.on('error', () => {});
if (socket.destroyed) { upSock.destroy(); return; }
c.tuneluri++;
const h = filtreazaAnteturi(upRes.rawHeaders);
const linii = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage || 'Switching Protocols'}`];
for (let i = 0; i < h.length; i += 2) linii.push(`${h[i]}: ${h[i + 1]}`);
linii.push('Connection: Upgrade', `Upgrade: ${upRes.headers.upgrade || req.headers.upgrade}`);
socket.write(linii.join('\r\n') + '\r\n\r\n');
if (upHead && upHead.length) socket.write(upHead);
if (head && head.length) upSock.write(head);
tuneluriDeschise.add(socket);
socket.setTimeout(0);
socket.setNoDelay(true);
upSock.setNoDelay(true);
const inchide = () => { tuneluriDeschise.delete(socket); socket.destroy(); upSock.destroy(); };
socket.on('close', inchide);
upSock.on('close', inchide);
upSock.pipe(socket);
socket.pipe(upSock);
});
// Upstream-ul nu a acceptat upgrade-ul: raspunsul lui ajunge la client, apoi conexiunea se inchide.
upReq.on('response', (upRes) => {
cron.opreste();
gata = true;
const h = filtreazaAnteturi(upRes.rawHeaders);
const linii = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage || ''}`];
for (let i = 0; i < h.length; i += 2) linii.push(`${h[i]}: ${h[i + 1]}`);
linii.push('Connection: close');
socket.write(linii.join('\r\n') + '\r\n\r\n');
upRes.on('error', () => socket.destroy());
upRes.pipe(socket);
});
upReq.on('error', (e) => {
if (gata) { socket.destroy(); return; }
gata = true;
if (socket.destroyed) return; // clientul a plecat: nu se numara ca eroare de upstream
const cod = numaraEroare(e);
if (!socket.destroyed) scrieBrut(socket, cod, corpEroare(cod, e));
});
socket.on('close', () => { if (!gata) upReq.destroy(); });
upReq.end();
});
function opreste(graceMs) {
seInchide = true;
return new Promise((resolve) => {
const t = setTimeout(() => {
server.closeAllConnections();
for (const s of tuneluriDeschise) s.destroy();
}, graceMs);
server.close(() => { clearTimeout(t); agent.destroy(); resolve(); });
server.closeIdleConnections();
});
}
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(cfg.port, cfg.host, () => {
server.off('error', reject);
server.on('error', (e) => jurnal({ event: 'server_error', code: e.code || 'error' }));
resolve({ server, adresa: server.address(), opreste, stare: corpStare });
});
});
}
async function main() {
let cfg;
try { cfg = citesteConfig(process.env); } catch (e) {
process.stderr.write(`aere-pq-gateway: configuration error: ${e.message}\n`);
process.exitCode = 2;
return;
}
let gw;
try { gw = await pornesteGateway(cfg); } catch (e) {
process.stderr.write(`aere-pq-gateway: failed to start: ${e.message}\n`);
process.exitCode = 1;
return;
}
const s = gw.stare();
scrieJurnal({
event: 'listening', address: gw.adresa.address, port: gw.adresa.port, mode: cfg.mode,
groups: GRUPURI[cfg.mode], minTlsVersion: 'TLSv1.3', certificateSha256: s.certificate.sha256,
upstreamScheme: s.upstream.scheme, node: process.version, openssl: process.versions.openssl,
});
if (!s.upstream.encrypted) scrieJurnal({ event: 'warning', detail: 'UPSTREAM is plain http: the hop from this gateway to the upstream is not encrypted; keep it on a trusted network or use https' });
let oprire = false;
const laSemnal = (semnal) => {
if (oprire) return;
oprire = true;
scrieJurnal({ event: 'shutdown', signal: semnal, graceMs: cfg.shutdownGraceMs });
gw.opreste(cfg.shutdownGraceMs).then(() => {
scrieJurnal({ event: 'stopped' });
process.exitCode = 0;
});
};
process.on('SIGTERM', () => laSemnal('SIGTERM'));
process.on('SIGINT', () => laSemnal('SIGINT'));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();