#!/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();