diff --git a/new_bridge.js b/new_bridge.js new file mode 100644 index 0000000..3978684 --- /dev/null +++ b/new_bridge.js @@ -0,0 +1,391 @@ +// bridge.js — HTTP→TCP bridge for ABS (ABS_POLL + LOG/LOGBT login) using 512-byte MFC-style frames +// CommonJS (Node 18+) + +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 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 (0..5, or 0..9 if seconds >= 100 ever, but it's 0..5) + - 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 field 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 ---------- +// POST /login +// Body (JSON): +// { +// "opCard": "OPCARD17", // REQUIRED (<=17) +// "password": "1234567890", // plain 0–9; will be encrypted for you +// // OR: "passwordEnc": "45673" // already-encrypted string (skip client encryption) +// "btId": "0483", // REQUIRED (<=5) +// "usrId": "", // optional (<=5) +// "btMake": 0 // optional: numeric or single-char string +// } +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; + + // C field sizes: cPasswd[11] – warn if plain > 10 (enc becomes > 11) + if (!givenEnc && String(plain).length > 10) { + // still send (field will truncate), but tell caller + } + + 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 + }; + + // include encryption debug (non-sensitive) if we did it here + if (!givenEnc && encInfo) { + json.encryption = { + used: true, + utcSeconds: encInfo.utcSeconds, + key: encInfo.key, + encPasswd: passwd + }; + } else if (givenEnc) { + json.encryption = { used: false, providedPasswordEnc: true }; + } + + const bodyOffset = rcvHeader.size; // 18 or 24 + 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}`); +});