commit be88a4b46750c3f8b7bb9d6d299e6abf1bb0f029 Author: admin Date: Thu Apr 23 19:16:36 2026 +0300 feat: initial srt-server v1.0.0 — Node.js hub + React dashboard + Docker diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..29ca903 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Server +PORT=3001 +HOST=0.0.0.0 + +# JWT secret — change this to a long random string in production +JWT_SECRET=change-me-to-a-long-random-string + +# Admin credentials (set on first run, or configure here) +ADMIN_USER=admin +ADMIN_PASS=changeme + +# Data directory (stores agents.json, users.json) +DATA_DIR=./data diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03ce26e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +client/node_modules/ +client/dist/ +data/ +.env +*.log diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..01181f5 --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,13 @@ +# ── Публичный сервер (SSL автоматически через Let's Encrypt) ────────────────── +srt.queo.ru { + reverse_proxy /api/* localhost:3001 + reverse_proxy /ws localhost:3001 + reverse_proxy localhost:3001 +} + +# ── Raspberry Pi / локальная сеть (без SSL) ─────────────────────────────────── +# :3000 { +# reverse_proxy /api/* localhost:3001 +# reverse_proxy /ws localhost:3001 +# reverse_proxy localhost:3001 +# } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..78aed51 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS client-builder +WORKDIR /app/client +COPY client/package*.json ./ +RUN npm install +COPY client/ ./ +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY src/ ./src/ +COPY --from=client-builder /app/client/dist ./client/dist + +ENV PORT=3001 +ENV HOST=0.0.0.0 +ENV DATA_DIR=/data + +VOLUME ["/data"] +EXPOSE 3001 + +CMD ["node", "src/server.js"] diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..e3fb606 --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + SRT Server + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..5298864 --- /dev/null +++ b/client/package.json @@ -0,0 +1,18 @@ +{ + "name": "srt-server-client", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite --port 5174", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.7" + } +} diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..1fefd0e --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,20 @@ +import React, { useState, useEffect } from 'react' +import Login from './pages/Login.jsx' +import Dashboard from './pages/Dashboard.jsx' + +export default function App() { + const [token, setToken] = useState(() => localStorage.getItem('srt_token') || null) + + function handleLogin(jwt) { + localStorage.setItem('srt_token', jwt) + setToken(jwt) + } + + function handleLogout() { + localStorage.removeItem('srt_token') + setToken(null) + } + + if (!token) return + return +} diff --git a/client/src/components/AddAgentModal.jsx b/client/src/components/AddAgentModal.jsx new file mode 100644 index 0000000..19fdf22 --- /dev/null +++ b/client/src/components/AddAgentModal.jsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react' + +export default function AddAgentModal({ onAdd, onClose }) { + const [machineName, setMachineName] = useState('') + const [note, setNote] = useState('') + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(false) + const [copied, setCopied] = useState(false) + + async function handleSubmit(e) { + e.preventDefault() + if (!machineName.trim()) return + setLoading(true) + const data = await onAdd(machineName.trim(), note.trim()) + setResult(data) + setLoading(false) + } + + function copyToken() { + navigator.clipboard.writeText(result.token) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

Register new agent

