"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(); }