- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
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);
|
||
}
|