+ +
+ + {!result ? ( +
+ + setMachineName(e.target.value)} + placeholder="Studio, Office, Mobile unit…" + autoFocus + /> + + setNote(e.target.value)} + placeholder="Location, camera setup…" + /> +
+ + +
+
+ ) : ( +
+
+
+

Agent {result.machineName} registered.

+

Copy this token and paste it into SRT Streamer → Remote → Token:

+
+ {result.token} + +
+

⚠ Save the token — it won't be shown again.

+
+
+ +
+
+ )} +
+
+ ) +} diff --git a/client/src/components/AgentCard.jsx b/client/src/components/AgentCard.jsx new file mode 100644 index 0000000..f63e907 --- /dev/null +++ b/client/src/components/AgentCard.jsx @@ -0,0 +1,107 @@ +import React, { useState } from 'react' + +const STATUS_COLOR = { running: '#22c55e', connecting: '#f59e0b', idle: '#475569', error: '#ef4444' } + +export default function AgentCard({ agent, logs, onCommand, onDelete }) { + const [showLogs, setShowLogs] = useState(false) + const [copied, setCopied] = useState(false) + + const activeStreams = (agent.streams || []).filter(s => s.status === 'running' || s.status === 'connecting') + + function copyToken() { + navigator.clipboard.writeText(agent.token) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + return ( +
+ {/* Header */} +
+
+ + {agent.machineName} + {agent.version && v{agent.version}} +
+
+ {activeStreams.length > 0 && ( + {activeStreams.length} LIVE + )} + +
+
+ + {/* Token */} +
+ {agent.token} + +
+ + {/* Streams */} + {agent.online && (agent.streams || []).length > 0 && ( +
+ {agent.streams.map(s => ( +
+ + {s.id} + {s.status} + {(s.status === 'running' || s.status === 'connecting') && ( + + )} +
+ ))} +
+ )} + + {/* Actions */} + {agent.online && ( +
+ + + +
+ )} + + {!agent.online && ( +
+ Offline{agent.lastSeen ? ` · last seen ${new Date(agent.lastSeen).toLocaleString()}` : ''} +
+ )} + + {/* Logs */} + {showLogs && ( +
+ {logs.length === 0 + ?
No logs yet
+ : logs.slice(-30).map((l, i) => ( +
{l.text}
+ )) + } +
+ )} +
+ ) +} diff --git a/client/src/main.jsx b/client/src/main.jsx new file mode 100644 index 0000000..44951e0 --- /dev/null +++ b/client/src/main.jsx @@ -0,0 +1,6 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './styles/index.css' + +ReactDOM.createRoot(document.getElementById('root')).render() diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx new file mode 100644 index 0000000..9b292f0 --- /dev/null +++ b/client/src/pages/Dashboard.jsx @@ -0,0 +1,182 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react' +import AgentCard from '../components/AgentCard.jsx' +import AddAgentModal from '../components/AddAgentModal.jsx' + +export default function Dashboard({ token, onLogout }) { + const [agents, setAgents] = useState([]) + const [wsStatus, setWsStatus] = useState('connecting') + const [showAdd, setShowAdd] = useState(false) + const [logs, setLogs] = useState([]) // { agentToken, id, text, time } + const wsRef = useRef(null) + + // ─── Connect WebSocket ────────────────────────────────────────────────────── + const connectWs = useCallback(() => { + const proto = location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${location.host}/ws`) + wsRef.current = ws + + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'auth', jwt: token })) + } + + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + handleWsMessage(msg) + } catch {} + } + + ws.onclose = () => { + setWsStatus('disconnected') + setTimeout(connectWs, 3000) + } + + ws.onerror = () => setWsStatus('error') + }, [token]) + + useEffect(() => { + connectWs() + return () => wsRef.current?.close() + }, [connectWs]) + + // Keepalive + useEffect(() => { + const t = setInterval(() => { + if (wsRef.current?.readyState === 1) { + wsRef.current.send(JSON.stringify({ type: 'ping' })) + } + }, 25000) + return () => clearInterval(t) + }, []) + + function handleWsMessage(msg) { + switch (msg.type) { + case 'auth_ok': + setWsStatus('connected') + break + + case 'auth_fail': + setWsStatus('error') + onLogout() + break + + case 'agent_list': + setAgents(msg.agents || []) + break + + case 'agent_online': + setAgents(prev => { + const exists = prev.find(a => a.token === msg.agent.token) + if (exists) return prev.map(a => a.token === msg.agent.token ? { ...a, ...msg.agent, online: true } : a) + return [...prev, msg.agent] + }) + break + + case 'agent_offline': + setAgents(prev => prev.map(a => a.token === msg.token ? { ...a, online: false, streams: [] } : a)) + break + + case 'agent_devices': + setAgents(prev => prev.map(a => a.token === msg.token ? { ...a, devices: msg.devices } : a)) + break + + case 'stream_status': + setAgents(prev => prev.map(a => { + if (a.token !== msg.token) return a + const streams = (a.streams || []).map(s => s.id === msg.id ? { ...s, status: msg.status } : s) + if (!streams.find(s => s.id === msg.id)) streams.push({ id: msg.id, status: msg.status }) + return { ...a, streams } + })) + break + + case 'all_status': + setAgents(prev => prev.map(a => a.token === msg.token ? { ...a, streams: msg.streams || [] } : a)) + break + + case 'log': + setLogs(prev => [...prev.slice(-999), { agentToken: msg.token, id: msg.id, text: msg.text, time: Date.now() }]) + break + } + } + + function sendCmd(agentToken, msg) { + if (wsRef.current?.readyState === 1) { + wsRef.current.send(JSON.stringify({ ...msg, agentToken })) + } + } + + async function handleAddAgent(machineName, note) { + const res = await fetch('/api/agents', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ machineName, note }) + }) + const data = await res.json() + if (res.ok) { + setAgents(prev => [...prev, { token: data.token, machineName: data.machineName, online: false, streams: [], devices: {} }]) + } + return data + } + + async function handleDeleteAgent(agentToken) { + if (!confirm('Отозвать токен агента?')) return + await fetch(`/api/agents/${agentToken}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }) + setAgents(prev => prev.filter(a => a.token !== agentToken)) + } + + const onlineCount = agents.filter(a => a.online).length + + return ( +
+ {/* Header */} +
+
+ + SRT Server + + {wsStatus === 'connected' ? '● Connected' : wsStatus === 'connecting' ? '○ Connecting…' : '✕ Disconnected'} + +
+
+ {onlineCount} / {agents.length} online + + +
+
+ + {/* Agent grid */} +
+ {agents.length === 0 ? ( +
+
📡
+

No agents registered

+

Click + Add Agent to register a new SRT Streamer instance

+ +
+ ) : ( +
+ {agents.map(agent => ( + l.agentToken === agent.token).slice(-50)} + onCommand={(msg) => sendCmd(agent.token, msg)} + onDelete={() => handleDeleteAgent(agent.token)} + /> + ))} +
+ )} +
+ + {showAdd && ( + setShowAdd(false)} + /> + )} +
+ ) +} diff --git a/client/src/pages/Login.jsx b/client/src/pages/Login.jsx new file mode 100644 index 0000000..9a37acf --- /dev/null +++ b/client/src/pages/Login.jsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react' + +export default function Login({ onLogin }) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function handleSubmit(e) { + e.preventDefault() + setError('') + setLoading(true) + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }) + const data = await res.json() + if (!res.ok) { setError(data.error || 'Login failed'); return } + onLogin(data.token) + } catch { + setError('Server unavailable') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+

