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 path from 'node:path'; 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 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); } 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 (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.listen(PORT, () => { console.log( JSON.stringify( { service: 'trade-frontend', port: PORT, staticDir: STATIC_DIR, apiUpstream: API_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 ) ); });