Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
ADMIN_IDS=YOUR_TELEGRAM_USER_ID
# AUTOFIX_SECRET: HMAC-SHA256 signing secret shared with the Autofix AI sender.
# Generate: openssl rand -hex 32
AUTOFIX_SECRET=
# ENDPOINT_PORT: port for the runewager-endpoint companion service (backend.js).
ENDPOINT_PORT=3001
AFFILIATE_SOURCE=GambleCodez
ANNOUNCE_CHANNEL="-1002648883359"
BOTPRIVACYMODE="disabled"
Expand Down
250 changes: 250 additions & 0 deletions backend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
'use strict';
// =============================================================================
// Runewager Endpoint — companion HTTP service on port 3001
//
// Responsibilities:
// - Autofix AI webhook receiver (POST /autofix/webhook)
// - GET /autofix/test — signature self-test
// - GET /health — basic liveness
// - GET /health/full — detailed diagnostic snapshot
// - Telegram admin broadcast helper (sendAdmin)
// - Structured request/event logging to logs/backend.log
// - Graceful SIGTERM/SIGINT shutdown
//
// This service does NOT run Telegram polling (index.js owns that).
// =============================================================================

require('dotenv').config();

const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const https = require('https');
const os = require('os');
const path = require('path');
const { globalThrottle, stats: rlStats } = require('./rateLimiter');

// ─── Config ──────────────────────────────────────────────────────────────────

const PORT = parseInt(process.env.ENDPOINT_PORT ?? '3001', 10);
const AUTOFIX_SECRET = process.env.AUTOFIX_SECRET ?? '';
const TG_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? process.env.BOT_TOKEN ?? '';
const ADMIN_CHAT_IDS = (process.env.ADMIN_IDS ?? '')
.split(',').map(s => s.trim()).filter(Boolean);

// ─── Structured Logging ──────────────────────────────────────────────────────

const LOG_DIR = path.join(__dirname, 'logs');
const LOG_FILE = path.join(LOG_DIR, 'backend.log');
const ERR_FILE = path.join(LOG_DIR, 'backend-error.log');

try {
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
} catch (e) {
console.error(`[backend] Cannot create log directory ${LOG_DIR}: ${e.message}`);
}

const _logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });
const _errStream = fs.createWriteStream(ERR_FILE, { flags: 'a' });

const logger = {
_entry(level, eventType, msg, extra) {
return { ts: new Date().toISOString(), level, eventType, msg, ...extra };
},
_write(streams, obj) {
const line = JSON.stringify(obj) + '\n';
for (const s of streams) s.write(line);
},
info(eventType, msg, extra = {}) {
this._write([_logStream], this._entry('info', eventType, msg, extra));
},
error(eventType, msg, extra = {}) {
this._write([_logStream, _errStream], this._entry('error', eventType, msg, extra));
},
event(eventType, msg, extra = {}) {
this._write([_logStream], this._entry('event', eventType, msg, extra));
},
};

// ─── Telegram Admin Broadcast ─────────────────────────────────────────────────

/**
* Send a plain-text message to every chat ID listed in ADMIN_CHAT_IDS.
* Failures per-chat are swallowed so one bad ID never blocks the others.
* @param {string} msg
* @returns {Promise<void>}
*/
function sendAdmin(msg) {
if (!TG_TOKEN || ADMIN_CHAT_IDS.length === 0) return Promise.resolve();

const sendOne = (chatId) =>
globalThrottle(() => new Promise((resolve) => {
const body = JSON.stringify({ chat_id: chatId, text: String(msg) });
const req = https.request({
hostname: 'api.telegram.org',
path: `/bot${TG_TOKEN}/sendMessage`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}, () => resolve());
req.setTimeout(10_000, () => { req.destroy(); resolve(); });
req.on('error', () => resolve());
req.write(body);
req.end();
}));

return Promise.all(ADMIN_CHAT_IDS.map(sendOne)).then(() => {});
}

// ─── HMAC Signature Verification ─────────────────────────────────────────────

/**
* Verify an HMAC-SHA256 signature against the raw request body.
* Accepts both bare hex and "sha256=<hex>" prefixed forms.
* Uses timing-safe comparison to prevent timing attacks.
* @param {Buffer} rawBody
* @param {string} signature
* @returns {boolean}
*/
function verifySignature(rawBody, signature) {
if (!AUTOFIX_SECRET) return false;
const expected = crypto
.createHmac('sha256', AUTOFIX_SECRET)
.update(rawBody)
.digest('hex');
const sig = (signature ?? '').replace(/^sha256=/, '');
if (sig.length !== expected.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
} catch {
return false;
}
}

// ─── Express App ──────────────────────────────────────────────────────────────

const app = express();

// requestId middleware — every request gets a short unique ID for log correlation
app.use((req, _res, next) => {
req.id = crypto.randomBytes(5).toString('hex');
next();
});

// Capture raw body for HMAC verification on /autofix routes before JSON parsing
app.use('/autofix', express.raw({ type: '*/*', limit: '1mb' }));
app.use(express.json({ limit: '1mb' }));

// ─── GET /health ──────────────────────────────────────────────────────────────

app.get('/health', (req, res) => {
logger.info('http.health', 'Health check', { requestId: req.id });
res.json({
ok: true,
service: 'runewager-endpoint',
uptimeSec: Math.floor(process.uptime()),
});
});

