372 lines
12 KiB
JavaScript
372 lines
12 KiB
JavaScript
"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();
|
||
}
|