a4e8d87688
curl was inheriting HTTPS_PROXY=127.0.0.1:8888 (a local gost tunnel whose upstream VPN intermittently 503s), making the app fail to load dictionaries in dev. Upstream Ngenix WAF also newly requires a 307-to-self cookie handshake (ngenix_valid) before issuing JSON. Bypass the system proxy directly and keep a per-session cookie jar so the handshake only runs once.
187 lines
6.9 KiB
JavaScript
187 lines
6.9 KiB
JavaScript
/**
|
|
* 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)
|
|
* /* → localhost:8081 (Modern.js SSR + HMR)
|
|
*/
|
|
import express from "express";
|
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
import { execFile, spawn } from "node:child_process";
|
|
import { resolve } from "node:path";
|
|
import { existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
|
|
const PUBLIC_PORT = 8080;
|
|
const MODERNJS_PORT = 8081;
|
|
const API_TARGET = "https://flights.test.aeroflot.ru";
|
|
|
|
// Shared cookie jar so the Ngenix WAF cookie challenge (`ngenix_valid` +
|
|
// 307-to-self) only runs once per dev-server lifetime, not per request.
|
|
const COOKIE_JAR = resolve(tmpdir(), `aeroflot-dev-cookies-${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: "inherit",
|
|
env: { ...process.env, PORT: String(MODERNJS_PORT) },
|
|
})
|
|
: spawn(process.execPath, [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "dev"], {
|
|
stdio: "inherit",
|
|
env: { ...process.env, PORT: String(MODERNJS_PORT) },
|
|
});
|
|
modernProcess.on("error", (err) => {
|
|
console.error("Modern.js failed:", err);
|
|
process.exit(1);
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 18000));
|
|
|
|
const app = express();
|
|
|
|
// --- Mock fallback for /api/appSettings when WAF blocks requests ---
|
|
app.get("/api/appSettings", (req, res) => {
|
|
res.json({
|
|
showDebugVersion: "False",
|
|
uiOptions: {
|
|
isTestVersion: "",
|
|
filter: {
|
|
schedule: { searchFrom: "30d", searchTo: "30d", timeStep: "" },
|
|
onlineboard: { searchFrom: "2d", searchTo: "14d", timeStep: "" },
|
|
},
|
|
buttons: {
|
|
buyTicket: { period: { min: "2h", max: "72h" } },
|
|
flightStatus: { availableFrom: "24h", visible: "" },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
// --- 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 args = [
|
|
"-sS",
|
|
"-f", // fail on HTTP 4xx/5xx
|
|
"-L", // follow Ngenix 307-to-self cookie challenge
|
|
"--noproxy", "*", // bypass any inherited HTTPS_PROXY — upstream is publicly reachable
|
|
"-c", COOKIE_JAR, "-b", COOKIE_JAR, // persist `ngenix_valid` cookie across requests
|
|
"-H", `Accept: image/webp,image/jpeg,image/*,*/*`,
|
|
"-H", `User-Agent: ${req.headers["user-agent"] || "Mozilla/5.0"}`,
|
|
"-H", `Referer: ${API_TARGET}/`,
|
|
targetUrl,
|
|
];
|
|
const child = spawn("/usr/bin/curl", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
|
|
child.stdout.pipe(res);
|
|
child.on("exit", (code) => {
|
|
if (code !== 0) {
|
|
// -f means upstream failed; close cleanly with empty body.
|
|
res.end();
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- API proxy via curl (bypasses WAF TLS fingerprinting) ---
|
|
app.use(["/api", "/flights"], (req, res) => {
|
|
const targetUrl = `${API_TARGET}${req.originalUrl}`;
|
|
|
|
// Use curl to make the request — it passes through the system proxy
|
|
// with a proper TLS fingerprint that the WAF accepts.
|
|
const args = [
|
|
"-s", // silent
|
|
"-L", // follow Ngenix 307-to-self cookie challenge
|
|
"--noproxy", "*", // bypass any inherited HTTPS_PROXY — upstream is publicly reachable
|
|
"-c", COOKIE_JAR, "-b", COOKIE_JAR, // persist `ngenix_valid` cookie across requests
|
|
"-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}", // append status code at end
|
|
targetUrl,
|
|
];
|
|
|
|
if (req.method === "POST") {
|
|
args.unshift("-X", "POST");
|
|
// Read body and pass to curl
|
|
let body = "";
|
|
req.on("data", (chunk) => { body += chunk; });
|
|
req.on("end", () => {
|
|
if (body) {
|
|
args.push("-d", body);
|
|
args.push("-H", "Content-Type: application/json");
|
|
}
|
|
execCurl(args, res);
|
|
});
|
|
} else {
|
|
execCurl(args, res);
|
|
}
|
|
});
|
|
|
|
function execCurl(args, res) {
|
|
execFile("/usr/bin/curl", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout) => {
|
|
if (err) {
|
|
res.status(502).json({ error: err.message });
|
|
return;
|
|
}
|
|
|
|
// stdout = body + "\n" + statusCode (from -w "\n%{http_code}"). When
|
|
// upstream returns an empty body the stdout starts with "\n", so a
|
|
// `> 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("[");
|
|
|
|
res.status(status);
|
|
res.set("Content-Type", isJson ? "application/json" : "text/html");
|
|
res.set("Access-Control-Allow-Origin", "*");
|
|
res.send(body);
|
|
});
|
|
}
|
|
|
|
// --- 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(` /* → 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", modernProxy.upgrade);
|
|
|
|
process.on("SIGINT", () => { modernProcess.kill(); process.exit(); });
|
|
process.on("SIGTERM", () => { modernProcess.kill(); process.exit(); });
|