feat: initial srt-server v1.0.0 — Node.js hub + React dashboard + Docker

This commit is contained in:
admin
2026-04-23 19:16:36 +03:00
commit be88a4b467
21 changed files with 1197 additions and 0 deletions
+13
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
node_modules/
client/node_modules/
client/dist/
data/
.env
*.log
+13
View File
@@ -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
# }
+22
View File
@@ -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"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SRT Server</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+18
View File
@@ -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"
}
}
+20
View File
@@ -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 <Login onLogin={handleLogin} />
return <Dashboard token={token} onLogout={handleLogout} />
}
+81
View File
@@ -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 (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal">
<div className="modal-header">
<h2>Register new agent</h2>
<button className="btn-icon" onClick={onClose}></button>
</div>
{!result ? (
<form onSubmit={handleSubmit} className="modal-body">
<label className="field-label">Machine name <span className="req">*</span></label>
<input
className="field-input"
type="text"
value={machineName}
onChange={e => setMachineName(e.target.value)}
placeholder="Studio, Office, Mobile unit…"
autoFocus
/>
<label className="field-label" style={{ marginTop: 12 }}>Note (optional)</label>
<input
className="field-input"
type="text"
value={note}
onChange={e => setNote(e.target.value)}
placeholder="Location, camera setup…"
/>
<div className="modal-actions">
<button className="btn btn-ghost" type="button" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" type="submit" disabled={loading || !machineName.trim()}>
{loading ? 'Creating…' : 'Create'}
</button>
</div>
</form>
) : (
<div className="modal-body">
<div className="token-result">
<div className="token-result-icon"></div>
<p>Agent <strong>{result.machineName}</strong> registered.</p>
<p className="token-hint">Copy this token and paste it into <strong>SRT Streamer Remote Token</strong>:</p>
<div className="token-display-row">
<code className="token-display">{result.token}</code>
<button className="btn btn-primary" onClick={copyToken}>
{copied ? '✓ Copied!' : '📋 Copy'}
</button>
</div>
<p className="token-warn"> Save the token it won't be shown again.</p>
</div>
<div className="modal-actions">
<button className="btn btn-primary" onClick={onClose}>Done</button>
</div>
</div>
)}
</div>
</div>
)
}
+107
View File
@@ -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 (
<div className={`agent-card ${agent.online ? 'agent-card--online' : 'agent-card--offline'}`}>
{/* Header */}
<div className="agent-header">
<div className="agent-header-left">
<span className={`agent-dot ${agent.online ? 'dot--green' : 'dot--gray'}`} />
<span className="agent-name">{agent.machineName}</span>
{agent.version && <span className="agent-version">v{agent.version}</span>}
</div>
<div className="agent-header-right">
{activeStreams.length > 0 && (
<span className="agent-live-badge">{activeStreams.length} LIVE</span>
)}
<button className="btn-icon" onClick={onDelete} title="Revoke agent"></button>
</div>
</div>
{/* Token */}
<div className="agent-token-row">
<code className="agent-token">{agent.token}</code>
<button className="btn btn-xs" onClick={copyToken}>
{copied ? '✓' : '📋'}
</button>
</div>
{/* Streams */}
{agent.online && (agent.streams || []).length > 0 && (
<div className="agent-streams">
{agent.streams.map(s => (
<div key={s.id} className="stream-row">
<span className="stream-dot" style={{ background: STATUS_COLOR[s.status] || '#475569' }} />
<span className="stream-id">{s.id}</span>
<span className="stream-status">{s.status}</span>
{(s.status === 'running' || s.status === 'connecting') && (
<button
className="btn btn-xs btn-stop"
onClick={() => onCommand({ type: 'stop_stream', id: s.id })}
>
Stop
</button>
)}
</div>
))}
</div>
)}
{/* Actions */}
{agent.online && (
<div className="agent-actions">
<button
className="btn btn-sm btn-danger"
onClick={() => onCommand({ type: 'stop_all' })}
disabled={activeStreams.length === 0}
>
Stop All
</button>
<button
className="btn btn-sm"
onClick={() => onCommand({ type: 'get_devices' })}
>
Devices
</button>
<button
className={`btn btn-sm ${showLogs ? 'btn-active' : ''}`}
onClick={() => setShowLogs(v => !v)}
>
Logs {logs.length > 0 && `(${logs.length})`}
</button>
</div>
)}
{!agent.online && (
<div className="agent-offline-msg">
Offline{agent.lastSeen ? ` · last seen ${new Date(agent.lastSeen).toLocaleString()}` : ''}
</div>
)}
{/* Logs */}
{showLogs && (
<div className="agent-logs">
{logs.length === 0
? <div className="log-empty">No logs yet</div>
: logs.slice(-30).map((l, i) => (
<div key={i} className="log-line">{l.text}</div>
))
}
</div>
)}
</div>
)
}
+6
View File
@@ -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(<App />)
+182
View File
@@ -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 (
<div className="dashboard">
{/* Header */}
<header className="dash-header">
<div className="dash-header-left">
<span className="dash-logo"></span>
<span className="dash-title">SRT Server</span>
<span className={`ws-badge ${wsStatus}`}>
{wsStatus === 'connected' ? '● Connected' : wsStatus === 'connecting' ? '○ Connecting…' : '✕ Disconnected'}
</span>
</div>
<div className="dash-header-right">
<span className="agents-count">{onlineCount} / {agents.length} online</span>
<button className="btn btn-primary" onClick={() => setShowAdd(true)}>+ Add Agent</button>
<button className="btn btn-ghost" onClick={onLogout}>Logout</button>
</div>
</header>
{/* Agent grid */}
<main className="dash-content">
{agents.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">📡</div>
<h2>No agents registered</h2>
<p>Click <strong>+ Add Agent</strong> to register a new SRT Streamer instance</p>
<button className="btn btn-primary" onClick={() => setShowAdd(true)}>+ Add Agent</button>
</div>
) : (
<div className="agents-grid">
{agents.map(agent => (
<AgentCard
key={agent.token}
agent={agent}
logs={logs.filter(l => l.agentToken === agent.token).slice(-50)}
onCommand={(msg) => sendCmd(agent.token, msg)}
onDelete={() => handleDeleteAgent(agent.token)}
/>
))}
</div>
)}
</main>
{showAdd && (
<AddAgentModal
onAdd={handleAddAgent}
onClose={() => setShowAdd(false)}
/>
)}
</div>
)
}
+59
View File
@@ -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 (
<div className="login-page">
<div className="login-card">
<div className="login-logo"></div>
<h1 className="login-title">SRT Server</h1>
<p className="login-subtitle">Remote stream management</p>
<form className="login-form" onSubmit={handleSubmit}>
<input
className="login-input"
type="text"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
/>
<input
className="login-input"
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
{error && <div className="login-error">{error}</div>}
<button className="login-btn" type="submit" disabled={loading}>
{loading ? 'Logging in…' : 'Login'}
</button>
</form>
</div>
</div>
)
}
+133
View File
@@ -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; }
+12
View File
@@ -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 }
}
}
})
+17
View File
@@ -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:
+26
View File
@@ -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"
}
}
+109
View File
@@ -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
+63
View File
@@ -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 }
+33
View File
@@ -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 }
+215
View File
@@ -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()
+50
View File
@@ -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('<h2>SRT Server — run <code>npm run build:client</code> to build the UI</h2>')
})
})
// ─── 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(`╚═══════════════════════════════════════╝`)
})