// bridge.js — HTTP→TCP bridge for ABS (ABS_POLL + LOG/LOGBT login) using 512-byte MFC-style frames // Node 18+ (CommonJS) const express = require("express"); const net = require("net"); // ---- ABS endpoint (no server changes) ---- const ABS_HOST = process.env.ABS_HOST || "192.0.0.14"; const ABS_PORT = Number(process.env.ABS_PORT || 7000); // ---- transaction/opcode constants (from Abs.h / your grep) ---- const ABS_POLL = 178; // connectivity ping const LOG = 100; // login transaction code const LOGBT = 6013; // login opcode const SUCCESS = 0; // ---- 512-byte framing ---- const PKT_SIZE = 512; const PAYLOAD_PER_PKT = PKT_SIZE - 1; const app = express(); // we intentionally avoid global express.json() — we accept empty bodies safely function readJsonBody(req) { return new Promise((resolve) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { if (!chunks.length) return resolve({}); const txt = Buffer.concat(chunks).toString("utf8").trim(); if (!txt) return resolve({}); try { resolve(JSON.parse(txt)); } catch { resolve({}); } }); }); } // ---------- helpers: fixed-width strings, trimming ---------- function writeFixedAscii(buf, offset, s, len) { const str = (s ?? "").toString(); for (let i = 0; i < len; i++) { buf[offset + i] = i < str.length ? (str.charCodeAt(i) & 0xff) : 0x00; } return offset + len; } function readFixedAscii(buf, offset, len) { let end = offset; const max = offset + len; while (end < max && buf[end] !== 0) end++; const raw = buf.subarray(offset, end).toString("ascii"); return { value: raw.trimEnd(), next: offset + len }; } // ---------- struct packers / parsers (little-endian) ---------- // sSndHeader (24 bytes) function packSndHeader({ nTxnCd, nOpCd = 0, nNumRecsSent = 0, nNumRecsRqrd = 0, nTxnId = 0, cBtMake = 0 }) { const b = Buffer.alloc(24, 0); let o = 0; b.writeInt32LE(nTxnCd, o); o += 4; b.writeInt32LE(nOpCd, o); o += 4; b.writeInt32LE(nNumRecsSent, o); o += 4; b.writeInt32LE(nNumRecsRqrd, o); o += 4; b.writeInt32LE(nTxnId, o); o += 4; b.writeUInt8(cBtMake & 0xff, o); return b; } // sRcvHeader (18 or 24 bytes on the wire) function parseRcvHeaderFlexible(buf) { if (buf.length < 18) throw new Error(`Reply too short for RcvHeader: ${buf.length} bytes`); let o = 0; const nTxnCd = buf.readInt32LE(o); o += 4; const nRetCd = buf.readInt32LE(o); o += 4; const nNumRecs = buf.readInt32LE(o); o += 4; const nTxnId = buf.readInt32LE(o); o += 4; const cBtMake = buf.readUInt8(o); return { nTxnCd, nRetCd, nNumRecs, nTxnId, cBtMake, size: buf.length >= 24 ? 24 : 18 }; } // normalized 18-byte header (for display) function buildRcvHeader18({ nTxnCd, nRetCd, nNumRecs = 0, nTxnId = 0, cBtMake = 0 }) { const b = Buffer.alloc(18, 0); let o = 0; b.writeInt32LE(nTxnCd, o); o += 4; b.writeInt32LE(nRetCd, o); o += 4; b.writeInt32LE(nNumRecs, o); o += 4; b.writeInt32LE(nTxnId, o); o += 4; b.writeUInt8(cBtMake & 0xff, o); return b; } // ---------- ABS 512-byte packetization ---------- function absSend(sock, payload) { return new Promise((resolve, reject) => { let off = 0; function sendNext() { const remaining = payload.length - off; const toCopy = Math.min(PAYLOAD_PER_PKT, Math.max(remaining, 0)); const pkt = Buffer.alloc(PKT_SIZE, 0); pkt[0] = (off + toCopy >= payload.length) ? 48 /* '0' */ : 49 /* '1' */; if (toCopy > 0) payload.copy(pkt, 1, off, off + toCopy); off += toCopy; sock.write(pkt, (err) => { if (err) return reject(err); if (pkt[0] === 48) return resolve(); // last sendNext(); }); } sendNext(); }); } function absRecv(sock, timeoutMs = 7000) { return new Promise((resolve, reject) => { const chunks = []; let buf = Buffer.alloc(0); const timer = timeoutMs ? setTimeout(() => done(new Error("TCP timeout")), timeoutMs) : null; function done(err) { if (timer) clearTimeout(timer); sock.off("data", onData); if (err) reject(err); else resolve(Buffer.concat(chunks)); } function onData(data) { buf = Buffer.concat([buf, data]); while (buf.length >= PKT_SIZE) { const pkt = buf.subarray(0, PKT_SIZE); buf = buf.subarray(PKT_SIZE); const flag = pkt[0]; chunks.push(pkt.subarray(1)); if (flag === 48) return done(); // '0' } } sock.on("data", onData); sock.on("error", (e) => done(e)); sock.on("end", () => done(new Error("Socket ended before final '0' packet"))); }); } async function absRoundtrip(payload, timeoutMs = 7000) { const sock = new net.Socket(); await new Promise((res, rej) => sock.connect(ABS_PORT, ABS_HOST, res).once("error", rej)); try { await absSend(sock, payload); const replyConcat = await absRecv(sock, timeoutMs); sock.end(); return replyConcat; } catch (e) { try { sock.destroy(); } catch {} throw e; } } // ---------- MFC-style password "encryption" ---------- /* Mirrors the MFC EncryptPassword(): - key = first digit of current UTC seconds - for each input char: interpret as digit (non-digit -> 0), add key, keep ones digit - append key at the end Result length = input.length + 1 (cPasswd in sSndLog is 11 bytes, so 10-digit inputs fit). */ function encryptPasswordLikeMFC(plain) { const sec = new Date().getUTCSeconds(); // GetSystemTime (UTC) seconds const key = Number(String(sec)[0] || "0"); // first digit let out = ""; const s = (plain ?? "").toString(); for (let i = 0; i < s.length; i++) { const ch = s[i]; const d = (ch >= "0" && ch <= "9") ? (ch.charCodeAt(0) - 48) : 0; // atoi on non-digit -> 0 out += String((d + key) % 10); } return { enc: out + String(key), key, utcSeconds: sec }; } // ---------- SND/RCV: LOGIN ---------- // sSndLog (38 bytes): cUsrId[5], cOpCardCd[17], cPasswd[11], cBtId[5] function packSndLog({ usrId = "", opCard, passwd, btId }) { const b = Buffer.alloc(38, 0); let o = 0; o = writeFixedAscii(b, o, usrId, 5); o = writeFixedAscii(b, o, opCard, 17); o = writeFixedAscii(b, o, passwd, 11); o = writeFixedAscii(b, o, btId, 5); return b; } // sRcvLog parser (follows sRcvHeader if nRetCd == 0) function parseRcvLog(buf, offset = 0) { let o = offset; const t1 = readFixedAscii(buf, o, 20); const cDateTime = t1.value; o = t1.next; const t2 = readFixedAscii(buf, o, 31); const cUsrNm = t2.value; o = t2.next; const t3 = readFixedAscii(buf, o, 5 ); const cUsrId = t3.value; o = t3.next; const t4 = readFixedAscii(buf, o, 31); const cSupNm = t4.value; o = t4.next; const t5 = readFixedAscii(buf, o, 5 ); const cSupId = t5.value; o = t5.next; const t6 = readFixedAscii(buf, o, 5 ); const cUsrTyp = t6.value; o = t6.next; function rf() { const v = buf.readFloatLE(o); o += 4; return v; } function ri() { const v = buf.readInt32LE(o); o += 4; return v; } const fOpenBal = rf(); const fTktSalesByVoucher = rf(); const fTktSalesByCash = rf(); const fTktSalesByMemCard = rf(); const nTktSalesByVoucherCount = ri(); const nTktSalesByCashCount = ri(); const nTktSalesByMemCardCount = ri(); const fPayoutByVoucher = rf(); const fPayoutByCash = rf(); const fPayoutByMemCard = rf(); const nPayoutByVoucherCount = ri(); const nPayoutByCashCount = ri(); const nPayoutByMemCardCount = ri(); const fCancelByVoucher = rf(); const fCancelByCash = rf(); const fCancelByMemCard = rf(); const nCancelByVoucherCount = ri(); const nCancelByCashCount = ri(); const nCancelByMemCardCount = ri(); const fDeposit = rf(); const fWithdrawAmt = rf(); const fVoucherSales = rf(); const fVoucherEncash = rf(); const fCloseBal = rf(); const fSaleTarget = rf(); return { cDateTime, cUsrNm, cUsrId, cSupNm, cSupId, cUsrTyp, fOpenBal, fTktSalesByVoucher, fTktSalesByCash, fTktSalesByMemCard, nTktSalesByVoucherCount, nTktSalesByCashCount, nTktSalesByMemCardCount, fPayoutByVoucher, fPayoutByCash, fPayoutByMemCard, nPayoutByVoucherCount, nPayoutByCashCount, nPayoutByMemCardCount, fCancelByVoucher, fCancelByCash, fCancelByMemCard, nCancelByVoucherCount, nCancelByCashCount, nCancelByMemCardCount, fDeposit, fWithdrawAmt, fVoucherSales, fVoucherEncash, fCloseBal, fSaleTarget, size: o - offset }; } // ---------- routes ---------- app.get("/", (_req, res) => res.send("ABS bridge is up")); app.get("/health", (_req, res) => { const s = new net.Socket(); s.setTimeout(1500); s.connect(ABS_PORT, ABS_HOST, () => { s.destroy(); res.json({ ok: true, target: `${ABS_HOST}:${ABS_PORT}` }); }); s.on("timeout", () => { s.destroy(); res.status(504).json({ ok: false, error: "TCP timeout" }); }); s.on("error", (e) => { s.destroy(); res.status(502).json({ ok: false, error: String(e) }); }); }); // ---------- ABS_POLL ---------- app.post("/abs/poll", async (req, res) => { try { const body = await readJsonBody(req); const btMake = (typeof body.btMake === "string") ? body.btMake.charCodeAt(0) : (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00); const header = packSndHeader({ nTxnCd: ABS_POLL, nOpCd: 0, nNumRecsSent: 0, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: btMake }); const reply = await absRoundtrip(header, 7000); const headerSlice = reply.subarray(0, Math.min(reply.length, 24)); const parsed = parseRcvHeaderFlexible(headerSlice); const success = parsed.nRetCd === SUCCESS; const normalized = { nTxnCd: parsed.nTxnCd || ABS_POLL, nRetCd: parsed.nRetCd, nNumRecs: parsed.nNumRecs, nTxnId: parsed.nTxnId, cBtMake: parsed.cBtMake, size: 18 }; res.json({ ok: true, target: `${ABS_HOST}:${ABS_PORT}`, sentHeaderHex: header.toString("hex"), replyBytes: reply.length, replyHexFirst64: reply.subarray(0, 64).toString("hex"), parsedRcvHeaderRaw: parsed, normalizedRcvHeader: normalized, normalizedHeaderHex: buildRcvHeader18(normalized).toString("hex"), success }); } catch (e) { res.status(502).json({ ok: false, error: String(e) }); } }); // ---------- LOGIN ---------- app.post("/login", async (req, res) => { try { const body = await readJsonBody(req); const opCard = (body.opCard ?? "").toString(); const btId = (body.btId ?? "").toString(); const usrId = (body.usrId ?? "").toString(); const plain = (body.password ?? ""); const givenEnc = (body.passwordEnc ?? ""); if (!opCard || !btId || (!plain && !givenEnc)) { return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" }); } // Encrypt if only plain is supplied (MFC-compatible) let encInfo = null; let passwd = givenEnc ? String(givenEnc) : (encInfo = encryptPasswordLikeMFC(String(plain))).enc; const btMake = (typeof body.btMake === "string") ? body.btMake.charCodeAt(0) : (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00); const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: LOGBT, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: btMake }); const sndLog = packSndLog({ usrId, opCard, passwd, btId }); const sendBuf = Buffer.concat([sndHeader, sndLog]); const reply = await absRoundtrip(sendBuf, 10000); const hdrSlice = reply.subarray(0, Math.min(reply.length, 24)); const rcvHeader = parseRcvHeaderFlexible(hdrSlice); const json = { ok: true, target: `${ABS_HOST}:${ABS_PORT}`, sentHeaderHex: sndHeader.toString("hex"), sentBodyHex: sndLog.toString("hex"), replyBytes: reply.length, replyHexFirst64: reply.subarray(0, 64).toString("hex"), rcvHeaderRaw: rcvHeader, success: rcvHeader.nRetCd === SUCCESS }; if (!givenEnc && encInfo) { json.encryption = { used: true, utcSeconds: encInfo.utcSeconds, key: encInfo.key, encPasswd: passwd }; } else if (givenEnc) { json.encryption = { used: false, providedPasswordEnc: true }; } // --- adjust body offset to account for optional 4‑byte nOpCd (forward) --- let bodyOffset = rcvHeader.size; // 18 or 24 if (reply.length >= bodyOffset + 4) { const maybeOp = reply.readInt32LE(bodyOffset); if (maybeOp === LOGBT || maybeOp === LOG || maybeOp === 0 || (maybeOp >= 1000 && maybeOp <= 10000)) { bodyOffset += 4; // explicit opcode present json.rcvExtraOpCd = maybeOp; } else { // if current doesn't look like '20' but +4 does, move forward const looksLikeYear = reply[bodyOffset] === 0x32 && reply[bodyOffset + 1] === 0x30; // '2','0' const looksLikeYearFwd = reply[bodyOffset + 4] === 0x32 && reply[bodyOffset + 5] === 0x30; if (!looksLikeYear && looksLikeYearFwd) bodyOffset += 4; } } // --- back guard: if we landed on '/', back up 4 to include '2025' --- if (bodyOffset >= 4 && reply[bodyOffset] === 0x2f /* '/' */) { const couldBeYear = reply[bodyOffset - 4] === 0x32 /* '2' */ && reply[bodyOffset - 3] === 0x30 /* '0' */; if (couldBeYear) bodyOffset -= 4; } // optional: quick probe to verify alignment during testing json.offsetProbe = { bodyOffset, around: reply.subarray(Math.max(0, bodyOffset - 8), bodyOffset + 16).toString("ascii") }; // parse body if success if (json.success && reply.length >= bodyOffset + 20) { try { const log = parseRcvLog(reply, bodyOffset); json.log = log; } catch (e) { json.parseLogError = String(e); } } res.json(json); } catch (e) { res.status(502).json({ ok: false, error: String(e) }); } }); // ---- start HTTP server ---- const HTTP_PORT = Number(process.env.HTTP_BRIDGE_PORT || 8080); app.listen(HTTP_PORT, () => { console.log(`ABS HTTP bridge listening on :${HTTP_PORT}`); console.log(`Target ABS server: ${ABS_HOST}:${ABS_PORT}`); });