SRT Server

+

Remote stream management

+
+ setUsername(e.target.value)} + autoFocus + /> + setPassword(e.target.value)} + /> + {error &&
{error}
} + +
+
+
+ ) +} diff --git a/client/src/styles/index.css b/client/src/styles/index.css new file mode 100644 index 0000000..7710f15 --- /dev/null +++ b/client/src/styles/index.css @@ -0,0 +1,133 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f0f1a; + --bg2: #16213e; + --bg3: #1a1a2e; + --border: #1e2a4a; + --accent: #4f8ef7; + --green: #22c55e; + --red: #ef4444; + --orange: #f59e0b; + --text: #e2e8f0; + --text2: #94a3b8; + --text3: #475569; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-size: 13px; + color: var(--text); +} + +html, body, #root { height: 100%; background: var(--bg); } + +/* ── Login ── */ +.login-page { display: flex; align-items: center; justify-content: center; height: 100vh; background: var(--bg); } +.login-card { background: var(--bg3); border: 1px solid var(--border); border-radius: 16px; padding: 40px 36px; width: 360px; text-align: center; } +.login-logo { font-size: 36px; color: var(--accent); margin-bottom: 12px; } +.login-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; } +.login-subtitle { color: var(--text2); font-size: 13px; margin-bottom: 28px; } +.login-form { display: flex; flex-direction: column; gap: 10px; } +.login-input { background: #0d1117; border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 10px 14px; font-size: 13px; outline: none; } +.login-input:focus { border-color: var(--accent); } +.login-error { color: var(--red); font-size: 12px; } +.login-btn { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 10px; font-size: 13px; font-weight: 600; cursor: pointer; margin-top: 4px; } +.login-btn:hover:not(:disabled) { filter: brightness(1.1); } +.login-btn:disabled { opacity: 0.6; cursor: not-allowed; } + +/* ── Dashboard ── */ +.dashboard { display: flex; flex-direction: column; height: 100vh; } + +.dash-header { display: flex; align-items: center; justify-content: space-between; padding: 0 20px; height: 52px; background: var(--bg2); border-bottom: 1px solid var(--border); flex-shrink: 0; gap: 12px; } +.dash-header-left, .dash-header-right { display: flex; align-items: center; gap: 10px; } +.dash-logo { color: var(--accent); font-size: 18px; } +.dash-title { font-size: 15px; font-weight: 700; } + +.ws-badge { font-size: 11px; padding: 2px 8px; border-radius: 8px; font-weight: 600; } +.ws-badge.connected { background: rgba(34,197,94,.15); color: var(--green); } +.ws-badge.connecting { background: rgba(245,158,11,.15); color: var(--orange); } +.ws-badge.disconnected, .ws-badge.error { background: rgba(239,68,68,.15); color: var(--red); } + +.agents-count { font-size: 12px; color: var(--text2); } + +.dash-content { flex: 1; overflow-y: auto; padding: 20px; } + +/* ── Agent grid ── */ +.agents-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; } + +.agent-card { background: var(--bg3); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; } +.agent-card--online { border-color: rgba(34,197,94,.3); } +.agent-card--offline { opacity: 0.65; } + +.agent-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; background: var(--bg2); border-bottom: 1px solid var(--border); } +.agent-header-left, .agent-header-right { display: flex; align-items: center; gap: 8px; } + +.agent-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.dot--green { background: var(--green); box-shadow: 0 0 6px var(--green); } +.dot--gray { background: var(--text3); } + +.agent-name { font-weight: 600; font-size: 13.5px; } +.agent-version { font-size: 10px; color: var(--text3); padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; } + +.agent-live-badge { font-size: 10px; font-weight: 700; background: var(--red); color: #fff; padding: 2px 6px; border-radius: 6px; } + +.agent-token-row { display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-bottom: 1px solid var(--border); } +.agent-token { font-family: monospace; font-size: 10.5px; color: var(--text2); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.agent-streams { padding: 8px 14px; display: flex; flex-direction: column; gap: 4px; border-bottom: 1px solid var(--border); } +.stream-row { display: flex; align-items: center; gap: 6px; font-size: 12px; } +.stream-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } +.stream-id { flex: 1; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.stream-status { color: var(--text3); font-size: 11px; } + +.agent-actions { display: flex; gap: 6px; padding: 10px 14px; flex-wrap: wrap; } +.agent-offline-msg { padding: 10px 14px; font-size: 11.5px; color: var(--text3); } + +.agent-logs { max-height: 140px; overflow-y: auto; padding: 8px 14px; background: #0d1117; border-top: 1px solid var(--border); font-family: monospace; font-size: 10.5px; color: var(--text2); } +.agent-logs .log-line { padding: 1px 0; word-break: break-all; } +.agent-logs .log-empty { color: var(--text3); } + +/* ── Buttons ── */ +.btn { border: none; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500; padding: 5px 12px; transition: filter .15s; } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.btn:not(:disabled):hover { filter: brightness(1.1); } +.btn-primary { background: var(--accent); color: #fff; } +.btn-ghost { background: transparent; color: var(--text2); border: 1px solid var(--border); } +.btn-ghost:hover { color: var(--text); border-color: #2d3f6a; } +.btn-danger { background: var(--red); color: #fff; } +.btn-stop { background: rgba(239,68,68,.15); color: var(--red); border: 1px solid rgba(239,68,68,.3); } +.btn-sm { padding: 3px 10px; font-size: 11.5px; background: transparent; border: 1px solid var(--border); color: var(--text2); } +.btn-sm:not(:disabled):hover { border-color: var(--accent); color: var(--accent); } +.btn-sm.btn-active { background: rgba(79,142,247,.15); border-color: var(--accent); color: var(--accent); } +.btn-xs { padding: 2px 7px; font-size: 11px; background: transparent; border: 1px solid var(--border); color: var(--text3); border-radius: 4px; cursor: pointer; } +.btn-xs:hover { color: var(--text); } +.btn-icon { background: transparent; border: none; color: var(--text3); cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; } +.btn-icon:hover { color: var(--red); } + +/* ── Empty state ── */ +.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 80px 20px; text-align: center; color: var(--text2); } +.empty-icon { font-size: 48px; } +.empty-state h2 { font-size: 18px; color: var(--text); } +.empty-state p { color: var(--text2); } + +/* ── Modal ── */ +.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 100; } +.modal { background: var(--bg3); border: 1px solid var(--border); border-radius: 14px; width: 440px; max-width: 95vw; } +.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); } +.modal-header h2 { font-size: 15px; font-weight: 600; } +.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 8px; } +.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } + +.field-label { font-size: 11.5px; color: var(--text2); display: block; margin-bottom: 4px; } +.field-input { width: 100%; background: #0d1117; border: 1px solid var(--border); color: var(--text); border-radius: 7px; padding: 8px 12px; font-size: 13px; outline: none; } +.field-input:focus { border-color: var(--accent); } +.req { color: var(--red); } + +/* Token result */ +.token-result { display: flex; flex-direction: column; gap: 8px; } +.token-result-icon { font-size: 28px; } +.token-hint { font-size: 12px; color: var(--text2); } +.token-warn { font-size: 11.5px; color: var(--orange); } +.token-display-row { display: flex; gap: 8px; align-items: center; } +.token-display { font-family: monospace; font-size: 11px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; flex: 1; word-break: break-all; color: #7dd3fc; } + +/* Scrollbar */ +* { scrollbar-width: thin; scrollbar-color: #1e2a4a transparent; } diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 0000000..da9918a --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:3001', + '/ws': { target: 'ws://localhost:3001', ws: true } + } + } +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..290404a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + srt-server: + build: . + restart: unless-stopped + ports: + - "3001:3001" + volumes: + - srt-data:/data + environment: + - PORT=3001 + - JWT_SECRET=${JWT_SECRET:-change-me-in-production} + - ADMIN_USER=${ADMIN_USER:-admin} + - ADMIN_PASS=${ADMIN_PASS:-changeme} + - DATA_DIR=/data + +volumes: + srt-data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..2e6e081 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "srt-server", + "version": "1.0.0", + "description": "Central management server for SRT Streamer agents", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js", + "build:client": "cd client && npm install && npm run build", + "setup": "npm install && npm run build:client", + "docker:build": "docker build -t srt-server .", + "docker:run": "docker-compose up -d" + }, + "dependencies": { + "express": "^4.19.2", + "ws": "^8.18.0", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "dotenv": "^16.4.5", + "cors": "^2.8.5", + "uuid": "^10.0.0" + }, + "devDependencies": { + "nodemon": "^3.1.4" + } +} diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..47af1fd --- /dev/null +++ b/src/api.js @@ -0,0 +1,109 @@ +'use strict' +const express = require('express') +const { v4: uuidv4 } = require('uuid') +const { login, requireAuth, changePassword } = require('./auth') +const { loadAgents, saveAgents } = require('./config') +const hub = require('./hub') + +const router = express.Router() + +// ─── Auth ───────────────────────────────────────────────────────────────────── +router.post('/auth/login', (req, res) => { + const { username, password } = req.body + if (!username || !password) return res.status(400).json({ error: 'Missing credentials' }) + const token = login(username, password) + if (!token) return res.status(401).json({ error: 'Invalid username or password' }) + res.json({ token }) +}) + +router.post('/auth/change-password', requireAuth, (req, res) => { + const { newPassword } = req.body + if (!newPassword || newPassword.length < 6) + return res.status(400).json({ error: 'Password must be at least 6 characters' }) + changePassword(req.user.username, newPassword) + res.json({ ok: true }) +}) + +// ─── Agents ─────────────────────────────────────────────────────────────────── +// List all registered agents (with online status) +router.get('/agents', requireAuth, (req, res) => { + const agents = loadAgents() + const list = Object.entries(agents).map(([token, meta]) => { + const live = hub.agents.get(token) + return { + token, + machineName: meta.machineName || token.slice(0, 8), + note: meta.note || '', + online: !!live, + version: meta.version || '', + connectedAt: meta.connectedAt, + lastSeen: meta.lastSeen, + streams: live?.streams || [], + } + }) + res.json(list) +}) + +// Register new agent — generates a token to paste into SRT Streamer +router.post('/agents', requireAuth, (req, res) => { + const { machineName, note } = req.body + if (!machineName) return res.status(400).json({ error: 'machineName required' }) + const token = uuidv4() + const agents = loadAgents() + agents[token] = { + machineName, + note: note || '', + createdAt: new Date().toISOString(), + online: false + } + saveAgents(agents) + res.json({ token, machineName }) +}) + +// Update agent meta (name, note) +router.patch('/agents/:token', requireAuth, (req, res) => { + const agents = loadAgents() + if (!agents[req.params.token]) return res.status(404).json({ error: 'Not found' }) + const { machineName, note } = req.body + if (machineName) agents[req.params.token].machineName = machineName + if (note !== undefined) agents[req.params.token].note = note + saveAgents(agents) + res.json({ ok: true }) +}) + +// Delete / revoke agent +router.delete('/agents/:token', requireAuth, (req, res) => { + const agents = loadAgents() + if (!agents[req.params.token]) return res.status(404).json({ error: 'Not found' }) + delete agents[req.params.token] + saveAgents(agents) + res.json({ ok: true }) +}) + +// Get devices of a specific agent +router.get('/agents/:token/devices', requireAuth, (req, res) => { + const live = hub.agents.get(req.params.token) + if (!live) return res.status(404).json({ error: 'Agent not connected' }) + res.json(live.devices || {}) +}) + +// ─── Stream commands (proxied to agent via hub) ─────────────────────────────── +router.post('/agents/:token/start', requireAuth, (req, res) => { + const live = hub.agents.get(req.params.token) + if (!live) return res.status(404).json({ error: 'Agent not connected' }) + hub._sendToAgent(req.params.token, { type: 'start_stream', config: req.body }) + res.json({ ok: true }) +}) + +router.post('/agents/:token/stop', requireAuth, (req, res) => { + const { streamId } = req.body + hub._sendToAgent(req.params.token, { type: 'stop_stream', id: streamId }) + res.json({ ok: true }) +}) + +router.post('/agents/:token/stop-all', requireAuth, (req, res) => { + hub._sendToAgent(req.params.token, { type: 'stop_all' }) + res.json({ ok: true }) +}) + +module.exports = router diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..2401a21 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,63 @@ +'use strict' +const jwt = require('jsonwebtoken') +const bcrypt = require('bcryptjs') +const { loadUsers, saveUsers } = require('./config') + +const SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-prod' + +// ─── Ensure default admin exists on first run ───────────────────────────────── +function ensureDefaultAdmin() { + const users = loadUsers() + if (Object.keys(users).length === 0) { + const adminUser = process.env.ADMIN_USER || 'admin' + const adminPass = process.env.ADMIN_PASS || 'changeme' + users[adminUser] = { + passwordHash: bcrypt.hashSync(adminPass, 10), + role: 'admin', + createdAt: new Date().toISOString() + } + saveUsers(users) + console.log(`[Auth] Created default admin: ${adminUser} / ${adminPass}`) + console.log('[Auth] ⚠️ Change the password after first login!') + } +} + +// ─── Verify login ───────────────────────────────────────────────────────────── +function login(username, password) { + const users = loadUsers() + const user = users[username] + if (!user) return null + if (!bcrypt.compareSync(password, user.passwordHash)) return null + const token = jwt.sign({ username, role: user.role }, SECRET, { expiresIn: '7d' }) + return token +} + +// ─── Change password ────────────────────────────────────────────────────────── +function changePassword(username, newPassword) { + const users = loadUsers() + if (!users[username]) return false + users[username].passwordHash = bcrypt.hashSync(newPassword, 10) + saveUsers(users) + return true +} + +// ─── Middleware: verify JWT from Authorization header ───────────────────────── +function requireAuth(req, res, next) { + const header = req.headers.authorization || '' + const token = header.startsWith('Bearer ') ? header.slice(7) : null + if (!token) return res.status(401).json({ error: 'Unauthorized' }) + try { + req.user = jwt.verify(token, SECRET) + next() + } catch { + res.status(401).json({ error: 'Invalid token' }) + } +} + +// ─── Verify JWT for WebSocket connections ──────────────────────────────────── +function verifyJwt(token) { + try { return jwt.verify(token, SECRET) } + catch { return null } +} + +module.exports = { ensureDefaultAdmin, login, changePassword, requireAuth, verifyJwt } diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..d89d7ac --- /dev/null +++ b/src/config.js @@ -0,0 +1,33 @@ +'use strict' +const fs = require('fs') +const path = require('path') + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data') + +function ensureDir() { + if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) +} + +function load(file, defaultVal = {}) { + ensureDir() + const p = path.join(DATA_DIR, file) + try { + if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8')) + } catch {} + return defaultVal +} + +function save(file, data) { + ensureDir() + fs.writeFileSync(path.join(DATA_DIR, file), JSON.stringify(data, null, 2), 'utf8') +} + +// ─── Registered agents (tokens → meta) ─────────────────────────────────────── +function loadAgents() { return load('agents.json', {}) } +function saveAgents(agents) { save('agents.json', agents) } + +// ─── Admin users ────────────────────────────────────────────────────────────── +function loadUsers() { return load('users.json', {}) } +function saveUsers(users) { save('users.json', users) } + +module.exports = { loadAgents, saveAgents, loadUsers, saveUsers } diff --git a/src/hub.js b/src/hub.js new file mode 100644 index 0000000..ccd83e0 --- /dev/null +++ b/src/hub.js @@ -0,0 +1,215 @@ +'use strict' +const { EventEmitter } = require('events') +const { loadAgents, saveAgents } = require('./config') +const { verifyJwt } = require('./auth') + +// ─── Hub — центральная шина между агентами и браузерами ─────────────────────── +class Hub extends EventEmitter { + constructor() { + super() + // Активные WS соединения + this.agents = new Map() // token → { ws, meta, devices, streams } + this.browsers = new Set() // ws connections от авторизованных браузеров + } + + // ─── Входящее WS соединение ────────────────────────────────────────────── + handleConnection(ws, req) { + let authenticated = false + let connType = null // 'agent' | 'browser' + let agentToken = null + + const authTimeout = setTimeout(() => { + if (!authenticated) ws.terminate() + }, 10000) + + ws.on('message', (raw) => { + let msg + try { msg = JSON.parse(raw) } catch { return } + + // ── Первое сообщение — авторизация ────────────────────────────────── + if (!authenticated) { + if (msg.type === 'auth' && msg.token) { + // Агент подключается с токеном + const agents = loadAgents() + if (!agents[msg.token]) { + this._send(ws, { type: 'auth_fail', reason: 'Unknown token. Register agent first.' }) + ws.terminate() + return + } + clearTimeout(authTimeout) + authenticated = true + connType = 'agent' + agentToken = msg.token + + // Сохраняем соединение + const meta = agents[msg.token] + meta.machineName = msg.machineName || meta.machineName + meta.version = msg.version || '' + meta.connectedAt = new Date().toISOString() + meta.online = true + agents[msg.token] = meta + saveAgents(agents) + + this.agents.set(msg.token, { ws, meta, devices: {}, streams: [] }) + this._send(ws, { type: 'auth_ok', serverName: 'SRT Server' }) + + // Запрашиваем список устройств + this._send(ws, { type: 'get_devices' }) + + // Оповещаем браузеры + this._broadcastBrowsers({ type: 'agent_online', agent: this._agentInfo(msg.token) }) + console.log(`[Hub] Agent connected: ${meta.machineName} (${msg.token.slice(0, 8)}…)`) + + } else if (msg.type === 'auth' && msg.jwt) { + // Браузер подключается с JWT + const user = verifyJwt(msg.jwt) + if (!user) { + this._send(ws, { type: 'auth_fail', reason: 'Invalid session' }) + ws.terminate() + return + } + clearTimeout(authTimeout) + authenticated = true + connType = 'browser' + + this.browsers.add(ws) + this._send(ws, { type: 'auth_ok' }) + + // Отправляем текущий список агентов + this._send(ws, { type: 'agent_list', agents: this._allAgentInfos() }) + console.log(`[Hub] Browser connected: ${user.username}`) + } else { + ws.terminate() + } + return + } + + // ── Авторизованные сообщения ───────────────────────────────────────── + if (connType === 'agent') { + this._handleAgentMessage(agentToken, msg) + } else if (connType === 'browser') { + this._handleBrowserMessage(ws, msg) + } + }) + + ws.on('close', () => { + clearTimeout(authTimeout) + if (connType === 'agent' && agentToken) { + this.agents.delete(agentToken) + // Помечаем offline в хранилище + const agents = loadAgents() + if (agents[agentToken]) { + agents[agentToken].online = false + agents[agentToken].lastSeen = new Date().toISOString() + saveAgents(agents) + } + this._broadcastBrowsers({ type: 'agent_offline', token: agentToken }) + console.log(`[Hub] Agent disconnected: ${agentToken.slice(0, 8)}…`) + } else if (connType === 'browser') { + this.browsers.delete(ws) + } + }) + + ws.on('error', () => {}) + } + + // ─── Сообщения от агентов ──────────────────────────────────────────────── + _handleAgentMessage(token, msg) { + const agent = this.agents.get(token) + if (!agent) return + + switch (msg.type) { + case 'devices': + agent.devices = msg.devices + this._broadcastBrowsers({ type: 'agent_devices', token, devices: msg.devices }) + break + + case 'stream_status': + // Обновляем локальный список потоков + const stream = (agent.streams || []).find(s => s.id === msg.id) + if (stream) stream.status = msg.status + else agent.streams = [...(agent.streams || []), { id: msg.id, status: msg.status }] + this._broadcastBrowsers({ type: 'stream_status', token, id: msg.id, status: msg.status }) + break + + case 'all_status': + agent.streams = msg.streams || [] + this._broadcastBrowsers({ type: 'all_status', token, streams: msg.streams }) + break + + case 'log': + // Пробрасываем логи в браузеры + this._broadcastBrowsers({ type: 'log', token, id: msg.id, text: msg.text }) + break + + case 'ping': + this._send(agent.ws, { type: 'pong' }) + break + } + } + + // ─── Команды от браузеров ──────────────────────────────────────────────── + _handleBrowserMessage(browserWs, msg) { + const { agentToken } = msg + + switch (msg.type) { + case 'start_stream': + this._sendToAgent(agentToken, { type: 'start_stream', config: msg.config }) + break + case 'stop_stream': + this._sendToAgent(agentToken, { type: 'stop_stream', id: msg.id }) + break + case 'update_stream': + this._sendToAgent(agentToken, { type: 'update_stream', id: msg.id, changes: msg.changes }) + break + case 'stop_all': + this._sendToAgent(agentToken, { type: 'stop_all' }) + break + case 'get_devices': + this._sendToAgent(agentToken, { type: 'get_devices' }) + break + case 'ping': + this._send(browserWs, { type: 'pong' }) + break + } + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + _send(ws, obj) { + try { + if (ws.readyState === 1) ws.send(JSON.stringify(obj)) + } catch {} + } + + _sendToAgent(token, msg) { + const agent = this.agents.get(token) + if (agent) this._send(agent.ws, msg) + } + + _broadcastBrowsers(msg) { + for (const ws of this.browsers) this._send(ws, msg) + } + + _agentInfo(token) { + const stored = loadAgents()[token] || {} + const live = this.agents.get(token) + return { + token, + machineName: stored.machineName || token.slice(0, 8), + version: stored.version || '', + online: !!live, + connectedAt: stored.connectedAt, + lastSeen: stored.lastSeen, + devices: live?.devices || {}, + streams: live?.streams || [], + note: stored.note || '' + } + } + + _allAgentInfos() { + const agents = loadAgents() + return Object.keys(agents).map(t => this._agentInfo(t)) + } +} + +module.exports = new Hub() diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..f1dd6ac --- /dev/null +++ b/src/server.js @@ -0,0 +1,50 @@ +'use strict' +require('dotenv').config() + +const express = require('express') +const http = require('http') +const path = require('path') +const { WebSocketServer } = require('ws') + +const { ensureDefaultAdmin } = require('./auth') +const api = require('./api') +const hub = require('./hub') + +const PORT = parseInt(process.env.PORT) || 3001 +const HOST = process.env.HOST || '0.0.0.0' + +const app = express() + +// ─── Middleware ──────────────────────────────────────────────────────────────── +app.use(require('cors')()) +app.use(express.json()) + +// ─── API routes ─────────────────────────────────────────────────────────────── +app.use('/api', api) + +// ─── Serve React client (built) ─────────────────────────────────────────────── +const clientDist = path.join(__dirname, '..', 'client', 'dist') +app.use(express.static(clientDist)) +app.get('*', (req, res) => { + res.sendFile(path.join(clientDist, 'index.html'), (err) => { + if (err) res.status(200).send('

SRT Server — run npm run build:client to build the UI

') + }) +}) + +// ─── HTTP + WebSocket server ────────────────────────────────────────────────── +const server = http.createServer(app) + +const wss = new WebSocketServer({ server, path: '/ws' }) +wss.on('connection', (ws, req) => hub.handleConnection(ws, req)) + +// ─── Start ──────────────────────────────────────────────────────────────────── +ensureDefaultAdmin() + +server.listen(PORT, HOST, () => { + console.log(`╔═══════════════════════════════════════╗`) + console.log(`║ SRT Server v1.0.0 ║`) + console.log(`╠═══════════════════════════════════════╣`) + console.log(`║ HTTP → http://${HOST}:${PORT}`.padEnd(42) + `║`) + console.log(`║ WS → ws://${HOST}:${PORT}/ws`.padEnd(42) + `║`) + console.log(`╚═══════════════════════════════════════╝`) +})