- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark, Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI) - aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource, an aether-core gekoppelt (Rechte über aether_users.admin_group), mit WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU) - Prototypen admin/ vervollständigt - SQL-Schema aether_admin.sql - Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
7.8 KiB
JavaScript
199 lines
7.8 KiB
JavaScript
/* =====================================================================
|
|
aether-admin — Media-Server (WebRTC-SFU + Signaling)
|
|
|
|
Zwei Ohren:
|
|
• HTTP (GRANT_PORT) — nur für den FiveM-Server. Der stellt hier
|
|
kurzlebige Zugangstoken aus (/grant) und räumt Räume ab (/revoke),
|
|
abgesichert über das gemeinsame Geheimnis ADMIN_SECRET.
|
|
• WebSocket (WS_PORT) — für die NUIs. Publisher senden ihren Stream,
|
|
Viewer empfangen ihn. Der Server routet die Medien (SFU): er nimmt
|
|
die RTP-Pakete eines Publishers und schreibt sie in Ausgangs-Tracks,
|
|
die an alle Viewer desselben Raums gehen.
|
|
|
|
Signaling-Protokoll (JSON über WS), passend zu html/js/webrtc.js:
|
|
Client → Server: { t:"join", token }
|
|
{ t:"offer", sdp } (Publisher ist Offerer)
|
|
{ t:"answer", sdp } (Viewer antwortet auf Server-Offer)
|
|
{ t:"ice", candidate }
|
|
Server → Client: { t:"ready" } (Publisher darf sein Offer senden)
|
|
{ t:"offer", sdp } (Server offeriert dem Viewer)
|
|
{ t:"answer", sdp } (Antwort an den Publisher)
|
|
{ t:"ice", candidate }
|
|
|
|
HINWEIS: WebRTC-SFUs lassen sich nur im laufenden Betrieb sinnvoll
|
|
prüfen. Dieser Server ist die Referenzimplementierung — je nach
|
|
werift-Version können Kleinigkeiten anzupassen sein (siehe README).
|
|
===================================================================== */
|
|
"use strict";
|
|
|
|
const http = require("http");
|
|
const { WebSocketServer } = require("ws");
|
|
const { RTCPeerConnection, MediaStreamTrack } = require("werift");
|
|
|
|
const WS_PORT = parseInt(process.env.AETHER_MEDIA_WS_PORT || "8787", 10);
|
|
const GRANT_PORT = parseInt(process.env.AETHER_MEDIA_HTTP_PORT || "8788", 10);
|
|
const ADMIN_SECRET = process.env.AETHER_MEDIA_SECRET || "";
|
|
const STUN = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
|
|
if (!ADMIN_SECRET) {
|
|
console.warn("[media] WARNUNG: AETHER_MEDIA_SECRET ist leer — /grant lehnt alles ab.");
|
|
}
|
|
|
|
/* ---- Zustand ------------------------------------------------------- */
|
|
// token -> { raum, rolle, exp }
|
|
const grants = new Map();
|
|
// raum -> { publishers:Set, viewers:Set, tracks:MediaStreamTrack[] }
|
|
const raeume = new Map();
|
|
|
|
function raumHolen(id) {
|
|
if (!raeume.has(id)) raeume.set(id, { publishers: new Set(), viewers: new Set(), tracks: [] });
|
|
return raeume.get(id);
|
|
}
|
|
|
|
// Abgelaufene Token regelmäßig entfernen
|
|
setInterval(() => {
|
|
const jetzt = Date.now();
|
|
for (const [t, g] of grants) if (g.exp < jetzt) grants.delete(t);
|
|
}, 10000);
|
|
|
|
/* =====================================================================
|
|
HTTP: Token-Ausgabe (nur vom FiveM-Server)
|
|
===================================================================== */
|
|
const httpServer = http.createServer((req, res) => {
|
|
if (req.method !== "POST") { res.writeHead(405).end(); return; }
|
|
if (req.headers["x-admin-secret"] !== ADMIN_SECRET || !ADMIN_SECRET) {
|
|
res.writeHead(403).end('{"ok":false}'); return;
|
|
}
|
|
|
|
let body = "";
|
|
req.on("data", (c) => { body += c; if (body.length > 1e5) req.destroy(); });
|
|
req.on("end", () => {
|
|
let daten = {};
|
|
try { daten = JSON.parse(body || "{}"); } catch (e) {}
|
|
|
|
if (req.url === "/grant") {
|
|
const gueltig = Math.max(5, Math.min(300, +daten.gueltigSek || 30));
|
|
grants.set(daten.token, {
|
|
raum: String(daten.raum || ""),
|
|
rolle: daten.rolle === "publisher" ? "publisher" : "viewer",
|
|
exp: Date.now() + gueltig * 1000,
|
|
});
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}');
|
|
} else if (req.url === "/revoke") {
|
|
raumAufloesen(String(daten.raum || ""));
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}');
|
|
} else {
|
|
res.writeHead(404).end('{"ok":false}');
|
|
}
|
|
});
|
|
});
|
|
httpServer.listen(GRANT_PORT, () => console.log(`[media] HTTP /grant auf :${GRANT_PORT}`));
|
|
|
|
/* =====================================================================
|
|
WebSocket: Signaling + Medien
|
|
===================================================================== */
|
|
const wss = new WebSocketServer({ port: WS_PORT });
|
|
wss.on("listening", () => console.log(`[media] WebSocket auf :${WS_PORT}`));
|
|
|
|
wss.on("connection", (ws) => {
|
|
let sitzung = null; // wird nach gültigem join gesetzt
|
|
|
|
ws.on("message", async (roh) => {
|
|
let m; try { m = JSON.parse(roh.toString()); } catch (e) { return; }
|
|
|
|
// Erster Schritt: Beitritt mit gültigem Token
|
|
if (!sitzung) {
|
|
if (m.t !== "join") return;
|
|
const g = grants.get(m.token);
|
|
if (!g || g.exp < Date.now()) { ws.close(4001, "ungueltiges Token"); return; }
|
|
grants.delete(m.token); // Einmal-Token
|
|
|
|
const raum = raumHolen(g.raum);
|
|
const pc = new RTCPeerConnection({ iceServers: STUN });
|
|
sitzung = { rolle: g.rolle, raum: g.raum, pc, ws };
|
|
|
|
pc.onIceCandidate.subscribe((candidate) => {
|
|
sende(ws, { t: "ice", candidate: alsJson(candidate) });
|
|
});
|
|
|
|
if (g.rolle === "publisher") {
|
|
raum.publishers.add(sitzung);
|
|
// Publisher ist Offerer → wir bitten ihn, sein Offer zu schicken
|
|
pc.onTrack.subscribe((track) => publisherTrack(g.raum, track));
|
|
sende(ws, { t: "ready" });
|
|
} else {
|
|
raum.viewers.add(sitzung);
|
|
// Viewer bekommt alle bereits laufenden Tracks und ein Offer
|
|
for (const track of raum.tracks) pc.addTransceiver(track, { direction: "sendonly" });
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
sende(ws, { t: "offer", sdp: pc.localDescription });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Laufende Verhandlung
|
|
const pc = sitzung.pc;
|
|
if (m.t === "offer") { // von einem Publisher
|
|
await pc.setRemoteDescription(m.sdp);
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
sende(ws, { t: "answer", sdp: pc.localDescription });
|
|
} else if (m.t === "answer") { // von einem Viewer
|
|
await pc.setRemoteDescription(m.sdp);
|
|
} else if (m.t === "ice" && m.candidate) {
|
|
try { await pc.addIceCandidate(m.candidate); } catch (e) {}
|
|
}
|
|
});
|
|
|
|
ws.on("close", () => {
|
|
if (!sitzung) return;
|
|
const raum = raeume.get(sitzung.raum);
|
|
try { sitzung.pc.close(); } catch (e) {}
|
|
if (raum) { raum.publishers.delete(sitzung); raum.viewers.delete(sitzung); }
|
|
});
|
|
});
|
|
|
|
/* ---- Ein Publisher-Track kam an → an alle Viewer weiterreichen ----- */
|
|
async function publisherTrack(raumId, track) {
|
|
const raum = raeume.get(raumId);
|
|
if (!raum) return;
|
|
|
|
// Ausgangs-Track, in den wir die RTP-Pakete des Publishers schreiben
|
|
const fwd = new MediaStreamTrack({ kind: track.kind });
|
|
track.onReceiveRtp.subscribe((rtp) => { try { fwd.writeRtp(rtp); } catch (e) {} });
|
|
raum.tracks.push(fwd);
|
|
|
|
// Jedem laufenden Viewer den neuen Track zusätzlich anbieten (Renegotiation)
|
|
for (const viewer of raum.viewers) {
|
|
try {
|
|
viewer.pc.addTransceiver(fwd, { direction: "sendonly" });
|
|
const offer = await viewer.pc.createOffer();
|
|
await viewer.pc.setLocalDescription(offer);
|
|
sende(viewer.ws, { t: "offer", sdp: viewer.pc.localDescription });
|
|
} catch (e) { /* Viewer könnte gerade weg sein */ }
|
|
}
|
|
}
|
|
|
|
function raumAufloesen(raumId) {
|
|
const raum = raeume.get(raumId);
|
|
if (!raum) return;
|
|
for (const s of raum.publishers) try { s.pc.close(); } catch (e) {}
|
|
for (const s of raum.viewers) try { s.pc.close(); } catch (e) {}
|
|
raeume.delete(raumId);
|
|
}
|
|
|
|
/* ---- Kleine Helfer ------------------------------------------------- */
|
|
function sende(ws, obj) {
|
|
if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
|
|
}
|
|
function alsJson(candidate) {
|
|
if (!candidate) return null;
|
|
if (typeof candidate.toJSON === "function") return candidate.toJSON();
|
|
return {
|
|
candidate: candidate.candidate,
|
|
sdpMid: candidate.sdpMid,
|
|
sdpMLineIndex: candidate.sdpMLineIndex,
|
|
};
|
|
}
|