Files
flights_web/scripts/dev-server.mjs
T
gnezim 8feb5de70e Dev-server: fall back between direct and HTTPS_PROXY transports
The local WAF is unpredictable: some windows the gost VPN tunnel at
127.0.0.1:8888 is 503-ing (direct must work), other windows the direct IP
is throttled to 403 by Ngenix (VPN must be used). The previous hardcoded
`--noproxy '*'` survived one of those states only, which is why the
dictionary load surfaced as console 403s as soon as the state flipped.

Try direct first (faster when it works, simpler cookie jar), fall back
through the system HTTPS_PROXY on 4xx/5xx or curl failure, keep a
separate cookie jar per transport so the Ngenix cookies don't cross-
contaminate edge nodes.
2026-04-22 11:44:46 +03:00

229 lines
8.6 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.
// 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: "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 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 403/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) => {
const targetUrl = `${API_TARGET}${req.originalUrl}`;
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 execCurlWithFallback(buildArgs, extraArgs, res) {
runCurl([...extraArgs, ...buildArgs(true)], (direct) => {
if (isSuccessfulUpstream(direct)) {
respondWithCurlResult(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) => {
respondWithCurlResult(isSuccessfulUpstream(proxy) ? proxy : direct, res);
});
});
}
function runCurl(args, cb) {
execFile("/usr/bin/curl", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout) => {
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;
return status >= 200 && status < 400;
}
function respondWithCurlResult({ err, stdout }, res) {
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(); });