585 lines
28 KiB
JavaScript
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();
|