2026-10-06 16:48:36 +03:30
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 > {
2026-10-12 15:23:24 +03:30
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 < HttpResponse > {
2026-10-06 16:48:36 +03:30
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 ,
2026-10-12 15:23:24 +03:30
...( fresh ? { agent : false } : {}),
2026-10-06 16:48:36 +03:30
};
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 ) => {
2026-10-12 15:23:24 +03:30
if ( ! fresh && request . reusedSocket && err . code === 'ECONNRESET' ) {
finish (() => resolve ( send ( req , true )));
return ;
}
2026-10-06 16:48:36 +03:30
// 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 );
}