Files
tehran_taj/apps/api/src/modules/accounting-sync/accounting-http.ts
T

282 lines
11 KiB
TypeScript
Raw Normal View History

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<string, string>;
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<string> {
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<HttpResponse> {
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<string, string | number> = { ...(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,
};
const pins = isHttps ? pinnedFingerprints(req.ca) : new Set<string>();
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<HttpResponse>((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) => {
// 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<ServerCertificate> {
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<ServerCertificate>((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);
}