372 lines
12 KiB
JavaScript
Raw Permalink Normal View History

"use strict";
const dgram = require("dgram");
const fs = require("fs");
const http = require("http");
const https = require("https");
const path = require("path");
const { URL } = require("url");
const HOST = process.env.GUIDE_WEB_HOST || "0.0.0.0";
const PORT = Number(process.env.GUIDE_WEB_PORT || 8890);
const AUTO_EXIT_MS = Math.max(0, Number(process.env.GUIDE_WEB_AUTO_EXIT_MS || 0));
const APP_ID = "airport-guide-dashboard";
const ROOT = __dirname;
const CONFIG_PATH = path.resolve(process.env.GUIDE_WEB_CONFIG_PATH || path.join(ROOT, "config.json"));
const CONFIG_TEMP_PATH = `${CONFIG_PATH}.tmp`;
const ALLOWED_ORIGINS = (process.env.GUIDE_WEB_ALLOWED_ORIGINS || "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
const serverStartedAt = Date.now();
let activeBrowserSessions = 0;
let browserSessionSeen = false;
let lastSessionEndedAt = serverStartedAt;
const DEFAULT_CONFIG = {
mode: "http",
multicastAddress: "239.255.0.1",
udpPort: 8800,
targetId: "T001",
parkId: "P01",
httpUrl: "http://127.0.0.1:8801/api/parking/status",
pollIntervalMs: 1000,
flvUrl: ""
};
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".ico": "image/x-icon"
};
function sendJson(res, statusCode, value) {
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store"
});
res.end(JSON.stringify(value));
}
function handleHealth(res) {
sendJson(res, 200, {
app: APP_ID,
status: "ok",
port: PORT,
autoExit: AUTO_EXIT_MS > 0,
browserSessions: activeBrowserSessions
});
}
function handleBrowserSession(req, res) {
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
});
res.write("event: ready\ndata: {}\n\n");
activeBrowserSessions += 1;
browserSessionSeen = true;
const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 15000);
let closed = false;
const close = () => {
if (closed) return;
closed = true;
clearInterval(keepAlive);
activeBrowserSessions = Math.max(0, activeBrowserSessions - 1);
lastSessionEndedAt = Date.now();
};
req.on("close", close);
res.on("close", close);
}
function applyCors(req, res) {
const origin = req.headers.origin;
let allowedOrigin = "";
if (ALLOWED_ORIGINS.includes("*")) {
allowedOrigin = "*";
} else if (origin && ALLOWED_ORIGINS.includes(origin)) {
allowedOrigin = origin;
} else if (origin && ALLOWED_ORIGINS.length === 0) {
try {
const originHost = new URL(origin).hostname;
const requestHost = new URL(`http://${req.headers.host || ""}`).hostname;
const loopbackHosts = new Set(["localhost", "127.0.0.1", "::1"]);
if (originHost === requestHost || (loopbackHosts.has(originHost) && loopbackHosts.has(requestHost))) {
allowedOrigin = origin;
}
} catch {}
}
if (allowedOrigin) res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept");
res.setHeader("Access-Control-Max-Age", "86400");
res.setHeader("Access-Control-Allow-Private-Network", "true");
}
function cleanText(value, fallback, maxLength = 2048) {
const text = typeof value === "string" ? value.trim() : "";
return (text || fallback).slice(0, maxLength);
}
function sanitizeConfig(raw = {}) {
const port = Number(raw.udpPort);
const interval = Number(raw.pollIntervalMs);
const address = cleanText(raw.multicastAddress, DEFAULT_CONFIG.multicastAddress, 64);
const validMulticast = /^((22[4-9]|23\d)\.)/.test(address);
return {
mode: raw.mode === "udp" ? "udp" : "http",
multicastAddress: validMulticast ? address : DEFAULT_CONFIG.multicastAddress,
udpPort: Number.isInteger(port) && port >= 1 && port <= 65535 ? port : DEFAULT_CONFIG.udpPort,
targetId: cleanText(raw.targetId, DEFAULT_CONFIG.targetId, 128),
parkId: cleanText(raw.parkId, DEFAULT_CONFIG.parkId, 128),
httpUrl: cleanText(raw.httpUrl, DEFAULT_CONFIG.httpUrl),
pollIntervalMs: Number.isFinite(interval) ? Math.max(300, Math.round(interval)) : DEFAULT_CONFIG.pollIntervalMs,
flvUrl: cleanText(raw.flvUrl, "")
};
}
function readSavedConfig() {
try {
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
return {
...sanitizeConfig(raw),
configured: raw.configured === true
};
} catch {
return { ...DEFAULT_CONFIG, configured: false };
}
}
function handleConfig(req, res) {
if (req.method === "GET") {
sendJson(res, 200, readSavedConfig());
return;
}
if (req.method !== "POST") {
sendJson(res, 405, { error: "仅支持 GET 或 POST" });
return;
}
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
if (body.length > 64 * 1024) req.destroy(new Error("配置数据过大"));
});
req.on("end", () => {
try {
const saved = { ...sanitizeConfig(JSON.parse(body)), configured: true };
fs.writeFileSync(CONFIG_TEMP_PATH, `${JSON.stringify(saved, null, 2)}\n`, "utf8");
fs.renameSync(CONFIG_TEMP_PATH, CONFIG_PATH);
sendJson(res, 200, saved);
} catch (error) {
try { fs.rmSync(CONFIG_TEMP_PATH, { force: true }); } catch {}
sendJson(res, 400, { error: `配置保存失败:${error.message}` });
}
});
req.on("error", (error) => {
if (!res.headersSent) sendJson(res, 400, { error: error.message });
});
}
function requestTarget(rawUrl, onResponse, onError) {
let target;
try {
target = new URL(rawUrl);
} catch {
onError(new Error("无效的目标地址"));
return null;
}
if (!["http:", "https:"].includes(target.protocol)) {
onError(new Error("仅支持 HTTP 或 HTTPS 地址"));
return null;
}
const transport = target.protocol === "https:" ? https : http;
const request = transport.get(target, {
timeout: 6000,
headers: {
"User-Agent": "AirportGuideDashboard/1.0",
"Accept": "*/*",
"Connection": "keep-alive"
}
}, onResponse);
request.on("timeout", () => request.destroy(new Error("连接超时")));
request.on("error", onError);
return request;
}
function handleHttpStatus(url, res) {
const targetUrl = url.searchParams.get("url") || "";
requestTarget(targetUrl, (upstream) => {
let body = "";
upstream.setEncoding("utf8");
upstream.on("data", (chunk) => {
body += chunk;
if (body.length > 1024 * 1024) upstream.destroy(new Error("响应数据过大"));
});
upstream.on("end", () => {
if (upstream.statusCode !== 200) {
sendJson(res, 502, { error: `上游 HTTP ${upstream.statusCode}` });
return;
}
try {
const parsed = JSON.parse(body);
sendJson(res, 200, parsed);
} catch {
sendJson(res, 502, { error: "上游返回的内容不是有效 JSON" });
}
});
}, (error) => sendJson(res, 502, { error: error.message }));
}
function handleFlvProxy(url, req, res) {
const targetUrl = url.searchParams.get("url") || "";
const upstreamRequest = requestTarget(targetUrl, (upstream) => {
if (upstream.statusCode !== 200) {
sendJson(res, 502, { error: `视频源 HTTP ${upstream.statusCode}` });
upstream.resume();
return;
}
res.writeHead(200, {
"Content-Type": upstream.headers["content-type"] || "video/x-flv",
"Cache-Control": "no-store, no-cache, must-revalidate",
"Connection": "keep-alive"
});
upstream.pipe(res);
upstream.on("error", () => res.destroy());
}, (error) => {
if (!res.headersSent) sendJson(res, 502, { error: error.message });
else res.destroy();
});
req.on("close", () => upstreamRequest?.destroy());
}
function handleUdpStream(url, req, res) {
const address = url.searchParams.get("address") || "239.255.0.1";
const port = Number(url.searchParams.get("port") || 8800);
if (!/^((22[4-9]|23\d)\.)/.test(address) || !Number.isInteger(port) || port < 1 || port > 65535) {
sendJson(res, 400, { error: "组播地址或端口无效" });
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
});
res.write(`event: ready\ndata: ${JSON.stringify({ address, port })}\n\n`);
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 15000);
socket.on("message", (buffer) => {
const text = buffer.toString("utf8");
try {
JSON.parse(text);
res.write(`data: ${text.replace(/\r?\n/g, "")}\n\n`);
} catch {
res.write(`event: packet-error\ndata: ${JSON.stringify({ error: "收到非 JSON 组播数据" })}\n\n`);
}
});
socket.on("error", (error) => {
res.write(`event: bridge-error\ndata: ${JSON.stringify({ error: error.message })}\n\n`);
socket.close();
});
socket.bind(port, "0.0.0.0", () => {
try {
socket.addMembership(address);
} catch (error) {
res.write(`event: bridge-error\ndata: ${JSON.stringify({ error: error.message })}\n\n`);
socket.close();
}
});
const close = () => {
clearInterval(keepAlive);
try { socket.dropMembership(address); } catch {}
try { socket.close(); } catch {}
};
req.on("close", close);
res.on("close", close);
}
function serveStatic(url, res) {
const requested = url.pathname === "/" ? "/index.html" : decodeURIComponent(url.pathname);
const resolved = path.resolve(ROOT, `.${requested}`);
const relative = path.relative(ROOT, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
sendJson(res, 403, { error: "禁止访问" });
return;
}
fs.stat(resolved, (error, stat) => {
if (error || !stat.isFile()) {
sendJson(res, 404, { error: "文件不存在" });
return;
}
res.writeHead(200, {
"Content-Type": MIME_TYPES[path.extname(resolved).toLowerCase()] || "application/octet-stream",
"Cache-Control": resolved.endsWith("index.html") ? "no-store" : "public, max-age=86400"
});
fs.createReadStream(resolved).pipe(res);
});
}
const server = http.createServer((req, res) => {
applyCors(req, res);
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
if (url.pathname === "/api/health") return handleHealth(res);
if (url.pathname === "/api/session") return handleBrowserSession(req, res);
if (url.pathname === "/api/config") return handleConfig(req, res);
if (url.pathname === "/api/http-status") return handleHttpStatus(url, res);
if (url.pathname === "/api/flv-proxy") return handleFlvProxy(url, req, res);
if (url.pathname === "/api/udp-stream") return handleUdpStream(url, req, res);
return serveStatic(url, res);
});
server.on("error", (error) => {
if (error.code === "EADDRINUSE") {
console.error(`端口 ${PORT} 已被占用。请关闭已运行的监控服务,或设置其他端口后重试:`);
console.error("$env:GUIDE_WEB_PORT=8891; npm start");
} else {
console.error(`服务启动失败:${error.message}`);
}
process.exitCode = 1;
});
server.listen(PORT, HOST, () => {
console.log(`停机引导 Web 监控已启动http://127.0.0.1:${PORT}`);
console.log("按 Ctrl+C 停止服务");
});
if (AUTO_EXIT_MS > 0) {
const autoExitTimer = setInterval(() => {
if (activeBrowserSessions > 0) return;
const idleSince = browserSessionSeen ? lastSessionEndedAt : serverStartedAt;
if (Date.now() - idleSince <= AUTO_EXIT_MS) return;
clearInterval(autoExitTimer);
console.log("浏览器页面已关闭,停机引导服务自动退出");
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 2000).unref();
}, Math.min(5000, Math.max(1000, Math.round(AUTO_EXIT_MS / 4))));
autoExitTimer.unref();
}