/** * Development server with same-origin API proxy. * Equivalent to Angular's proxy.conf.json + ng serve. * * Port 8080 (browser-facing): * /api/* → curl → https://flights.test.aeroflot.ru (bypasses WAF via curl TLS) * /flights/* → curl → https://flights.test.aeroflot.ru * /map/* → curl → https://flights.test.aeroflot.ru (binary JPEG tiles) * /tracker/* → https://platform.test.aeroflot.ru (SignalR, same-origin) * /* → localhost:8081 (Modern.js SSR + HMR) */ import express from "express"; import { createProxyMiddleware, responseInterceptor } from "http-proxy-middleware"; import { HttpsProxyAgent } from "https-proxy-agent"; import { execFile, spawn } from "node:child_process"; import { resolve } from "node:path"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs"; const PUBLIC_PORT = 8080; const MODERNJS_PORT = 8081; const API_TARGET = process.env.API_TARGET || "https://flights.test.aeroflot.ru"; const TRACKER_TARGET = process.env.TRACKER_TARGET || "https://platform.test.aeroflot.ru"; const AEROFLOT_STATIC_TARGET = process.env.AEROFLOT_STATIC_TARGET || "https://www.aeroflot.ru"; const SYSTEM_PROXY = process.env.https_proxy || process.env.HTTPS_PROXY || ""; const DEBUG_PROXY_BODY = process.env.DEBUG_PROXY_BODY === "1"; const LOCAL_PUBLIC_ORIGIN = `http://localhost:${PUBLIC_PORT}`; const shellStaticAssetCache = new Map(); // Shared cookie jar so the Ngenix WAF cookie challenge (`ngenix_valid` + // 307-to-self) only runs once per dev-server lifetime, not per request. // We keep one jar per transport (direct vs. HTTPS_PROXY) because the WAF // anchors the cookie to the upstream edge node that minted it; mixing // jars across transports re-triggers the challenge on every request. const COOKIE_JAR_DIRECT = resolve(tmpdir(), `aeroflot-dev-cookies-direct-${process.pid}.txt`); const COOKIE_JAR_PROXY = resolve(tmpdir(), `aeroflot-dev-cookies-proxy-${process.pid}.txt`); // --- Start Modern.js on internal port --- console.log(`Starting Modern.js on :${MODERNJS_PORT}...`); // Resolve the modern binary directly to avoid DEP0190 (shell: true with spawn) const modernBin = resolve("node_modules", ".bin", "modern"); const modernProcess = existsSync(modernBin) ? spawn(modernBin, ["dev"], { stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, PORT: String(MODERNJS_PORT), AEROFLOT_SHELL_LOADER_PROXY: "1", }, }) : spawn(process.execPath, [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "dev"], { stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, PORT: String(MODERNJS_PORT), AEROFLOT_SHELL_LOADER_PROXY: "1", }, }); modernProcess.on("error", (err) => { console.error("Modern.js failed:", err); process.exit(1); }); await new Promise((r) => setTimeout(r, 18000)); const app = express(); // --- Tile proxy via curl (binary JPEG, pipe stdout straight to response) --- const TILE_CONTENT_TYPES = { ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", }; app.use("/map", (req, res) => { const targetUrl = `${API_TARGET}${req.originalUrl}`; const ext = req.path.substring(req.path.lastIndexOf(".")); const contentType = TILE_CONTENT_TYPES[ext] ?? "application/octet-stream"; res.status(200); res.setHeader("Content-Type", contentType); res.setHeader("Cache-Control", "public, max-age=86400"); const commonHeaders = [ "-H", `Accept: image/webp,image/jpeg,image/*,*/*`, "-H", `User-Agent: ${req.headers["user-agent"] || "Mozilla/5.0"}`, "-H", `Referer: ${API_TARGET}/`, ]; // Tile transport is binary — fall back to the proxy path only if the // direct attempt emits no data (indicating WAF deny / throttle). const tryTile = (noproxy) => { const args = [ "-sS", "-f", // fail on HTTP 4xx/5xx "-L", // follow Ngenix 307-to-self cookie challenge ...(noproxy ? ["--noproxy", "*"] : []), "-c", noproxy ? COOKIE_JAR_DIRECT : COOKIE_JAR_PROXY, "-b", noproxy ? COOKIE_JAR_DIRECT : COOKIE_JAR_PROXY, ...commonHeaders, targetUrl, ]; return spawn("/usr/bin/curl", args, { stdio: ["ignore", "pipe", "pipe"] }); }; const child = tryTile(true); let gotData = false; child.stdout.on("data", (chunk) => { gotData = true; res.write(chunk); }); child.on("exit", (code) => { if (code === 0) { res.end(); return; } if (gotData) { res.end(); return; } // Direct failed with no data — retry through the system HTTPS_PROXY. const retry = tryTile(false); retry.stdout.pipe(res); retry.on("exit", () => res.end()); }); }); // --- API proxy via curl (bypasses WAF TLS fingerprinting) --- // Two transports: (1) direct with `--noproxy '*'` — fast path when the // upstream is publicly reachable; (2) through the system HTTPS_PROXY // (e.g. a local gost VPN tunnel) — required when the direct IP hits a // WAF throttle (403 deny page). Try direct first; on 4xx/5xx or // network error, retry through the proxy. Each transport keeps its own // cookie jar because the WAF cookie is bound to the edge node that // minted it. app.use(["/api", "/flights"], (req, res) => { console.log(`API proxy called for: ${req.originalUrl}`); const targetUrl = `${API_TARGET}${req.originalUrl}`; console.log(`targetUrl: ${targetUrl}`); const buildArgs = (noproxy) => [ "-s", "-L", ...(noproxy ? ["--noproxy", "*"] : []), "-c", noproxy ? COOKIE_JAR_DIRECT : COOKIE_JAR_PROXY, "-b", noproxy ? COOKIE_JAR_DIRECT : COOKIE_JAR_PROXY, "-H", `Accept: ${req.headers.accept || "application/json"}`, "-H", `User-Agent: ${req.headers["user-agent"] || "Mozilla/5.0"}`, "-H", `Accept-Language: ${req.headers["accept-language"] || "ru"}`, "-w", "\n%{http_code}", targetUrl, ]; if (req.method === "POST") { let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => { const bodyArgs = body ? ["-X", "POST", "-d", body, "-H", "Content-Type: application/json"] : ["-X", "POST"]; execCurlWithFallback(buildArgs, bodyArgs, res); }); } else { execCurlWithFallback(buildArgs, [], res); } }); function normalizeTrackerCookie(cookie) { if (!cookie.startsWith("signal-id=")) return cookie; const parts = cookie .split(";") .map((part) => part.trim()) .filter( (part) => part.length > 0 && !part.toLowerCase().startsWith("domain=") && !part.toLowerCase().startsWith("path=") && !part.toLowerCase().startsWith("samesite="), ); return [...parts, "Path=/tracker/hub", "SameSite=Lax"].join("; "); } function applyTrackerCookieHeaders(proxyRes, res) { const setCookie = proxyRes.headers["set-cookie"]; if (Array.isArray(setCookie)) { res.setHeader("set-cookie", setCookie.map(normalizeTrackerCookie)); } else if (typeof setCookie === "string") { res.setHeader("set-cookie", normalizeTrackerCookie(setCookie)); } } function isTrackerLongPollTimeout(proxyRes, req) { const requestUrl = req.originalUrl ?? req.url ?? ""; return ( req.method === "GET" && (requestUrl.startsWith("/tracker/hub?id=") || requestUrl.startsWith("/hub?id=")) && proxyRes.statusCode === 504 ); } // --- SignalR TrackerHub proxy --- // Browser-direct localhost → platform.test.aeroflot.ru fails CORS. Keep the // hub same-origin in development and let proxy-helper / gost route the // upstream request through the TIM tunnel when HTTPS_PROXY is set. const trackerProxy = createProxyMiddleware({ target: TRACKER_TARGET, changeOrigin: true, secure: false, ws: true, logLevel: "warn", pathRewrite: (path) => `/tracker${path}`, selfHandleResponse: true, on: { proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => { applyTrackerCookieHeaders(proxyRes, res); if (isTrackerLongPollTimeout(proxyRes, req)) { console.warn(`Tracker long poll timed out upstream, returning empty 200 for ${req.originalUrl ?? req.url}`); res.statusCode = 200; res.statusMessage = "OK"; res.setHeader("content-type", "text/plain"); return ""; } return buffer; }), }, ...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}), }); app.use("/tracker", trackerProxy); // --- Aeroflot frontend loader static proxy --- // The loader derives follow-up asset URLs from its own script origin. Serving // it through localhost keeps Header/Footer hydration visible in dev Chrome // without browser CORS errors from www.aeroflot.ru. app.use( "/frontend/static", createProxyMiddleware({ target: AEROFLOT_STATIC_TARGET, changeOrigin: true, secure: false, logLevel: "warn", pathRewrite: (path) => `/frontend/static${path}`, selfHandleResponse: true, on: { proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => { const cacheKey = req.originalUrl ?? req.url ?? ""; const contentType = inferShellStaticContentType( cacheKey, proxyRes.headers["content-type"], ); if (!/(css|html|javascript|json|text)/i.test(String(contentType))) { if (proxyRes.statusCode && proxyRes.statusCode < 500) { shellStaticAssetCache.set(cacheKey, { body: buffer, contentType }); } return buffer; } if (proxyRes.statusCode && proxyRes.statusCode >= 500) { const cached = shellStaticAssetCache.get(cacheKey); if (cached) { res.statusCode = 200; res.statusMessage = "OK"; res.setHeader("content-type", cached.contentType); return cached.body; } const retried = await retryAeroflotStaticAsset(cacheKey); if (retried) { const body = rewriteAeroflotShellUrls(retried.body); shellStaticAssetCache.set(cacheKey, { body, contentType }); res.statusCode = 200; res.statusMessage = "OK"; res.setHeader("content-type", contentType); return body; } } const body = rewriteAeroflotShellUrls(buffer.toString("utf8")); if (!proxyRes.statusCode || proxyRes.statusCode < 500) { shellStaticAssetCache.set(cacheKey, { body, contentType }); } return body; }), }, ...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}), }), ); function inferShellStaticContentType(requestPath, upstreamContentType) { const contentType = String(upstreamContentType ?? ""); if (/(css|html|javascript|json|text)/i.test(contentType)) return contentType; const pathname = requestPath.split("?")[0] ?? ""; if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "application/javascript"; if (pathname.endsWith(".css")) return "text/css"; if (pathname.endsWith(".json")) return "application/json"; if (pathname.endsWith(".html")) return "text/html"; return contentType; } function retryAeroflotStaticAsset(originalUrl) { const targetUrl = `${AEROFLOT_STATIC_TARGET}${originalUrl}`; const buildArgs = (noproxy) => [ "-sS", "-f", "-L", ...(noproxy ? ["--noproxy", "*"] : []), "-H", "Accept: */*", "-H", "User-Agent: Mozilla/5.0", targetUrl, ]; return new Promise((resolveRetry) => { runCurlWithoutStatus(buildArgs(true), (direct) => { if (!direct.err && direct.stdout) { resolveRetry({ body: direct.stdout }); return; } runCurlWithoutStatus(buildArgs(false), (proxy) => { resolveRetry(!proxy.err && proxy.stdout ? { body: proxy.stdout } : null); }); }); }); } app.use("/personal/services/internal/v.0.0.1/json/get_member_info", (_req, res) => { res.status(200).json({ data: null }); }); app.use("/gw/api/pr/LKAB/Profile/v3/get", (_req, res) => { res.status(200).json({ data: null }); }); app.use(["/ws2/v.0.0.1/json/currency/RU", "/ws2/v.0.0.1/json/calcurr/"], (_req, res) => { res.status(200).json({ data: { currency: "RUB" }, errors: [], isSuccess: true }); }); app.use("/pkl/ws/json/v1/member/get", (_req, res) => { res.status(200).json({ data: null, errors: [], isSuccess: true }); }); app.use( ["/ws2", "/cms2", "/personal", "/offers", "/feedback"], express.raw({ type: "*/*" }), (req, res) => { const targetUrl = `${AEROFLOT_STATIC_TARGET}${req.originalUrl}`; const requestBody = req.body?.length ? req.body.toString("utf8").replaceAll(LOCAL_PUBLIC_ORIGIN, API_TARGET) : ""; const buildArgs = (noproxy) => [ "-s", "-L", ...(noproxy ? ["--noproxy", "*"] : []), "-H", `Accept: ${req.headers.accept || "application/json"}`, "-H", `User-Agent: ${req.headers["user-agent"] || "Mozilla/5.0"}`, "-H", `Accept-Language: ${req.headers["accept-language"] || "ru"}`, "-w", "\n%{http_code}", targetUrl, ]; const bodyArgs = req.method === "GET" || req.method === "HEAD" ? [] : [ "-X", req.method, "-H", `Content-Type: ${req.headers["content-type"] || "application/json"}`, ...(requestBody ? ["--data-raw", requestBody] : []), ]; execCurlWithFallback(buildArgs, bodyArgs, res, respondWithAeroflotFrontendResult); }, ); app.use( "/media", createProxyMiddleware({ target: AEROFLOT_STATIC_TARGET, changeOrigin: true, secure: false, logLevel: "warn", pathRewrite: (path) => `/media${path}`, ...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}), }), ); function execCurlWithFallback(buildArgs, extraArgs, res, responder = respondWithCurlResult) { runCurl([...extraArgs, ...buildArgs(true)], (direct) => { if (isSuccessfulUpstream(direct)) { responder(direct, res); return; } // Direct hit a WAF deny / throttle / network failure — retry through // the system HTTPS_PROXY (gost VPN tunnel on this host). runCurl([...extraArgs, ...buildArgs(false)], (proxy) => { responder(isSuccessfulUpstream(proxy) ? proxy : direct, res); }); }); } function runCurl(args, cb) { execFile("/usr/bin/curl", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout, stderr) => { if (err) { console.warn(`curl failed: ${err.message}`); } if (stderr) { console.warn(`curl stderr: ${stderr.substring(0, 300)}`); } cb({ err, stdout: stdout ?? "" }); }); } function runCurlWithoutStatus(args, cb) { execFile("/usr/bin/curl", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout, stderr) => { if (err) { console.warn(`static curl retry failed: ${err.message}`); } if (stderr) { console.warn(`static curl retry stderr: ${stderr.substring(0, 300)}`); } cb({ err, stdout: stdout ?? "" }); }); } function isSuccessfulUpstream({ err, stdout }) { if (err) return false; const lastNewline = stdout.lastIndexOf("\n"); const statusStr = lastNewline >= 0 ? stdout.substring(lastNewline + 1).trim() : ""; const status = parseInt(statusStr) || 0; if (status < 200 || status >= 400) return false; const body = lastNewline >= 0 ? stdout.substring(0, lastNewline) : stdout; const trimmed = body.trimStart(); // Some upstream edges return the Angular shell as HTTP 200 for API // paths on the direct transport. Treat that as a failed API response so // the fallback transport can fetch the JSON payload. if (trimmed.startsWith(" 0` check mis-treats the status as body and falls back to 200 — // silently hiding real 4xx/5xx responses. Use `>= 0` and split // unconditionally when the newline is present. const lastNewline = stdout.lastIndexOf("\n"); const body = lastNewline >= 0 ? stdout.substring(0, lastNewline) : stdout; const statusStr = lastNewline >= 0 ? stdout.substring(lastNewline + 1).trim() : "200"; const status = parseInt(statusStr) || 200; const isJson = body.trimStart().startsWith("{") || body.trimStart().startsWith("["); console.log( `proxy response: status=${status}, content=${isJson ? "json" : "html"}, bodyLength=${body.length}, ${summarizeBody(body)}`, ); if (DEBUG_PROXY_BODY) { console.log(`body preview: ${body.substring(0, 500)}`); } res.status(status); res.set("Content-Type", isJson ? "application/json" : "text/html"); res.set("Access-Control-Allow-Origin", "*"); res.send(body); } function respondWithAeroflotFrontendResult(result, res) { if (result.err) { res.status(502).json({ error: result.err.message }); return; } const lastNewline = result.stdout.lastIndexOf("\n"); const rawBody = lastNewline >= 0 ? result.stdout.substring(0, lastNewline) : result.stdout; const body = rewriteAeroflotShellUrls(rawBody); const statusStr = lastNewline >= 0 ? result.stdout.substring(lastNewline + 1).trim() : "200"; const status = parseInt(statusStr) || 200; const isJson = body.trimStart().startsWith("{") || body.trimStart().startsWith("["); res.status(status); res.set("Content-Type", isJson ? "application/json" : "text/html"); res.set("Access-Control-Allow-Origin", "*"); res.send(body); } function summarizeBody(body) { const trimmed = body.trimStart(); if (!trimmed) return "body=empty"; try { const parsed = JSON.parse(trimmed); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { if (typeof parsed.days === "string") { const available = [...parsed.days].filter((d) => d === "1").length; return `daysLength=${parsed.days.length}, availableDays=${available}`; } if (parsed.data && Array.isArray(parsed.data.routes)) { return `routes=${parsed.data.routes.length}`; } return `jsonKeys=${Object.keys(parsed).slice(0, 5).join(",")}`; } if (Array.isArray(parsed)) { return `jsonArrayLength=${parsed.length}`; } } catch { // fall through to non-JSON summary } return `bodyPreview=${trimmed.substring(0, 80).replace(/\s+/g, " ")}`; } // --- Everything else → Modern.js --- const modernProxy = createProxyMiddleware({ target: `http://localhost:${MODERNJS_PORT}`, changeOrigin: false, ws: true, logLevel: "silent", }); app.use(modernProxy); const server = app.listen(PUBLIC_PORT, () => { console.log(`\n ✓ Dev server: http://localhost:${PUBLIC_PORT}`); console.log(` /api/* → curl → ${API_TARGET}`); console.log(` /tracker/* → proxy → ${TRACKER_TARGET}`); console.log(` /* → Modern.js :${MODERNJS_PORT}\n`); }); // Forward WebSocket upgrades to Modern.js HMR server explicitly, // preventing reconnection spam from http-proxy-middleware's built-in ws handling. server.on("upgrade", (req, socket, head) => { if (req.url?.startsWith("/tracker")) { trackerProxy.upgrade(req, socket, head); return; } modernProxy.upgrade(req, socket, head); }); process.on("SIGINT", () => { modernProcess.kill(); process.exit(); }); process.on("SIGTERM", () => { modernProcess.kill(); process.exit(); });