import crypto from 'node:crypto'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import https from 'node:https'; import net from 'node:net'; import path from 'node:path'; import tls from 'node:tls'; const PORT = Number.parseInt(process.env.PORT || '8081', 10); if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`); const APP_VERSION = String(process.env.APP_VERSION || 'v1').trim() || 'v1'; const BUILD_TIMESTAMP = String(process.env.BUILD_TIMESTAMP || '').trim() || undefined; const STARTED_AT = new Date().toISOString(); const STATIC_DIR = process.env.STATIC_DIR || '/srv'; const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json'; const API_READ_TOKEN_FILE = process.env.API_READ_TOKEN_FILE || '/tokens/read.json'; const API_UPSTREAM = process.env.API_UPSTREAM || process.env.API_URL || 'http://api:8787'; const HASURA_UPSTREAM = process.env.HASURA_UPSTREAM || 'http://hasura:8080'; const HASURA_GRAPHQL_PATH = process.env.HASURA_GRAPHQL_PATH || '/v1/graphql'; const GRAPHQL_CORS_ORIGIN = process.env.GRAPHQL_CORS_ORIGIN || process.env.CORS_ORIGIN || '*'; const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on') .trim() .toLowerCase(); const BASIC_AUTH_ENABLED = !['off', 'false', '0', 'disabled', 'none'].includes(BASIC_AUTH_MODE); const AUTH_USER_HEADER = String(process.env.AUTH_USER_HEADER || 'x-trade-user') .trim() .toLowerCase(); const AUTH_MODE = String(process.env.AUTH_MODE || 'session') .trim() .toLowerCase(); const HTPASSWD_FILE = String(process.env.HTPASSWD_FILE || '/auth/users').trim(); const AUTH_SESSION_SECRET_FILE = String(process.env.AUTH_SESSION_SECRET_FILE || '').trim() || null; const AUTH_SESSION_COOKIE = String(process.env.AUTH_SESSION_COOKIE || 'trade_session') .trim() .toLowerCase(); const AUTH_SESSION_TTL_SECONDS = Number.parseInt(process.env.AUTH_SESSION_TTL_SECONDS || '43200', 10); // 12h const DLOB_SOURCE_COOKIE = String(process.env.DLOB_SOURCE_COOKIE || 'trade_dlob_source').trim() || 'trade_dlob_source'; const DLOB_SOURCE_DEFAULT = String(process.env.DLOB_SOURCE_DEFAULT || 'mevnode').trim() || 'mevnode'; function readJson(filePath) { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } function readText(filePath) { return fs.readFileSync(filePath, 'utf8'); } function timingSafeEqualStr(a, b) { const aa = Buffer.from(String(a), 'utf8'); const bb = Buffer.from(String(b), 'utf8'); if (aa.length !== bb.length) return false; return crypto.timingSafeEqual(aa, bb); } function timingSafeEqualBuf(a, b) { if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) return false; if (a.length !== b.length) return false; return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } function loadBasicAuth() { const j = readJson(BASIC_AUTH_FILE); const username = (j?.username || '').toString(); const password = (j?.password || '').toString(); if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`); return { username, password }; } function loadApiReadToken() { const j = readJson(API_READ_TOKEN_FILE); const token = (j?.token || '').toString(); if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`); return token; } function send(res, status, headers, body) { res.statusCode = status; for (const [k, v] of Object.entries(headers || {})) res.setHeader(k, v); if (body == null) return void res.end(); res.end(body); } function sendJson(res, status, body) { send(res, status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, JSON.stringify(body)); } function basicAuthRequired(res) { res.setHeader('www-authenticate', 'Basic realm="trade"'); send(res, 401, { 'content-type': 'text/plain; charset=utf-8' }, 'unauthorized'); } function unauthorized(res) { sendJson(res, 401, { ok: false, error: 'unauthorized' }); } function isAuthorized(req, creds) { const auth = req.headers.authorization || ''; const m = String(auth).match(/^Basic\s+(.+)$/i); if (!m?.[1]) return false; let decoded; try { decoded = Buffer.from(m[1], 'base64').toString('utf8'); } catch { return false; } const idx = decoded.indexOf(':'); if (idx < 0) return false; const u = decoded.slice(0, idx); const p = decoded.slice(idx + 1); return timingSafeEqualStr(u, creds.username) && timingSafeEqualStr(p, creds.password); } const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json; charset=utf-8', }; function contentTypeFor(filePath) { return MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; } function safePathFromUrlPath(urlPath) { const decoded = decodeURIComponent(urlPath); const cleaned = decoded.replace(/\0/g, ''); // strip leading slash so join() doesn't ignore STATIC_DIR const rel = cleaned.replace(/^\/+/, ''); const normalized = path.normalize(rel); // prevent traversal if (normalized.startsWith('..') || path.isAbsolute(normalized)) return null; return normalized; } function injectIndexHtml(html, { dlobSource, redirectPath }) { const src = normalizeDlobSource(dlobSource) || 'mevnode'; const redirect = safeRedirectPath(redirectPath); const hrefBase = `/prefs/dlob-source?redirect=${encodeURIComponent(redirect)}&set=`; const styleActive = 'font-weight:700;text-decoration:underline;'; const styleInactive = 'font-weight:400;text-decoration:none;'; const snippet = `
`; const bodyClose = /<\/body>/i; if (bodyClose.test(html)) return html.replace(bodyClose, `${snippet}