// ─── GET /health/full ─────────────────────────────────────────────────────────

app.get('/health/full', (req, res) => {
const mem = process.memoryUsage();
const [load1] = os.loadavg();
logger.info('http.health.full', 'Full health check', { requestId: req.id });
res.json({
ok: true,
service: 'runewager-endpoint',
uptimeSec: Math.floor(process.uptime()),
nodeVersion: process.version,
memoryMB: {
rss: +(mem.rss / 1_048_576).toFixed(1),
heapUsed: +(mem.heapUsed / 1_048_576).toFixed(1),
},
cpuLoad: +load1.toFixed(2),
activeTelegramPolling: false, // polling lives in index.js
autofixWebhookRegistered: Boolean(AUTOFIX_SECRET),
adminChatIdsConfigured: ADMIN_CHAT_IDS.length,
rateLimiter: rlStats(),
});
});

// ─── GET /autofix/test ────────────────────────────────────────────────────────

app.get('/autofix/test', (req, res) => {
const timestamp = new Date().toISOString();
const payload = JSON.stringify({ event: 'test', ts: timestamp, requestId: req.id });
const sig = AUTOFIX_SECRET
? 'sha256=' + crypto.createHmac('sha256', AUTOFIX_SECRET).update(payload).digest('hex')
: '';
const signatureValid = AUTOFIX_SECRET ? verifySignature(Buffer.from(payload), sig) : false;

logger.event('autofix.test', 'Signature self-test', { requestId: req.id, signatureValid });
res.json({ ok: true, signatureValid, timestamp, requestId: req.id, secretConfigured: Boolean(AUTOFIX_SECRET) });
});

// ─── POST /autofix/webhook ────────────────────────────────────────────────────

app.post('/autofix/webhook', async (req, res) => {
const requestId = req.id;
try {
const sig = req.headers['x-autofix-signature'] ?? req.headers['x-hub-signature-256'] ?? '';
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(JSON.stringify(req.body ?? {}));

if (!AUTOFIX_SECRET || !verifySignature(rawBody, sig)) {
const reason = !AUTOFIX_SECRET ? 'AUTOFIX_SECRET not configured' : 'Signature mismatch';
logger.error('autofix.webhook', `${reason} — request rejected`, { requestId });
return res.status(401).json({ ok: false, error: 'Invalid signature', requestId });
}

let payload;
try { payload = JSON.parse(rawBody.toString('utf8')); } catch { payload = {}; }

const eventName = payload.event ?? payload.action ?? 'unknown';
logger.event('autofix.webhook', 'Autofix event received', { requestId, event: eventName });

await sendAdmin(`🤖 Autofix [${requestId}]: ${eventName}`);

res.json({ ok: true, requestId, received: true });
} catch (err) {
const summary = err?.message ?? String(err);
logger.error('autofix.webhook', 'Handler error', { requestId, error: summary });
await sendAdmin(`⚠️ Autofix webhook error [${requestId}]: ${summary}`).catch(() => {});
res.status(500).json({ ok: false, error: 'Internal server error', requestId });
}
});

// ─── Start ────────────────────────────────────────────────────────────────────

const server = app.listen(PORT, '127.0.0.1', () => {
logger.info('server.start', 'runewager-endpoint listening', { port: PORT });
});

// ─── Graceful Shutdown ────────────────────────────────────────────────────────

function shutdown(signal) {
logger.info('server.shutdown', `${signal} received — shutting down gracefully`);
server.close(() => {
logger.info('server.shutdown', 'HTTP server closed — exiting cleanly');
process.exit(0);
});
// Force-exit after 10 s if connections don't drain
setTimeout(() => {
logger.error('server.shutdown', 'Graceful shutdown timeout — forcing exit');
process.exit(1);
}, 10_000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

process.on('uncaughtException', err => {
logger.error('process.uncaughtException', err.message, { stack: err.stack });
sendAdmin(`💀 runewager-endpoint crash: ${err.message}`).finally(() => process.exit(1));
});

process.on('unhandledRejection', reason => {
logger.error('process.unhandledRejection', String(reason));
});
7 changes: 7 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { exec, execFile, spawn } = require('child_process');
const telegramSafe = require('./telegramSafe');

// =========================
// Config
Expand Down Expand Up @@ -225,6 +226,12 @@ const COPY = {

const bot = new Telegraf(BOT_TOKEN);

// Patch bot.telegram methods to route through the global rate limiter.
// Because ctx.telegram IS bot.telegram (same object reference in Telegraf v4),
// this single call covers ctx.reply(), ctx.answerCbQuery(), ctx.editMessageText(),
// ctx.telegram.sendMessage(), and every direct bot.telegram.*() call in this file.
// Must be called BEFORE any bot.use() middleware so the patch is always in effect.
telegramSafe.init(bot);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Callback-query hygiene middleware: if a callback handler sends a new reply,
// remove the source callback card first so menus do not stack.
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
},
"scripts": {
"start": "node index.js",
"endpoint": "node backend.js",
"endpoint:check": "node --check backend.js",
"prod": "./prod-run.sh",
"health": "./health-check.sh",
"health:verbose": "./health-check.sh --verbose",
Expand All @@ -25,6 +27,7 @@
},
"dependencies": {
"dotenv": "^16.6.1",
"express": "^4.21.2",
"telegraf": "^4.16.3"
}
}
Loading
Loading