diff --git a/helper/server.js b/helper/server.js index 81c853e..fccbd8d 100644 --- a/helper/server.js +++ b/helper/server.js @@ -3,16 +3,27 @@ import express from "express"; import http from "http"; import { WebSocketServer } from "ws"; +const TURN_URLS = String(process.env.TURN_URLS || "") + .split(",") + .map(value => value.trim()) + .filter(Boolean); + +const TURN_USERNAME = String(process.env.TURN_USERNAME || "").trim(); +const TURN_PASSWORD = String(process.env.TURN_PASSWORD || "").trim(); + const PORT = Number(process.env.PORT || 8080); const HOST = process.env.HOST || "0.0.0.0"; + const CALL_HELPER_SHARED_SECRET = String( process.env.CALL_HELPER_SHARED_SECRET || "" ).trim(); + const PUBLIC_BASE = String( process.env.PUBLIC_BASE || `http://127.0.0.1:${PORT}` ).replace(/\/+$/, ""); const HELPER_LOG_LEVEL = String(process.env.HELPER_LOG_LEVEL || "info").toLowerCase(); + const HELPER_DEFAULT_ROUTE_MODE = String( process.env.HELPER_DEFAULT_ROUTE_MODE || "direct" ).toLowerCase(); @@ -24,9 +35,11 @@ const ENABLE_OVERLAY_METADATA = String( const OVERLAY_YGG_ENABLED = String( process.env.OVERLAY_YGG_ENABLED || "0" ) === "1"; + const OVERLAY_YGG_BRIDGE_WSS = String( process.env.OVERLAY_YGG_BRIDGE_WSS || "" ).trim(); + const OVERLAY_YGG_BRIDGE_LABEL = String( process.env.OVERLAY_YGG_BRIDGE_LABEL || "ygg-bridge" ).trim(); @@ -34,9 +47,11 @@ const OVERLAY_YGG_BRIDGE_LABEL = String( const OVERLAY_I2P_ENABLED = String( process.env.OVERLAY_I2P_ENABLED || "0" ) === "1"; + const OVERLAY_I2P_BRIDGE_HTTPS = String( process.env.OVERLAY_I2P_BRIDGE_HTTPS || "" ).trim(); + const OVERLAY_I2P_BRIDGE_LABEL = String( process.env.OVERLAY_I2P_BRIDGE_LABEL || "i2p-bridge" ).trim(); @@ -44,6 +59,15 @@ const OVERLAY_I2P_BRIDGE_LABEL = String( const RELAY_ENABLED = String(process.env.RELAY_ENABLED || "1") === "1"; const RELAY_WSS = String(process.env.RELAY_WSS || "").trim(); +const CORS_ALLOWED_ORIGINS = String( + process.env.CORS_ALLOWED_ORIGINS || "https://angel.33.backend-3.com" +) + .split(",") + .map(value => value.trim()) + .filter(Boolean); + +const allowedOrigins = new Set(CORS_ALLOWED_ORIGINS); + if (!CALL_HELPER_SHARED_SECRET) { console.error("missing CALL_HELPER_SHARED_SECRET"); process.exit(1); @@ -100,8 +124,9 @@ function isoNow() { function log(level, message, meta = {}) { const allowed = ["debug", "info", "warn", "error"]; - const currentIdx = allowed.indexOf(HELPER_LOG_LEVEL); - const entryIdx = allowed.indexOf(level); + const currentIdx = Math.max(0, allowed.indexOf(HELPER_LOG_LEVEL)); + const entryIdx = Math.max(0, allowed.indexOf(level)); + const item = { ts: isoNow(), level, @@ -123,6 +148,31 @@ function log(level, message, meta = {}) { } } +function isAllowedOrigin(origin) { + if (!origin) return false; + return allowedOrigins.has(origin); +} + +function applyCors(req, res) { + const origin = String(req.headers.origin || "").trim(); + if (origin && isAllowedOrigin(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + } +} + +app.use((req, res, next) => { + applyCors(req, res); + + if (req.method === "OPTIONS") { + return res.status(204).end(); + } + + next(); +}); + function b64urlDecode(value) { const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4)); const normalized = value.replace(/-/g, "+").replace(/_/g, "/") + pad; @@ -248,6 +298,7 @@ function buildRouteCandidates(callId) { function buildTransportPolicy(routeMode) { const order = []; + if (routeMode === "overlay_ygg") { order.push("overlay_ygg", "direct", "relay"); } else if (routeMode === "overlay_i2p") { @@ -263,8 +314,27 @@ function buildTransportPolicy(routeMode) { }; } +function getIceServers() { + const servers = [ + { + urls: ["stun:stun.l.google.com:19302"] + } + ]; + + if (TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD) { + servers.push({ + urls: TURN_URLS, + username: TURN_USERNAME, + credential: TURN_PASSWORD + }); + } + + return servers; +} + function getOrCreateCall(callId, room, routeMode = "direct") { let call = calls.get(callId); + if (!call) { call = { callId, @@ -277,6 +347,7 @@ function getOrCreateCall(callId, room, routeMode = "direct") { calls.set(callId, call); log("info", "created call", { callId, room, routeMode }); } + return call; } @@ -338,6 +409,13 @@ function statusSnapshot() { bridge_https: OVERLAY_I2P_BRIDGE_HTTPS || null } }, + turn: { + enabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD), + urls: TURN_URLS + }, + cors: { + allowed_origins: [...allowedOrigins] + }, counts: { active_calls: calls.size, active_tickets: tickets.size @@ -375,11 +453,13 @@ function removeParticipant(callId, peerId, notify = true) { } } -app.get("/healthz", (_req, res) => { +app.get("/healthz", (req, res) => { + applyCors(req, res); res.json(statusSnapshot()); }); -app.get("/status", (_req, res) => { +app.get("/status", (req, res) => { + applyCors(req, res); res.json({ ...statusSnapshot(), calls: [...calls.values()].map(callSnapshot) @@ -387,6 +467,7 @@ app.get("/status", (_req, res) => { }); app.get("/logs", (req, res) => { + applyCors(req, res); const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50))); res.json({ ok: true, @@ -395,6 +476,8 @@ app.get("/logs", (req, res) => { }); app.post("/api/call/session", (req, res) => { + applyCors(req, res); + try { const token = req.body?.token; const payload = verifySignedToken(token); @@ -429,7 +512,9 @@ app.post("/api/call/session", (req, res) => { callId, peerId, username: payload.username, - routeMode + routeMode, + origin: req.headers.origin || null, + turnEnabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD) }); return res.json({ @@ -437,17 +522,14 @@ app.post("/api/call/session", (req, res) => { call_id: callId, peer_id: peerId, ws_url: buildWsUrl(ticket), - iceServers: [ - { - urls: ["stun:stun.l.google.com:19302"] - } - ], + iceServers: getIceServers(), transport_policy: transportPolicy, route_candidates: routeCandidates }); } catch (error) { log("warn", "call session rejected", { - error: error.message + error: error.message, + origin: req.headers.origin || null }); return res.status(401).json({ @@ -458,6 +540,14 @@ app.post("/api/call/session", (req, res) => { }); server.on("upgrade", (req, socket, head) => { + const origin = String(req.headers.origin || "").trim(); + + if (origin && !isAllowedOrigin(origin)) { + log("warn", "websocket upgrade rejected by origin", { origin }); + socket.destroy(); + return; + } + const url = new URL(req.url, `http://${req.headers.host}`); if (url.pathname !== "/ws") { socket.destroy(); @@ -468,6 +558,9 @@ server.on("upgrade", (req, socket, head) => { const ticketData = ticket ? tickets.get(ticket) : null; if (!ticketData || ticketData.exp <= nowSeconds()) { + log("warn", "websocket upgrade rejected by invalid/expired ticket", { + origin: origin || null + }); socket.destroy(); return; } @@ -581,6 +674,7 @@ wss.on("connection", ws => { if (msg.type === "offer" || msg.type === "answer") { const to = String(msg.to || "").trim(); if (!to) return; + const target = call.participants.get(to); if (!target) { log("warn", "signal target missing", { @@ -606,6 +700,7 @@ wss.on("connection", ws => { if (msg.type === "ice") { const to = String(msg.to || "").trim(); if (!to) return; + const target = call.participants.get(to); if (!target) { log("warn", "ice target missing", { @@ -656,6 +751,11 @@ server.listen(PORT, HOST, () => { overlays: { ygg: OVERLAY_YGG_ENABLED, i2p: OVERLAY_I2P_ENABLED - } + }, + turn: { + enabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD), + urls: TURN_URLS + }, + corsAllowedOrigins: [...allowedOrigins] }); }); \ No newline at end of file