From 4657e766f1d55c444d0d02f5afaf0bbbe46933b6 Mon Sep 17 00:00:00 2001 From: u1 Date: Wed, 14 Jan 2026 08:49:17 +0000 Subject: [PATCH] staging: add frontend server script with /graphql proxy --- .../overlays/staging/frontend-server.mjs | 719 ++++++++++++++++++ 1 file changed, 719 insertions(+) create mode 100644 kustomize/overlays/staging/frontend-server.mjs diff --git a/kustomize/overlays/staging/frontend-server.mjs b/kustomize/overlays/staging/frontend-server.mjs new file mode 100644 index 0000000..a74c0db --- /dev/null +++ b/kustomize/overlays/staging/frontend-server.mjs @@ -0,0 +1,719 @@ +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 + +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 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(); + 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 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' + ); +} + +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; + + 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; + + 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 === '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 + ) + ); +});