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 = ` + +
+ DLOB + mevnode + | + drift +
+`; + + const bodyClose = /<\/body>/i; + if (bodyClose.test(html)) return html.replace(bodyClose, `${snippet}`); + return `${html}\n${snippet}\n`; +} + +function serveStatic(req, res) { + if (req.method !== 'GET' && req.method !== 'HEAD') { + send(res, 405, { 'content-type': 'text/plain; charset=utf-8' }, 'method_not_allowed'); + return; + } + + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + const rel = safePathFromUrlPath(url.pathname); + if (rel == null) { + send(res, 400, { 'content-type': 'text/plain; charset=utf-8' }, 'bad_request'); + return; + } + + const root = path.resolve(STATIC_DIR); + const fileCandidate = path.resolve(root, rel); + if (!fileCandidate.startsWith(root)) { + send(res, 400, { 'content-type': 'text/plain; charset=utf-8' }, 'bad_request'); + return; + } + + const trySend = (filePath) => { + try { + const st = fs.statSync(filePath); + if (st.isDirectory()) return trySend(path.join(filePath, 'index.html')); + res.statusCode = 200; + res.setHeader('content-type', contentTypeFor(filePath)); + res.setHeader('cache-control', filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000'); + if (req.method === 'HEAD') return void res.end(); + if (filePath.endsWith('index.html')) { + const html = fs.readFileSync(filePath, 'utf8'); + const injected = injectIndexHtml(html, { + dlobSource: resolveDlobSource(req), + redirectPath: url.pathname + url.search, + }); + res.end(injected); + return true; + } + fs.createReadStream(filePath).pipe(res); + return true; + } catch { + return false; + } + }; + + // exact file, otherwise SPA fallback + if (trySend(fileCandidate)) return; + const indexPath = path.join(root, 'index.html'); + if (trySend(indexPath)) return; + + send(res, 404, { 'content-type': 'text/plain; charset=utf-8' }, 'not_found'); +} + +function stripHopByHopHeaders(headers) { + const hop = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + ]); + const out = {}; + for (const [k, v] of Object.entries(headers || {})) { + if (hop.has(k.toLowerCase())) continue; + out[k] = v; + } + return out; +} + +function readHeader(req, name) { + const v = req.headers[String(name).toLowerCase()]; + return Array.isArray(v) ? v[0] : v; +} + +function readCookie(req, name) { + const raw = typeof req.headers.cookie === 'string' ? req.headers.cookie : ''; + if (!raw) return null; + const needle = `${name}=`; + for (const part of raw.split(';')) { + const t = part.trim(); + if (!t.startsWith(needle)) continue; + return t.slice(needle.length) || null; + } + return null; +} + +function normalizeDlobSource(value) { + const v = String(value ?? '') + .trim() + .toLowerCase(); + if (v === 'mevnode') return 'mevnode'; + if (v === 'drift') return 'drift'; + return null; +} + +function resolveDlobSource(req) { + const fromCookie = normalizeDlobSource(readCookie(req, DLOB_SOURCE_COOKIE)); + if (fromCookie) return fromCookie; + return normalizeDlobSource(DLOB_SOURCE_DEFAULT) || 'mevnode'; +} + +function safeRedirectPath(value) { + const s = String(value ?? '').trim(); + if (!s.startsWith('/')) return '/'; + if (s.startsWith('//')) return '/'; + return s.replace(/\r|\n/g, ''); +} + +function setDlobSourceCookie(res, { secure, dlobSource }) { + const src = normalizeDlobSource(dlobSource); + if (!src) return false; + const parts = [ + `${DLOB_SOURCE_COOKIE}=${src}`, + 'Path=/', + 'SameSite=Lax', + 'HttpOnly', + 'Max-Age=31536000', + ]; + if (secure) parts.push('Secure'); + res.setHeader('set-cookie', parts.join('; ')); + return true; +} + +function resolveAuthUser(req) { + const user = readHeader(req, AUTH_USER_HEADER) || readHeader(req, 'x-webauth-user'); + const value = typeof user === 'string' ? user.trim() : ''; + return value || null; +} + +function isHttpsRequest(req) { + const xf = readHeader(req, 'x-forwarded-proto'); + if (typeof xf === 'string' && xf.toLowerCase() === 'https') return true; + return Boolean(req.socket && req.socket.encrypted); +} + +function base64urlEncode(buf) { + return Buffer.from(buf) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function base64urlDecode(str) { + const cleaned = String(str).replace(/-/g, '+').replace(/_/g, '/'); + const pad = cleaned.length % 4 === 0 ? '' : '='.repeat(4 - (cleaned.length % 4)); + return Buffer.from(cleaned + pad, 'base64'); +} + +function loadSessionSecret() { + if (process.env.AUTH_SESSION_SECRET && String(process.env.AUTH_SESSION_SECRET).trim()) { + return Buffer.from(String(process.env.AUTH_SESSION_SECRET).trim(), 'utf8'); + } + if (AUTH_SESSION_SECRET_FILE) { + try { + const txt = readText(AUTH_SESSION_SECRET_FILE).trim(); + if (txt) return Buffer.from(txt, 'utf8'); + } catch { + // ignore + } + } + return crypto.randomBytes(32); +} + +const SESSION_SECRET = loadSessionSecret(); + +function signSessionPayload(payloadB64) { + return crypto.createHmac('sha256', SESSION_SECRET).update(payloadB64).digest(); +} + +function makeSessionCookieValue(username) { + const now = Math.floor(Date.now() / 1000); + const exp = now + (Number.isFinite(AUTH_SESSION_TTL_SECONDS) && AUTH_SESSION_TTL_SECONDS > 0 ? AUTH_SESSION_TTL_SECONDS : 43200); + const payload = JSON.stringify({ u: String(username), exp }); + const payloadB64 = base64urlEncode(Buffer.from(payload, 'utf8')); + const sigB64 = base64urlEncode(signSessionPayload(payloadB64)); + return `${payloadB64}.${sigB64}`; +} + +function getSessionUser(req) { + const raw = readCookie(req, AUTH_SESSION_COOKIE); + if (!raw) return null; + const parts = raw.split('.'); + if (parts.length !== 2) return null; + const [payloadB64, sigB64] = parts; + if (!payloadB64 || !sigB64) return null; + + let payload; + try { + payload = JSON.parse(base64urlDecode(payloadB64).toString('utf8')); + } catch { + return null; + } + const u = typeof payload?.u === 'string' ? payload.u.trim() : ''; + const exp = Number(payload?.exp); + if (!u || !Number.isFinite(exp)) return null; + const now = Math.floor(Date.now() / 1000); + if (now >= exp) return null; + + const expected = signSessionPayload(payloadB64); + let got; + try { + got = base64urlDecode(sigB64); + } catch { + return null; + } + if (!timingSafeEqualBuf(expected, got)) return null; + + return u; +} + +function resolveAuthenticatedUser(req) { + const sessionUser = getSessionUser(req); + if (sessionUser) return sessionUser; + const headerUser = resolveAuthUser(req); + if (headerUser) return headerUser; + if (AUTH_MODE === 'off' || AUTH_MODE === 'none' || AUTH_MODE === 'disabled') return 'anonymous'; + return null; +} + +function clearSessionCookie(res, secure) { + const parts = [`${AUTH_SESSION_COOKIE}=`, 'Path=/', 'Max-Age=0', 'HttpOnly', 'SameSite=Lax']; + if (secure) parts.push('Secure'); + res.setHeader('set-cookie', parts.join('; ')); +} + +function setSessionCookie(res, secure, username) { + const value = makeSessionCookieValue(username); + const parts = [ + `${AUTH_SESSION_COOKIE}=${value}`, + 'Path=/', + `Max-Age=${Number.isFinite(AUTH_SESSION_TTL_SECONDS) ? AUTH_SESSION_TTL_SECONDS : 43200}`, + 'HttpOnly', + 'SameSite=Lax', + ]; + if (secure) parts.push('Secure'); + res.setHeader('set-cookie', parts.join('; ')); +} + +function verifyWithHtpasswd(username, password) { + try { + const r = spawnSync('htpasswd', ['-vb', HTPASSWD_FILE, String(username), String(password)], { + stdio: 'ignore', + timeout: 3000, + }); + return r.status === 0; + } catch { + return false; + } +} + +function readBody(req, limitBytes = 1024 * 16) { + return new Promise((resolve, reject) => { + let total = 0; + const chunks = []; + req.on('data', (chunk) => { + total += chunk.length; + if (total > limitBytes) { + reject(new Error('payload_too_large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +function proxyApi(req, res, apiReadToken) { + const upstreamBase = new URL(API_UPSTREAM); + const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + + const prefix = '/api'; + const strippedPath = inUrl.pathname === prefix ? '/' : inUrl.pathname.startsWith(prefix + '/') ? inUrl.pathname.slice(prefix.length) : null; + if (strippedPath == null) { + send(res, 404, { 'content-type': 'text/plain; charset=utf-8' }, 'not_found'); + return; + } + + const target = new URL(upstreamBase.toString()); + target.pathname = strippedPath || '/'; + target.search = inUrl.search; + + const isHttps = target.protocol === 'https:'; + const lib = isHttps ? https : http; + + const headers = stripHopByHopHeaders(req.headers); + delete headers.authorization; // basic auth from client must not leak upstream + headers.host = target.host; + headers.authorization = `Bearer ${apiReadToken}`; + + const upstreamReq = lib.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port || (isHttps ? 443 : 80), + method: req.method, + path: target.pathname + target.search, + headers, + }, + (upstreamRes) => { + const outHeaders = stripHopByHopHeaders(upstreamRes.headers); + res.writeHead(upstreamRes.statusCode || 502, outHeaders); + upstreamRes.pipe(res); + } + ); + + upstreamReq.on('error', (err) => { + if (!res.headersSent) { + send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`); + } else { + res.destroy(); + } + }); + + req.pipe(upstreamReq); +} + +function withCors(res) { + res.setHeader('access-control-allow-origin', GRAPHQL_CORS_ORIGIN); + res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS'); + res.setHeader( + 'access-control-allow-headers', + 'content-type, authorization, x-hasura-admin-secret, x-hasura-role, x-hasura-user-id, x-hasura-dlob-source' + ); +} + +function proxyGraphqlHttp(req, res) { + const upstreamBase = new URL(HASURA_UPSTREAM); + const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + + const target = new URL(upstreamBase.toString()); + target.pathname = HASURA_GRAPHQL_PATH; + target.search = inUrl.search; + + const isHttps = target.protocol === 'https:'; + const lib = isHttps ? https : http; + + const headers = stripHopByHopHeaders(req.headers); + headers.host = target.host; + delete headers['x-hasura-dlob-source']; + headers['x-hasura-dlob-source'] = resolveDlobSource(req); + + const upstreamReq = lib.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port || (isHttps ? 443 : 80), + method: req.method, + path: target.pathname + target.search, + headers, + }, + (upstreamRes) => { + const outHeaders = stripHopByHopHeaders(upstreamRes.headers); + withCors(res); + res.writeHead(upstreamRes.statusCode || 502, outHeaders); + upstreamRes.pipe(res); + } + ); + + upstreamReq.on('error', (err) => { + if (!res.headersSent) { + withCors(res); + send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`); + } else { + res.destroy(); + } + }); + + req.pipe(upstreamReq); +} + +function isGraphqlPath(pathname) { + return pathname === '/graphql' || pathname === '/graphql-ws'; +} + +function proxyGraphqlWs(req, socket, head) { + const upstreamBase = new URL(HASURA_UPSTREAM); + const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + + const target = new URL(upstreamBase.toString()); + target.pathname = HASURA_GRAPHQL_PATH; + target.search = inUrl.search; + + const port = Number(target.port || (target.protocol === 'https:' ? 443 : 80)); + const host = target.hostname; + + const connect = + target.protocol === 'https:' + ? () => tls.connect({ host, port, servername: host }) + : () => net.connect({ host, port }); + + const upstream = connect(); + upstream.setNoDelay(true); + socket.setNoDelay(true); + + // For WebSocket upgrades we must forward `connection`/`upgrade` and related headers. + const headers = { ...req.headers }; + delete headers['content-length']; + delete headers['content-type']; + headers.host = target.host; + delete headers['x-hasura-dlob-source']; + headers['x-hasura-dlob-source'] = resolveDlobSource(req); + + const lines = []; + lines.push(`GET ${target.pathname + target.search} HTTP/1.1`); + for (const [k, v] of Object.entries(headers)) { + if (v == null) continue; + if (Array.isArray(v)) { + for (const vv of v) lines.push(`${k}: ${vv}`); + } else { + lines.push(`${k}: ${v}`); + } + } + lines.push('', ''); + upstream.write(lines.join('\r\n')); + + if (head?.length) upstream.write(head); + + upstream.on('error', () => { + try { + socket.destroy(); + } catch { + // ignore + } + }); + socket.on('error', () => { + try { + upstream.destroy(); + } catch { + // ignore + } + }); + + upstream.pipe(socket); + socket.pipe(upstream); +} + +async function handler(req, res) { + if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) { + send( + res, + 200, + { 'content-type': 'application/json; charset=utf-8' }, + JSON.stringify({ ok: true, version: APP_VERSION, buildTimestamp: BUILD_TIMESTAMP, startedAt: STARTED_AT }) + ); + return; + } + + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + if (isGraphqlPath(url.pathname)) { + if (req.method === 'OPTIONS') { + withCors(res); + res.statusCode = 204; + res.end(); + return; + } + if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') { + const user = resolveAuthenticatedUser(req); + if (!user) { + withCors(res); + unauthorized(res); + return; + } + } + withCors(res); + proxyGraphqlHttp(req, res); + return; + } + if (req.method === 'GET' && url.pathname === '/whoami') { + sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE }); + return; + } + + if (req.method === 'GET' && url.pathname === '/prefs/dlob-source') { + const set = url.searchParams.get('set'); + if (!set) { + sendJson(res, 200, { ok: true, dlobSource: resolveDlobSource(req) }); + return; + } + + const ok = setDlobSourceCookie(res, { secure: isHttpsRequest(req), dlobSource: set }); + if (!ok) { + sendJson(res, 400, { ok: false, error: 'invalid_dlob_source' }); + return; + } + + res.statusCode = 302; + res.setHeader('location', safeRedirectPath(url.searchParams.get('redirect') || '/')); + res.end(); + return; + } + + if (req.method === 'POST' && url.pathname === '/auth/login') { + if (AUTH_MODE === 'off' || AUTH_MODE === 'none' || AUTH_MODE === 'disabled') { + sendJson(res, 400, { ok: false, error: 'auth_disabled' }); + return; + } + + const raw = await readBody(req); + const ct = String(req.headers['content-type'] || '').toLowerCase(); + let username = ''; + let password = ''; + if (ct.includes('application/json')) { + let json; + try { + json = JSON.parse(raw); + } catch { + sendJson(res, 400, { ok: false, error: 'bad_json' }); + return; + } + username = typeof json?.username === 'string' ? json.username.trim() : ''; + password = typeof json?.password === 'string' ? json.password : ''; + } else { + const params = new URLSearchParams(raw); + username = String(params.get('username') || '').trim(); + password = String(params.get('password') || ''); + } + + if (!username || !password || username.length > 64 || password.length > 200) { + sendJson(res, 400, { ok: false, error: 'invalid_input' }); + return; + } + + const ok = verifyWithHtpasswd(username, password); + if (!ok) { + unauthorized(res); + return; + } + + const secure = isHttpsRequest(req); + setSessionCookie(res, secure, username); + sendJson(res, 200, { ok: true, user: username }); + return; + } + + if ((req.method === 'POST' || req.method === 'GET') && (url.pathname === '/auth/logout' || url.pathname === '/logout')) { + clearSessionCookie(res, isHttpsRequest(req)); + if (req.method === 'GET') { + res.statusCode = 302; + res.setHeader('location', '/'); + res.end(); + return; + } + sendJson(res, 200, { ok: true }); + return; + } + + if (BASIC_AUTH_ENABLED) { + let creds; + try { + creds = loadBasicAuth(); + } catch (e) { + send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); + return; + } + + if (!isAuthorized(req, creds)) { + basicAuthRequired(res); + return; + } + } + + if (req.url?.startsWith('/api') && (req.url === '/api' || req.url.startsWith('/api/'))) { + if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') { + const user = resolveAuthenticatedUser(req); + if (!user) { + unauthorized(res); + return; + } + } + + let token; + try { + token = loadApiReadToken(); + } catch (e) { + send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); + return; + } + proxyApi(req, res, token); + return; + } + + serveStatic(req, res); +} + +const server = http.createServer((req, res) => { + handler(req, res).catch((e) => { + if (res.headersSent) { + res.destroy(); + return; + } + send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); + }); +}); +server.on('upgrade', (req, socket, head) => { + try { + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + if (!isGraphqlPath(url.pathname)) { + socket.destroy(); + return; + } + if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') { + const user = resolveAuthenticatedUser(req); + if (!user) { + try { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + } catch { + // ignore + } + socket.destroy(); + return; + } + } + proxyGraphqlWs(req, socket, head); + } catch { + socket.destroy(); + } +}); +server.listen(PORT, () => { + console.log( + JSON.stringify( + { + service: 'trade-frontend', + port: PORT, + staticDir: STATIC_DIR, + apiUpstream: API_UPSTREAM, + hasuraUpstream: HASURA_UPSTREAM, + basicAuthFile: BASIC_AUTH_FILE, + basicAuthMode: BASIC_AUTH_MODE, + apiReadTokenFile: API_READ_TOKEN_FILE, + authUserHeader: AUTH_USER_HEADER, + authMode: AUTH_MODE, + htpasswdFile: HTPASSWD_FILE, + }, + null, + 2 + ) + ); +}); diff --git a/kustomize/overlays/prod/kustomization.yaml b/kustomize/overlays/prod/kustomization.yaml index f22c8c9..6d35101 100644 --- a/kustomize/overlays/prod/kustomization.yaml +++ b/kustomize/overlays/prod/kustomization.yaml @@ -6,5 +6,16 @@ namespace: trade-prod resources: - ../../base +patchesStrategicMerge: + - frontend-graphql-proxy-patch.yaml + +configMapGenerator: + - name: trade-frontend-server-script + files: + - frontend-server.mjs + +generatorOptions: + disableNameSuffixHash: true + commonLabels: env: prod diff --git a/kustomize/overlays/staging/frontend-server.mjs b/kustomize/overlays/staging/frontend-server.mjs index a74c0db..696b338 100644 --- a/kustomize/overlays/staging/frontend-server.mjs +++ b/kustomize/overlays/staging/frontend-server.mjs @@ -37,6 +37,8 @@ const AUTH_SESSION_COOKIE = String(process.env.AUTH_SESSION_COOKIE || 'trade_ses .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'); @@ -143,6 +145,29 @@ function safePathFromUrlPath(urlPath) { 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 = ` + +
+ DLOB + mevnode + | + drift +
+`; + + const bodyClose = /<\/body>/i; + if (bodyClose.test(html)) return html.replace(bodyClose, `${snippet}`); + return `${html}\n${snippet}\n`; +} + function serveStatic(req, res) { if (req.method !== 'GET' && req.method !== 'HEAD') { send(res, 405, { 'content-type': 'text/plain; charset=utf-8' }, 'method_not_allowed'); @@ -171,6 +196,15 @@ function serveStatic(req, res) { res.setHeader('content-type', contentTypeFor(filePath)); res.setHeader('cache-control', filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000'); if (req.method === 'HEAD') return void res.end(); + if (filePath.endsWith('index.html')) { + const html = fs.readFileSync(filePath, 'utf8'); + const injected = injectIndexHtml(html, { + dlobSource: resolveDlobSource(req), + redirectPath: url.pathname + url.search, + }); + res.end(injected); + return true; + } fs.createReadStream(filePath).pipe(res); return true; } catch { @@ -222,6 +256,43 @@ function readCookie(req, name) { return null; } +function normalizeDlobSource(value) { + const v = String(value ?? '') + .trim() + .toLowerCase(); + if (v === 'mevnode') return 'mevnode'; + if (v === 'drift') return 'drift'; + return null; +} + +function resolveDlobSource(req) { + const fromCookie = normalizeDlobSource(readCookie(req, DLOB_SOURCE_COOKIE)); + if (fromCookie) return fromCookie; + return normalizeDlobSource(DLOB_SOURCE_DEFAULT) || 'mevnode'; +} + +function safeRedirectPath(value) { + const s = String(value ?? '').trim(); + if (!s.startsWith('/')) return '/'; + if (s.startsWith('//')) return '/'; + return s.replace(/\r|\n/g, ''); +} + +function setDlobSourceCookie(res, { secure, dlobSource }) { + const src = normalizeDlobSource(dlobSource); + if (!src) return false; + const parts = [ + `${DLOB_SOURCE_COOKIE}=${src}`, + 'Path=/', + 'SameSite=Lax', + 'HttpOnly', + 'Max-Age=31536000', + ]; + if (secure) parts.push('Secure'); + res.setHeader('set-cookie', parts.join('; ')); + return true; +} + function resolveAuthUser(req) { const user = readHeader(req, AUTH_USER_HEADER) || readHeader(req, 'x-webauth-user'); const value = typeof user === 'string' ? user.trim() : ''; @@ -423,7 +494,7 @@ function withCors(res) { res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS'); res.setHeader( 'access-control-allow-headers', - 'content-type, authorization, x-hasura-admin-secret, x-hasura-role, x-hasura-user-id' + 'content-type, authorization, x-hasura-admin-secret, x-hasura-role, x-hasura-user-id, x-hasura-dlob-source' ); } @@ -440,6 +511,8 @@ function proxyGraphqlHttp(req, res) { const headers = stripHopByHopHeaders(req.headers); headers.host = target.host; + delete headers['x-hasura-dlob-source']; + headers['x-hasura-dlob-source'] = resolveDlobSource(req); const upstreamReq = lib.request( { @@ -499,6 +572,8 @@ function proxyGraphqlWs(req, socket, head) { delete headers['content-length']; delete headers['content-type']; headers.host = target.host; + delete headers['x-hasura-dlob-source']; + headers['x-hasura-dlob-source'] = resolveDlobSource(req); const lines = []; lines.push(`GET ${target.pathname + target.search} HTTP/1.1`); @@ -570,6 +645,25 @@ async function handler(req, res) { return; } + if (req.method === 'GET' && url.pathname === '/prefs/dlob-source') { + const set = url.searchParams.get('set'); + if (!set) { + sendJson(res, 200, { ok: true, dlobSource: resolveDlobSource(req) }); + return; + } + + const ok = setDlobSourceCookie(res, { secure: isHttpsRequest(req), dlobSource: set }); + if (!ok) { + sendJson(res, 400, { ok: false, error: 'invalid_dlob_source' }); + return; + } + + res.statusCode = 302; + res.setHeader('location', safeRedirectPath(url.searchParams.get('redirect') || '/')); + res.end(); + return; + } + if (req.method === 'POST' && url.pathname === '/auth/login') { if (AUTH_MODE === 'off' || AUTH_MODE === 'none' || AUTH_MODE === 'disabled') { sendJson(res, 400, { ok: false, error: 'auth_disabled' });