diff --git a/kustomize/overlays/prod/frontend-graphql-proxy-patch.yaml b/kustomize/overlays/prod/frontend-graphql-proxy-patch.yaml new file mode 100644 index 0000000..fe28e5e --- /dev/null +++ b/kustomize/overlays/prod/frontend-graphql-proxy-patch.yaml @@ -0,0 +1,18 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: trade-frontend +spec: + template: + spec: + containers: + - name: frontend + volumeMounts: + - name: frontend-server-script + mountPath: /app/services/frontend/server.mjs + subPath: frontend-server.mjs + readOnly: true + volumes: + - name: frontend-server-script + configMap: + name: trade-frontend-server-script diff --git a/kustomize/overlays/prod/frontend-server.mjs b/kustomize/overlays/prod/frontend-server.mjs new file mode 100644 index 0000000..696b338 --- /dev/null +++ b/kustomize/overlays/prod/frontend-server.mjs @@ -0,0 +1,813 @@ +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}