import { X509Certificate } from 'crypto'; import * as http from 'http'; import * as https from 'https'; import * as net from 'net'; import * as tls from 'tls'; /** * The one HTTP client both accounting drivers use. * * Built on node:http(s) rather than global fetch because a self-hosted * Tahesab/Zarrin often serves a self-signed certificate, and fetch cannot be * given a CA without an extra dependency. The reference "solved" that with * `Http::withoutVerifying()` (SEC-17); here verification is ALWAYS on and the * admin can paste the installation's certificate instead. * * A pasted certificate is trusted two ways: as a CA (the normal chain and * hostname checks), and as a pin — when the server presents exactly that * certificate it is accepted whatever name it was issued for. Tahesab's own * certificate is self-signed for `localhost`, so a remote install reached by * IP can only be verified by the pin. * * Errors say whether the request may have reached the server. That decides * what the outbox may do next: a refused connection is safe to retry, a * timeout after the request went out is not (Zarrin has no idempotency key). */ export interface HttpRequest { method: 'GET' | 'POST'; url: string; headers?: Record; body?: string; timeoutMs: number; /** PEM CA certificate(s) to trust in addition to the system store. */ ca?: string | null; } export interface HttpResponse { status: number; body: string; } export class AccountingHttpError extends Error { constructor( message: string, /** True when the request may have been received and acted on. */ readonly ambiguous: boolean, readonly code?: string, ) { super(message); this.name = 'AccountingHttpError'; } } const MAX_BODY_BYTES = 20 * 1024 * 1024; const TLS_CERT_ERRORS = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/; /** The PEM blocks in a pasted bundle, parsed; unparsable blocks are skipped. */ export function parsePemCertificates(pem: string | null | undefined): X509Certificate[] { const blocks = (pem ?? '').match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []; const out: X509Certificate[] = []; for (const block of blocks) { try { out.push(new X509Certificate(block)); } catch { // not a certificate — the CA list below will reject it too } } return out; } /** SHA-256 fingerprints, as Node prints them (`AB:CD:…`), of the pasted certificates. */ export function pinnedFingerprints(pem: string | null | undefined): Set { return new Set(parsePemCertificates(pem).map((c) => c.fingerprint256)); } function tlsErrorMessage(err: NodeJS.ErrnoException, pinned: boolean): string { if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') { return pinned ? `گواهی سرور برای این آدرس صادر نشده و با گواهی ثبت‌شده در تنظیمات هم یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید` : `گواهی TLS سرور برای این آدرس صادر نشده (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`; } return pinned ? `گواهی فعلی سرور با گواهی ثبت‌شده در تنظیمات یکی نیست (${err.code}) — اگر گواهی سرور عوض شده، در بخش «گواهی امنیتی سرور» «بررسی دوباره» را بزنید` : `گواهی TLS سرور قابل‌اعتماد نیست (${err.code}) — در تب «اتصال»، بخش «گواهی امنیتی سرور»، «بررسی گواهی سرور» را بزنید و پس از تطبیق اثرانگشت تأیید و ذخیره کنید`; } export function httpRequest(req: HttpRequest): Promise { return send(req, false); } /** * One attempt. `fresh` = on a new connection: a server that closes idle * kept-alive connections (Zarrin v228 does) makes the next request on a pooled * one fail with ECONNRESET / "socket hang up" before it was received. That one * case is repeated once on a fresh connection; nothing else is ever retried. */ function send(req: HttpRequest, fresh: boolean): Promise { let url: URL; try { url = new URL(req.url); } catch { return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL')); } const isHttps = url.protocol === 'https:'; if (!isHttps && url.protocol !== 'http:') { return Promise.reject(new AccountingHttpError('آدرس باید با http یا https شروع شود', false, 'BAD_URL')); } const headers: Record = { ...(req.headers ?? {}) }; if (req.body !== undefined) headers['Content-Length'] = Buffer.byteLength(req.body); const options: https.RequestOptions = { method: req.method, hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), path: `${url.pathname}${url.search}`, headers, ...(fresh ? { agent: false } : {}), }; const pins = isHttps ? pinnedFingerprints(req.ca) : new Set(); if (isHttps) { options.rejectUnauthorized = true; if (req.ca && req.ca.trim()) { // Extra CA on top of the system store, never instead of verification. options.ca = [...tls.rootCertificates, req.ca]; // Runs only after the chain verified. The exact pasted certificate is // accepted under any name; anything else gets the normal name check. // (Pooled sockets are keyed by `ca`, so a reused one had the same pins.) options.checkServerIdentity = (host, cert) => pins.has(cert.fingerprint256) ? undefined : tls.checkServerIdentity(host, cert); } } return new Promise((resolve, reject) => { let connected = false; let settled = false; const finish = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); fn(); }; const client = isHttps ? https : http; const request = client.request(options, (res) => { const chunks: Buffer[] = []; let size = 0; res.on('data', (chunk: Buffer) => { size += chunk.length; if (size > MAX_BODY_BYTES) { request.destroy(); finish(() => reject(new AccountingHttpError('پاسخ سرور حسابداری بیش از حد بزرگ است', true, 'TOO_LARGE'))); return; } chunks.push(chunk); }); res.on('end', () => finish(() => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })), ); res.on('error', (err) => finish(() => reject(new AccountingHttpError(err.message, true, 'RESPONSE')))); }); request.on('socket', (socket) => { // A kept-alive socket is already connected; listening on it again would // pile up a listener per request. if (!(socket as { connecting?: boolean }).connecting) { connected = true; return; } socket.once(isHttps ? 'secureConnect' : 'connect', () => { connected = true; }); }); const timer = setTimeout(() => { request.destroy(); finish(() => reject( new AccountingHttpError( connected ? 'پاسخ سرور حسابداری در زمان مقرر نرسید' : 'اتصال به سرور حسابداری برقرار نشد (timeout)', connected, 'TIMEOUT', ), ), ); }, req.timeoutMs); request.on('error', (err: NodeJS.ErrnoException) => { if (!fresh && request.reusedSocket && err.code === 'ECONNRESET') { finish(() => resolve(send(req, true))); return; } // Node's own text for these suggests flags that do not apply here. const text = isHttps && err.code && TLS_CERT_ERRORS.test(err.code) ? tlsErrorMessage(err, pins.size > 0) : err.message; finish(() => reject(new AccountingHttpError(text, connected, err.code))); }); if (req.body !== undefined) request.write(req.body); request.end(); }); } export interface ServerCertificate { pem: string; subject: string; issuer: string; subjectAltName: string | null; validFrom: string; validTo: string; expired: boolean; fingerprint256: string; selfSigned: boolean; /** Valid chain to a system CA and issued for this host: no pin needed. */ trustedBySystem: boolean; } /** * Reads the certificate an https server presents, WITHOUT trusting it, so the * admin can compare its fingerprint with the one on the accounting server and * pin it. Nothing but the TLS handshake is sent. */ export function fetchServerCertificate(baseUrl: string, timeoutMs: number): Promise { let url: URL; try { url = new URL(baseUrl); } catch { return Promise.reject(new AccountingHttpError('آدرس سرور حسابداری نامعتبر است', false, 'BAD_URL')); } if (url.protocol !== 'https:') { return Promise.reject(new AccountingHttpError('این آدرس https نیست؛ گواهی ندارد', false, 'NOT_HTTPS')); } const host = url.hostname.replace(/^\[|\]$/g, ''); return new Promise((resolve, reject) => { const socket = tls.connect({ host, port: Number(url.port || 443), servername: net.isIP(host) ? undefined : host, rejectUnauthorized: false, }); const timer = setTimeout(() => { socket.destroy(); reject(new AccountingHttpError('اتصال به سرور حسابداری برقرار نشد (timeout)', false, 'TIMEOUT')); }, timeoutMs); socket.once('secureConnect', () => { clearTimeout(timer); const peer = socket.getPeerCertificate(); const chainOk = socket.authorized; socket.end(); if (!peer?.raw) { reject(new AccountingHttpError('سرور گواهی ارسال نکرد', false, 'NO_CERT')); return; } const cert = new X509Certificate(peer.raw); const nameOk = net.isIP(host) ? !!cert.checkIP(host) : !!cert.checkHost(host); const b64 = peer.raw.toString('base64').match(/.{1,64}/g)!.join('\n'); resolve({ pem: `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----`, subject: cert.subject, issuer: cert.issuer, subjectAltName: cert.subjectAltName ?? null, validFrom: cert.validFrom, validTo: cert.validTo, expired: new Date(cert.validTo).getTime() < Date.now(), fingerprint256: cert.fingerprint256, // checkIssued() also wants keyCertSign, which Tahesab's certificate lacks. selfSigned: cert.subject === cert.issuer && cert.verify(cert.publicKey), trustedBySystem: chainOk && nameOk, }); }); socket.once('error', (err: NodeJS.ErrnoException) => { clearTimeout(timer); reject(new AccountingHttpError(err.message, false, err.code)); }); }); } /** * Parses a JSON body that may carry junk around it. Tahesab replies have been * seen with a BOM, raw control characters and unescaped backslashes inside * strings (henza_server TahesabController::test had to scrub them the same * way before json_decode would accept the body). */ export function parseLooseJson(body: string): unknown { try { return JSON.parse(body); } catch { // fall through to the scrubbed attempt } let raw = body.replace(/^/, ''); raw = raw.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); raw = raw.replace(/\\(?!["\\/bfnrtu])/g, '-'); const start = raw.search(/[{[]/); if (start > 0) raw = raw.slice(start); return JSON.parse(raw); }