From 6db547f7f831500b80945dce1b07eca7d7e116cd Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 21 Feb 2026 17:46:54 +0100 Subject: [PATCH] feat: Add WebRadio, TURN server, and Lyrion streaming integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New packages: - luci-app-webradio: Web radio management with Lyrion bridge tab - luci-app-turn: TURN/STUN server UI for WebRTC (Jitsi integration) - secubox-app-lyrion-bridge: Lyrion → Squeezelite → FFmpeg → Icecast pipeline - secubox-app-squeezelite: Squeezelite audio player with FIFO output - secubox-app-turn: TURN server with ACME SSL and Jitsi setup - secubox-app-webradio: Icecast/ezstream web radio server Features: - HTTPS streaming via HAProxy (stream.gk2.secubox.in) - Lyrion Music Server bridge for streaming playlists to Icecast - TURN server with time-limited credential generation - CrowdSec integration for WebRadio security - Schedule-based radio programming with jingles Co-Authored-By: Claude Opus 4.5 --- .claude/HISTORY.md | 54 ++ .claude/settings.local.json | 9 +- package/secubox/luci-app-turn/Makefile | 15 + .../resources/view/turn/overview.js | 228 ++++++ .../root/usr/libexec/rpcd/luci.turn | 213 ++++++ .../usr/share/luci/menu.d/luci-app-turn.json | 14 + .../usr/share/rpcd/acl.d/luci-app-turn.json | 17 + .../resources/view/webradio/lyrion.js | 195 +++++ .../root/usr/libexec/rpcd/luci.webradio | 101 ++- .../share/luci/menu.d/luci-app-webradio.json | 8 + .../share/rpcd/acl.d/luci-app-webradio.json | 4 +- .../secubox-app-jitsi/files/usr/sbin/jitsctl | 56 ++ .../secubox-app-lyrion-bridge/Makefile | 44 ++ .../files/etc/config/lyrion-bridge | 27 + .../files/etc/init.d/lyrion-bridge | 61 ++ .../usr/lib/lyrion-bridge/ffmpeg-bridge.sh | 124 ++++ .../files/usr/sbin/lyrionstreamctl | 337 +++++++++ .../secubox/secubox-app-squeezelite/Makefile | 42 ++ .../files/etc/config/squeezelite | 21 + .../files/etc/init.d/squeezelite | 80 +++ .../files/usr/sbin/squeezelitectl | 281 ++++++++ package/secubox/secubox-app-turn/Makefile | 42 ++ .../secubox-app-turn/files/etc/config/turn | 26 + .../secubox-app-turn/files/etc/init.d/turn | 147 ++++ .../secubox-app-turn/files/usr/sbin/turnctl | 391 ++++++++++ package/secubox/secubox-app-webradio/Makefile | 43 ++ .../files/etc/config/webradio | 44 ++ .../files/etc/init.d/webradio | 78 ++ .../usr/lib/webradio/crowdsec-install.sh | 81 +++ .../files/usr/lib/webradio/scheduler.sh | 88 +++ .../files/usr/sbin/webradioctl | 675 ++++++++++++++++++ 31 files changed, 3542 insertions(+), 4 deletions(-) create mode 100644 package/secubox/luci-app-turn/Makefile create mode 100644 package/secubox/luci-app-turn/htdocs/luci-static/resources/view/turn/overview.js create mode 100644 package/secubox/luci-app-turn/root/usr/libexec/rpcd/luci.turn create mode 100644 package/secubox/luci-app-turn/root/usr/share/luci/menu.d/luci-app-turn.json create mode 100644 package/secubox/luci-app-turn/root/usr/share/rpcd/acl.d/luci-app-turn.json create mode 100644 package/secubox/luci-app-webradio/htdocs/luci-static/resources/view/webradio/lyrion.js create mode 100644 package/secubox/secubox-app-lyrion-bridge/Makefile create mode 100644 package/secubox/secubox-app-lyrion-bridge/files/etc/config/lyrion-bridge create mode 100644 package/secubox/secubox-app-lyrion-bridge/files/etc/init.d/lyrion-bridge create mode 100644 package/secubox/secubox-app-lyrion-bridge/files/usr/lib/lyrion-bridge/ffmpeg-bridge.sh create mode 100644 package/secubox/secubox-app-lyrion-bridge/files/usr/sbin/lyrionstreamctl create mode 100644 package/secubox/secubox-app-squeezelite/Makefile create mode 100644 package/secubox/secubox-app-squeezelite/files/etc/config/squeezelite create mode 100644 package/secubox/secubox-app-squeezelite/files/etc/init.d/squeezelite create mode 100644 package/secubox/secubox-app-squeezelite/files/usr/sbin/squeezelitectl create mode 100644 package/secubox/secubox-app-turn/Makefile create mode 100644 package/secubox/secubox-app-turn/files/etc/config/turn create mode 100644 package/secubox/secubox-app-turn/files/etc/init.d/turn create mode 100644 package/secubox/secubox-app-turn/files/usr/sbin/turnctl create mode 100644 package/secubox/secubox-app-webradio/Makefile create mode 100644 package/secubox/secubox-app-webradio/files/etc/config/webradio create mode 100644 package/secubox/secubox-app-webradio/files/etc/init.d/webradio create mode 100644 package/secubox/secubox-app-webradio/files/usr/lib/webradio/crowdsec-install.sh create mode 100644 package/secubox/secubox-app-webradio/files/usr/lib/webradio/scheduler.sh create mode 100644 package/secubox/secubox-app-webradio/files/usr/sbin/webradioctl diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index a61e3a14..abbd78f1 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -2793,3 +2793,57 @@ git checkout HEAD -- index.html - `.sb-btn` action buttons with hover states - Dark mode via CSS media queries - No external CSS file dependencies — fully self-contained views + +56. **Lyrion Stream Integration (2026-02-21)** + - New `secubox-app-squeezelite` package — Virtual Squeezebox player for Lyrion Music Server. + - New `secubox-app-lyrion-bridge` package — Audio bridge from Squeezelite to WebRadio/Icecast. + - **Squeezelite CLI (squeezelitectl)**: + - Service control: `start`, `stop`, `restart`, `enable`, `disable`, `status` + - Connection: `discover` (auto-find Lyrion), `connect [server]`, `disconnect` + - Audio: `devices` (list outputs), `output [device]` (set output) + - Streaming: `fifo enable [path]`, `fifo disable`, `fifo status` + - **Lyrion Bridge CLI (lyrionstreamctl)**: + - Setup: `setup [lyrion-ip]` — Full pipeline configuration + - Service: `start`, `stop`, `restart`, `enable`, `disable`, `status` + - Config: `config mount|bitrate|name|server [value]` + - Operations: `expose ` (HAProxy+SSL), `logs [lines]` + - **Pipeline Architecture**: + - Lyrion Server → Squeezelite (FIFO output /tmp/squeezelite.pcm) + - Squeezelite → FFmpeg (PCM to MP3 encoding) + - FFmpeg → Icecast (HTTP streaming) + - **FFmpeg Bridge (ffmpeg-bridge.sh)**: + - Reads PCM from FIFO (s16le, 44100Hz, stereo) + - Encodes to MP3 (configurable bitrate, default 192kbps) + - Streams to Icecast mount point + - Auto-syncs metadata from Lyrion (artist/title) + - Auto-reconnect on stream errors + - UCI configs: `/etc/config/squeezelite`, `/etc/config/lyrion-bridge` + - Files: + - `secubox-app-squeezelite/`: Makefile, UCI config, init script, squeezelitectl + - `secubox-app-lyrion-bridge/`: Makefile, UCI config, init script, lyrionstreamctl, ffmpeg-bridge.sh + +57. **TURN Server for WebRTC (2026-02-21)** + - New `secubox-app-turn` package — coturn-based TURN/STUN server for NAT traversal. + - Required for Jitsi Meet when direct P2P connections fail (symmetric NAT, firewalls). + - **TURN CLI (turnctl)**: + - Service: `start`, `stop`, `restart`, `enable`, `disable`, `status` + - Setup: `setup-jitsi [jitsi-domain] [turn-domain]` — Configure for Jitsi Meet + - SSL: `ssl [domain]` — Generate/install SSL certificates + - Network: `expose [domain]` — Configure DNS and firewall rules + - Auth: `credentials [user] [ttl]` — Generate time-limited WebRTC credentials + - Testing: `test [host]` — Test TURN connectivity + - Logs: `logs [lines]` — View server logs + - **Ports**: 3478 (STUN/TURN), 5349 (TURN over TLS), 49152-65535 (media relay) + - **Security**: + - HMAC-SHA1 time-limited credentials (REST API compatible) + - Blocked peer IPs: RFC1918, localhost, link-local + - Auto-generated static auth secret + - **Jitsi Integration**: Added `jitsctl setup-turn [domain]` command + - UCI config: `/etc/config/turn` (sections: main, ssl, limits, log) + - Files: + - `secubox-app-turn/Makefile` + - `secubox-app-turn/files/etc/config/turn` + - `secubox-app-turn/files/etc/init.d/turn` + - `secubox-app-turn/files/usr/sbin/turnctl` + - Modified: + - `secubox-app-jitsi/files/usr/sbin/jitsctl` — Added `setup-turn` command diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d9205636..f4e77524 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -381,7 +381,14 @@ "WebFetch(domain:cf.gk2.secubox.in)", "WebFetch(domain:streamlit.gk2.secubox.in)", "Bash(# Use SDK''s package tools cd /home/reepost/CyberMindStudio/secubox-openwrt/secubox-tools/sdk # Copy the manually created IPK to SDK''s output cp /home/reepost/CyberMindStudio/secubox-openwrt/package/secubox/secubox-app-bonus/root/www/secubox-feed/secubox-app-ipblocklist_1.0.0-r1_all.ipk bin/packages/aarch64_cortex-a72/secubox/ # Regenerate index for that feed cd bin/packages/aarch64_cortex-a72/secubox ../../../../scripts/ipkg-make-index.sh . gzip -k -f Packages # Now rebuild the bonus package which will include everything cd /home/reepost/CyberMindStudio/secubox-openwrt ./secubox-tools/local-build.sh build secubox-app-bonus 2>&1)", - "WebFetch(domain:portal.secubox.in)" + "WebFetch(domain:portal.secubox.in)", + "Bash(# Test 1: Check that portal.secubox.in redirects to login when not authenticated curl -s -k -I https://portal.secubox.in/)", + "Bash(# Test the complete flow echo \"\"=== Test 1: Root URL redirects to login ===\"\" curl -s -k -I https://portal.secubox.in/)", + "Bash(__NEW_LINE_7fd1ab4a5ccb9b63__ echo \"\")", + "Bash(__NEW_LINE_755a36c329effceb__ echo \"\")", + "Bash(__NEW_LINE_02bd2dd51e90cbf8__ echo \"\")", + "Bash(__NEW_LINE_70eb6f3ae1c26753__ echo \"\")", + "WebFetch(domain:radio.gk2.secubox.in)" ] } } diff --git a/package/secubox/luci-app-turn/Makefile b/package/secubox/luci-app-turn/Makefile new file mode 100644 index 00000000..117692c1 --- /dev/null +++ b/package/secubox/luci-app-turn/Makefile @@ -0,0 +1,15 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-turn +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +LUCI_TITLE:=LuCI TURN Server Management +LUCI_DEPENDS:=+secubox-app-turn +luci-base + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-turn/conffiles +endef + +$(eval $(call BuildPackage,luci-app-turn)) diff --git a/package/secubox/luci-app-turn/htdocs/luci-static/resources/view/turn/overview.js b/package/secubox/luci-app-turn/htdocs/luci-static/resources/view/turn/overview.js new file mode 100644 index 00000000..eeeab8fe --- /dev/null +++ b/package/secubox/luci-app-turn/htdocs/luci-static/resources/view/turn/overview.js @@ -0,0 +1,228 @@ +'use strict'; +'require view'; +'require rpc'; +'require poll'; +'require ui'; + +var callStatus = rpc.declare({ object: 'luci.turn', method: 'status', expect: {} }); +var callStart = rpc.declare({ object: 'luci.turn', method: 'start', expect: {} }); +var callStop = rpc.declare({ object: 'luci.turn', method: 'stop', expect: {} }); +var callEnable = rpc.declare({ object: 'luci.turn', method: 'enable', expect: {} }); +var callDisable = rpc.declare({ object: 'luci.turn', method: 'disable', expect: {} }); +var callSetupJitsi = rpc.declare({ object: 'luci.turn', method: 'setup_jitsi', params: ['jitsi_domain', 'turn_domain'], expect: {} }); +var callSSL = rpc.declare({ object: 'luci.turn', method: 'ssl', params: ['domain'], expect: {} }); +var callExpose = rpc.declare({ object: 'luci.turn', method: 'expose', params: ['domain'], expect: {} }); +var callCredentials = rpc.declare({ object: 'luci.turn', method: 'credentials', params: ['username', 'ttl'], expect: {} }); +var callLogs = rpc.declare({ object: 'luci.turn', method: 'logs', params: ['lines'], expect: {} }); + +return view.extend({ + data: {}, + + load: function() { + return callStatus().then(function(r) { this.data = r; return r; }.bind(this)); + }, + + render: function(data) { + var self = this; + this.data = data || {}; + + poll.add(function() { + return callStatus().then(function(r) { + self.data = r; + self.updateUI(r); + }); + }, 5); + + return E('div', { 'class': 'cbi-map' }, [ + E('style', {}, this.getStyles()), + + E('div', { 'class': 'sb-header' }, [ + E('h2', {}, 'TURN Server'), + E('div', { 'class': 'sb-chips' }, [ + E('span', { 'class': 'chip', 'id': 'chip-status' }, + this.data.running ? 'Running' : 'Stopped'), + E('span', { 'class': 'chip' }, 'Realm: ' + (this.data.realm || 'N/A')), + E('span', { 'class': 'chip' }, 'Port: ' + (this.data.port || 3478)) + ]) + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Service Control'), + E('div', { 'class': 'btn-row' }, [ + E('button', { 'class': 'sb-btn sb-btn-success', 'click': ui.createHandlerFn(this, 'handleStart') }, 'Start'), + E('button', { 'class': 'sb-btn sb-btn-danger', 'click': ui.createHandlerFn(this, 'handleStop') }, 'Stop'), + E('button', { 'class': 'sb-btn', 'click': ui.createHandlerFn(this, 'handleEnable') }, 'Enable Autostart'), + E('button', { 'class': 'sb-btn', 'click': ui.createHandlerFn(this, 'handleDisable') }, 'Disable Autostart') + ]) + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Status'), + E('div', { 'class': 'sb-grid' }, [ + this.renderCard('Server', this.data.running ? 'Running' : 'Stopped', this.data.running ? 'success' : 'danger'), + this.renderCard('UDP 3478', this.data.udp_3478 ? 'Listening' : 'Closed', this.data.udp_3478 ? 'success' : 'warning'), + this.renderCard('TCP 5349', this.data.tcp_5349 ? 'Listening' : 'Closed', this.data.tcp_5349 ? 'success' : 'warning'), + this.renderCard('External IP', this.data.external_ip || this.data.detected_ip || 'Unknown', 'info') + ]) + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Jitsi Integration'), + E('p', {}, 'Configure TURN server for Jitsi Meet WebRTC connections'), + E('div', { 'class': 'form-row' }, [ + E('input', { 'type': 'text', 'id': 'jitsi-domain', 'placeholder': 'jitsi.secubox.in', 'class': 'sb-input' }), + E('input', { 'type': 'text', 'id': 'turn-domain', 'placeholder': 'turn.secubox.in', 'class': 'sb-input' }), + E('button', { 'class': 'sb-btn sb-btn-primary', 'click': ui.createHandlerFn(this, 'handleSetupJitsi') }, 'Setup for Jitsi') + ]) + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'SSL & Expose'), + E('div', { 'class': 'form-row' }, [ + E('input', { 'type': 'text', 'id': 'ssl-domain', 'placeholder': 'turn.secubox.in', 'class': 'sb-input' }), + E('button', { 'class': 'sb-btn', 'click': ui.createHandlerFn(this, 'handleSSL') }, 'Setup SSL'), + E('button', { 'class': 'sb-btn sb-btn-primary', 'click': ui.createHandlerFn(this, 'handleExpose') }, 'Expose (DNS+FW)') + ]) + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Generate Credentials'), + E('p', {}, 'Generate time-limited TURN credentials for WebRTC clients'), + E('div', { 'class': 'form-row' }, [ + E('input', { 'type': 'text', 'id': 'cred-user', 'placeholder': 'username', 'value': 'webrtc', 'class': 'sb-input' }), + E('input', { 'type': 'number', 'id': 'cred-ttl', 'placeholder': 'TTL (seconds)', 'value': '86400', 'class': 'sb-input' }), + E('button', { 'class': 'sb-btn sb-btn-primary', 'click': ui.createHandlerFn(this, 'handleCredentials') }, 'Generate') + ]), + E('pre', { 'id': 'credentials-output', 'class': 'sb-output', 'style': 'display:none;' }, '') + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Logs'), + E('button', { 'class': 'sb-btn', 'click': ui.createHandlerFn(this, 'handleShowLogs') }, 'Show Logs'), + E('pre', { 'id': 'logs-output', 'class': 'sb-output', 'style': 'display:none; max-height:300px; overflow:auto;' }, '') + ]) + ]); + }, + + renderCard: function(title, value, status) { + var statusClass = status === 'success' ? 'card-success' : (status === 'danger' ? 'card-danger' : (status === 'warning' ? 'card-warning' : 'card-info')); + return E('div', { 'class': 'sb-card ' + statusClass }, [ + E('div', { 'class': 'card-title' }, title), + E('div', { 'class': 'card-value' }, value) + ]); + }, + + updateUI: function(data) { + var chip = document.getElementById('chip-status'); + if (chip) { + chip.textContent = data.running ? 'Running' : 'Stopped'; + chip.className = 'chip ' + (data.running ? 'chip-success' : 'chip-danger'); + } + }, + + handleStart: function() { + return callStart().then(function() { + ui.addNotification(null, E('p', 'TURN server started')); + }); + }, + + handleStop: function() { + return callStop().then(function() { + ui.addNotification(null, E('p', 'TURN server stopped')); + }); + }, + + handleEnable: function() { + return callEnable().then(function() { + ui.addNotification(null, E('p', 'TURN server enabled')); + }); + }, + + handleDisable: function() { + return callDisable().then(function() { + ui.addNotification(null, E('p', 'TURN server disabled')); + }); + }, + + handleSetupJitsi: function() { + var jitsiDomain = document.getElementById('jitsi-domain').value || 'jitsi.secubox.in'; + var turnDomain = document.getElementById('turn-domain').value || 'turn.secubox.in'; + + return callSetupJitsi(jitsiDomain, turnDomain).then(function(res) { + ui.addNotification(null, E('p', 'TURN configured for Jitsi. Auth secret: ' + (res.auth_secret || 'generated'))); + }); + }, + + handleSSL: function() { + var domain = document.getElementById('ssl-domain').value || 'turn.secubox.in'; + return callSSL(domain).then(function(res) { + ui.addNotification(null, E('p', 'SSL configured for ' + domain)); + }); + }, + + handleExpose: function() { + var domain = document.getElementById('ssl-domain').value || 'turn.secubox.in'; + return callExpose(domain).then(function(res) { + ui.addNotification(null, E('p', 'TURN exposed on ' + domain)); + }); + }, + + handleCredentials: function() { + var username = document.getElementById('cred-user').value || 'webrtc'; + var ttl = parseInt(document.getElementById('cred-ttl').value) || 86400; + + return callCredentials(username, ttl).then(function(res) { + var output = document.getElementById('credentials-output'); + output.style.display = 'block'; + output.textContent = JSON.stringify({ + urls: ['turn:' + res.realm + ':3478', 'turn:' + res.realm + ':3478?transport=tcp'], + username: res.username, + credential: res.password + }, null, 2); + }); + }, + + handleShowLogs: function() { + return callLogs(100).then(function(res) { + var output = document.getElementById('logs-output'); + output.style.display = 'block'; + output.textContent = res.logs || 'No logs available'; + }); + }, + + getStyles: function() { + return [ + '.sb-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }', + '.sb-chips { display:flex; gap:10px; }', + '.chip { padding:5px 12px; border-radius:15px; font-size:0.85em; background:#444; color:#fff; }', + '.chip-success { background:#28a745; }', + '.chip-danger { background:#dc3545; }', + '.sb-section { background:#1a1a2e; padding:20px; margin-bottom:15px; border-radius:8px; }', + '.sb-section h3 { margin:0 0 15px 0; color:#4fc3f7; }', + '.sb-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:15px; }', + '.sb-card { padding:15px; border-radius:8px; text-align:center; }', + '.card-success { background:#155724; border:1px solid #28a745; }', + '.card-danger { background:#721c24; border:1px solid #dc3545; }', + '.card-warning { background:#856404; border:1px solid #ffc107; }', + '.card-info { background:#0c5460; border:1px solid #17a2b8; }', + '.card-title { font-size:0.85em; color:#aaa; margin-bottom:5px; }', + '.card-value { font-size:1.1em; font-weight:bold; }', + '.btn-row { display:flex; gap:10px; flex-wrap:wrap; }', + '.sb-btn { padding:8px 16px; border:none; border-radius:5px; cursor:pointer; background:#444; color:#fff; }', + '.sb-btn:hover { background:#555; }', + '.sb-btn-primary { background:#007bff; }', + '.sb-btn-primary:hover { background:#0056b3; }', + '.sb-btn-success { background:#28a745; }', + '.sb-btn-success:hover { background:#218838; }', + '.sb-btn-danger { background:#dc3545; }', + '.sb-btn-danger:hover { background:#c82333; }', + '.form-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }', + '.sb-input { padding:8px 12px; border:1px solid #444; border-radius:5px; background:#2a2a3e; color:#fff; }', + '.sb-output { background:#0a0a15; padding:15px; border-radius:5px; font-family:monospace; font-size:0.9em; white-space:pre-wrap; word-break:break-all; }' + ].join('\n'); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-turn/root/usr/libexec/rpcd/luci.turn b/package/secubox/luci-app-turn/root/usr/libexec/rpcd/luci.turn new file mode 100644 index 00000000..078e21ba --- /dev/null +++ b/package/secubox/luci-app-turn/root/usr/libexec/rpcd/luci.turn @@ -0,0 +1,213 @@ +#!/bin/sh +# RPCD handler for TURN server management + +. /usr/share/libubox/jshn.sh + +uci_get() { uci -q get "turn.$1" 2>/dev/null || echo "$2"; } + +case "$1" in + list) + echo '{"status":{},"logs":{"lines":50},"test":{"host":""},"start":{},"stop":{},"restart":{},"enable":{},"disable":{},"setup_jitsi":{"jitsi_domain":"","turn_domain":""},"ssl":{"domain":""},"expose":{"domain":""},"credentials":{"username":"","ttl":86400}}' + ;; + call) + case "$2" in + status) + json_init + + local enabled=$(uci_get main.enabled 0) + local realm=$(uci_get main.realm "turn.secubox.in") + local port=$(uci_get main.listening_port "3478") + local tls_port=$(uci_get main.tls_port "5349") + local external_ip=$(uci_get main.external_ip "") + + json_add_boolean enabled $([ "$enabled" = "1" ] && echo 1 || echo 0) + json_add_string realm "$realm" + json_add_int port "$port" + json_add_int tls_port "$tls_port" + json_add_string external_ip "$external_ip" + + if pgrep -f "turnserver" >/dev/null 2>&1; then + json_add_boolean running 1 + json_add_int pid $(pgrep -f "turnserver" | head -1) + else + json_add_boolean running 0 + json_add_int pid 0 + fi + + # Check ports + if grep -q ":0D92 " /proc/net/udp 2>/dev/null; then + json_add_boolean udp_3478 1 + else + json_add_boolean udp_3478 0 + fi + + if grep -q ":14E5 " /proc/net/tcp 2>/dev/null; then + json_add_boolean tcp_5349 1 + else + json_add_boolean tcp_5349 0 + fi + + # Auto-detect external IP if empty + if [ -z "$external_ip" ]; then + external_ip=$(curl -s -4 --connect-timeout 3 https://ifconfig.me 2>/dev/null || echo "") + json_add_string detected_ip "$external_ip" + fi + + json_dump + ;; + + logs) + read -r input + json_load "$input" + json_get_var lines lines 50 + + json_init + json_add_string result "ok" + + local log_file=$(uci_get log.log_file "/var/log/turnserver.log") + if [ -f "$log_file" ]; then + json_add_string logs "$(tail -n "$lines" "$log_file" 2>/dev/null | head -c 50000)" + else + json_add_string logs "$(logread | grep -i turn | tail -n "$lines" | head -c 50000)" + fi + + json_dump + ;; + + test) + read -r input + json_load "$input" + json_get_var host host "" + + [ -z "$host" ] && host=$(uci_get main.realm "turn.secubox.in") + + json_init + + # Test UDP 3478 + if nc -u -z -w 2 "$host" 3478 2>/dev/null; then + json_add_boolean udp_reachable 1 + else + json_add_boolean udp_reachable 0 + fi + + # Test TCP 5349 + if nc -z -w 2 "$host" 5349 2>/dev/null; then + json_add_boolean tcp_reachable 1 + else + json_add_boolean tcp_reachable 0 + fi + + json_add_string host "$host" + json_dump + ;; + + start) + /etc/init.d/turn start 2>&1 + json_init + json_add_string result "ok" + json_dump + ;; + + stop) + /etc/init.d/turn stop 2>&1 + json_init + json_add_string result "ok" + json_dump + ;; + + restart) + /etc/init.d/turn restart 2>&1 + json_init + json_add_string result "ok" + json_dump + ;; + + enable) + uci set turn.main.enabled='1' + uci commit turn + /etc/init.d/turn enable + /etc/init.d/turn start + json_init + json_add_string result "ok" + json_dump + ;; + + disable) + uci set turn.main.enabled='0' + uci commit turn + /etc/init.d/turn disable + /etc/init.d/turn stop + json_init + json_add_string result "ok" + json_dump + ;; + + setup_jitsi) + read -r input + json_load "$input" + json_get_var jitsi_domain jitsi_domain "" + json_get_var turn_domain turn_domain "turn.secubox.in" + + output=$(turnctl setup-jitsi "$jitsi_domain" "$turn_domain" 2>&1) + local auth_secret=$(uci_get main.static_auth_secret "") + + json_init + json_add_string result "ok" + json_add_string turn_domain "$turn_domain" + json_add_string auth_secret "$auth_secret" + json_add_string output "$output" + json_dump + ;; + + ssl) + read -r input + json_load "$input" + json_get_var domain domain "" + + output=$(turnctl ssl "$domain" 2>&1) + + json_init + json_add_string result "ok" + json_add_string output "$output" + json_dump + ;; + + expose) + read -r input + json_load "$input" + json_get_var domain domain "" + + output=$(turnctl expose "$domain" 2>&1) + + json_init + json_add_string result "ok" + json_add_string output "$output" + json_dump + ;; + + credentials) + read -r input + json_load "$input" + json_get_var username username "webrtc" + json_get_var ttl ttl 86400 + + local auth_secret=$(uci_get main.static_auth_secret "") + local realm=$(uci_get main.realm "turn.secubox.in") + local timestamp=$(($(date +%s) + ttl)) + local temp_username="${timestamp}:${username}" + + # HMAC-SHA1 credential + local password=$(echo -n "$temp_username" | openssl dgst -sha1 -hmac "$auth_secret" -binary | base64) + + json_init + json_add_string result "ok" + json_add_string realm "$realm" + json_add_string username "$temp_username" + json_add_string password "$password" + json_add_int ttl "$ttl" + json_add_int expires "$timestamp" + json_dump + ;; + esac + ;; +esac diff --git a/package/secubox/luci-app-turn/root/usr/share/luci/menu.d/luci-app-turn.json b/package/secubox/luci-app-turn/root/usr/share/luci/menu.d/luci-app-turn.json new file mode 100644 index 00000000..d6769e54 --- /dev/null +++ b/package/secubox/luci-app-turn/root/usr/share/luci/menu.d/luci-app-turn.json @@ -0,0 +1,14 @@ +{ + "admin/services/turn": { + "title": "TURN Server", + "order": 85, + "action": { + "type": "view", + "path": "turn/overview" + }, + "depends": { + "acl": ["luci-app-turn"], + "uci": { "turn": true } + } + } +} diff --git a/package/secubox/luci-app-turn/root/usr/share/rpcd/acl.d/luci-app-turn.json b/package/secubox/luci-app-turn/root/usr/share/rpcd/acl.d/luci-app-turn.json new file mode 100644 index 00000000..f6bb386c --- /dev/null +++ b/package/secubox/luci-app-turn/root/usr/share/rpcd/acl.d/luci-app-turn.json @@ -0,0 +1,17 @@ +{ + "luci-app-turn": { + "description": "Grant access to TURN server management", + "read": { + "ubus": { + "luci.turn": ["status", "logs", "test"] + }, + "uci": ["turn"] + }, + "write": { + "ubus": { + "luci.turn": ["start", "stop", "restart", "enable", "disable", "setup_jitsi", "ssl", "expose", "credentials"] + }, + "uci": ["turn"] + } + } +} diff --git a/package/secubox/luci-app-webradio/htdocs/luci-static/resources/view/webradio/lyrion.js b/package/secubox/luci-app-webradio/htdocs/luci-static/resources/view/webradio/lyrion.js new file mode 100644 index 00000000..c7f12bb5 --- /dev/null +++ b/package/secubox/luci-app-webradio/htdocs/luci-static/resources/view/webradio/lyrion.js @@ -0,0 +1,195 @@ +'use strict'; +'require view'; +'require rpc'; +'require poll'; +'require ui'; + +var callBridgeStatus = rpc.declare({ object: 'luci.webradio', method: 'bridge_status', expect: {} }); +var callBridgeStart = rpc.declare({ object: 'luci.webradio', method: 'bridge_start', expect: {} }); +var callBridgeStop = rpc.declare({ object: 'luci.webradio', method: 'bridge_stop', expect: {} }); +var callBridgeSetup = rpc.declare({ object: 'luci.webradio', method: 'bridge_setup', params: ['lyrion_server'], expect: {} }); + +return view.extend({ + data: {}, + + load: function() { + return callBridgeStatus().then(function(r) { this.data = r; return r; }.bind(this)).catch(function() { return {}; }); + }, + + render: function(data) { + var self = this; + this.data = data || {}; + + poll.add(function() { + return callBridgeStatus().then(function(r) { + self.data = r; + self.updateUI(r); + }).catch(function() {}); + }, 5); + + return E('div', { 'class': 'cbi-map' }, [ + E('style', {}, this.getStyles()), + + E('div', { 'class': 'sb-header' }, [ + E('h2', {}, 'Lyrion Stream Bridge'), + E('div', { 'class': 'sb-chips' }, [ + E('span', { 'class': 'chip', 'id': 'chip-bridge' }, this.data.bridge_running ? 'Bridge Running' : 'Bridge Stopped'), + E('span', { 'class': 'chip', 'id': 'chip-lyrion' }, this.data.lyrion_online ? 'Lyrion Online' : 'Lyrion Offline') + ]) + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Architecture'), + E('div', { 'class': 'pipeline' }, [ + E('span', { 'class': 'pipe-node' }, 'Lyrion Server'), + E('span', { 'class': 'pipe-arrow' }, '\u2192'), + E('span', { 'class': 'pipe-node' }, 'Squeezelite'), + E('span', { 'class': 'pipe-arrow' }, '\u2192'), + E('span', { 'class': 'pipe-node' }, 'FIFO'), + E('span', { 'class': 'pipe-arrow' }, '\u2192'), + E('span', { 'class': 'pipe-node' }, 'FFmpeg'), + E('span', { 'class': 'pipe-arrow' }, '\u2192'), + E('span', { 'class': 'pipe-node' }, 'Icecast') + ]) + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Status'), + E('div', { 'class': 'sb-grid' }, [ + this.renderCard('Lyrion', this.data.lyrion_online ? 'Online' : 'Offline', this.data.lyrion_online ? 'success' : 'danger', 'lyrion-card'), + this.renderCard('Squeezelite', this.data.squeezelite_running ? 'Running' : 'Stopped', this.data.squeezelite_running ? 'success' : 'warning', 'squeeze-card'), + this.renderCard('FFmpeg', this.data.ffmpeg_running ? 'Encoding' : 'Idle', this.data.ffmpeg_running ? 'success' : 'warning', 'ffmpeg-card'), + this.renderCard('Icecast Mount', this.data.mount_active ? 'Active' : 'Inactive', this.data.mount_active ? 'success' : 'warning', 'mount-card') + ]) + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Now Playing'), + E('div', { 'class': 'now-playing', 'id': 'now-playing' }, [ + E('span', { 'class': 'np-title' }, this.data.title || 'Nothing playing'), + this.data.artist ? E('span', { 'class': 'np-artist' }, this.data.artist) : '' + ]), + E('div', { 'style': 'margin-top:10px;' }, [ + E('span', {}, 'Listeners: '), + E('strong', { 'id': 'listeners' }, String(this.data.listeners || 0)) + ]) + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Quick Setup'), + E('div', { 'class': 'form-row' }, [ + E('input', { 'type': 'text', 'id': 'lyrion-server', 'placeholder': 'Lyrion IP (e.g. 127.0.0.1)', 'value': this.data.lyrion_server || '', 'class': 'sb-input' }), + E('button', { 'class': 'sb-btn sb-btn-primary', 'click': ui.createHandlerFn(this, 'handleSetup') }, 'Setup Pipeline') + ]), + E('p', { 'style': 'color:#888; font-size:0.9em; margin-top:10px;' }, + 'This will configure Squeezelite FIFO output, FFmpeg encoder, and Icecast mount.') + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Bridge Control'), + E('div', { 'class': 'btn-row' }, [ + E('button', { 'class': 'sb-btn sb-btn-success', 'click': ui.createHandlerFn(this, 'handleStart') }, 'Start Bridge'), + E('button', { 'class': 'sb-btn sb-btn-danger', 'click': ui.createHandlerFn(this, 'handleStop') }, 'Stop Bridge') + ]) + ]), + + E('div', { 'class': 'sb-section' }, [ + E('h3', {}, 'Stream URL'), + E('div', { 'class': 'stream-url' }, [ + E('a', { 'href': this.data.stream_url || '#', 'target': '_blank', 'id': 'stream-url-link' }, + this.data.stream_url || 'http://127.0.0.1:8000/lyrion') + ]), + E('audio', { 'controls': true, 'style': 'width:100%; max-width:400px; margin-top:10px;' }, [ + E('source', { 'src': this.data.stream_url || 'http://127.0.0.1:8000/lyrion', 'type': 'audio/mpeg' }) + ]) + ]) + ]); + }, + + renderCard: function(title, value, status, id) { + var cls = 'sb-card card-' + status; + return E('div', { 'class': cls, 'id': id }, [ + E('div', { 'class': 'card-title' }, title), + E('div', { 'class': 'card-value' }, value) + ]); + }, + + updateUI: function(data) { + var chipBridge = document.getElementById('chip-bridge'); + var chipLyrion = document.getElementById('chip-lyrion'); + if (chipBridge) chipBridge.textContent = data.bridge_running ? 'Bridge Running' : 'Bridge Stopped'; + if (chipLyrion) chipLyrion.textContent = data.lyrion_online ? 'Lyrion Online' : 'Lyrion Offline'; + + var np = document.getElementById('now-playing'); + if (np) { + np.innerHTML = ''; + np.appendChild(E('span', { 'class': 'np-title' }, data.title || 'Nothing playing')); + if (data.artist) np.appendChild(E('span', { 'class': 'np-artist' }, data.artist)); + } + + var listeners = document.getElementById('listeners'); + if (listeners) listeners.textContent = String(data.listeners || 0); + }, + + handleSetup: function() { + var server = document.getElementById('lyrion-server').value || '127.0.0.1'; + return callBridgeSetup(server).then(function(res) { + ui.addNotification(null, E('p', 'Pipeline setup complete. Stream URL: ' + (res.stream_url || 'http://127.0.0.1:8000/lyrion'))); + }).catch(function(e) { + ui.addNotification(null, E('p', 'Setup failed: ' + e.message), 'error'); + }); + }, + + handleStart: function() { + return callBridgeStart().then(function() { + ui.addNotification(null, E('p', 'Lyrion bridge started')); + }).catch(function(e) { + ui.addNotification(null, E('p', 'Failed: ' + e.message), 'error'); + }); + }, + + handleStop: function() { + return callBridgeStop().then(function() { + ui.addNotification(null, E('p', 'Lyrion bridge stopped')); + }).catch(function(e) { + ui.addNotification(null, E('p', 'Failed: ' + e.message), 'error'); + }); + }, + + getStyles: function() { + return [ + '.sb-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }', + '.sb-chips { display:flex; gap:10px; }', + '.chip { padding:5px 12px; border-radius:15px; font-size:0.85em; background:#444; color:#fff; }', + '.sb-section { background:#1a1a2e; padding:20px; margin-bottom:15px; border-radius:8px; }', + '.sb-section h3 { margin:0 0 15px 0; color:#4fc3f7; }', + '.pipeline { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:15px; background:#0a0a15; border-radius:5px; }', + '.pipe-node { padding:8px 15px; background:#2196f3; color:#fff; border-radius:20px; font-weight:bold; }', + '.pipe-arrow { color:#4fc3f7; font-size:1.5em; }', + '.sb-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:15px; }', + '.sb-card { padding:15px; border-radius:8px; text-align:center; }', + '.card-success { background:#155724; border:1px solid #28a745; }', + '.card-danger { background:#721c24; border:1px solid #dc3545; }', + '.card-warning { background:#856404; border:1px solid #ffc107; }', + '.card-title { font-size:0.85em; color:#aaa; margin-bottom:5px; }', + '.card-value { font-size:1.1em; font-weight:bold; }', + '.now-playing { padding:15px; background:#0a0a15; border-radius:5px; }', + '.np-title { display:block; font-size:1.2em; font-weight:bold; }', + '.np-artist { display:block; color:#888; margin-top:5px; }', + '.btn-row { display:flex; gap:10px; flex-wrap:wrap; }', + '.sb-btn { padding:8px 16px; border:none; border-radius:5px; cursor:pointer; background:#444; color:#fff; }', + '.sb-btn:hover { background:#555; }', + '.sb-btn-primary { background:#007bff; }', + '.sb-btn-success { background:#28a745; }', + '.sb-btn-danger { background:#dc3545; }', + '.form-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }', + '.sb-input { padding:8px 12px; border:1px solid #444; border-radius:5px; background:#2a2a3e; color:#fff; }', + '.stream-url { padding:15px; background:#0a0a15; border-radius:5px; }', + '.stream-url a { color:#4fc3f7; text-decoration:none; font-family:monospace; }' + ].join('\n'); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-webradio/root/usr/libexec/rpcd/luci.webradio b/package/secubox/luci-app-webradio/root/usr/libexec/rpcd/luci.webradio index 08930e10..9569682b 100644 --- a/package/secubox/luci-app-webradio/root/usr/libexec/rpcd/luci.webradio +++ b/package/secubox/luci-app-webradio/root/usr/libexec/rpcd/luci.webradio @@ -45,7 +45,11 @@ case "$1" in "list_audio_devices": {}, "security_status": {}, "install_crowdsec": {}, - "generate_ssl_cert": {"hostname": ""} + "generate_ssl_cert": {"hostname": ""}, + "bridge_status": {}, + "bridge_start": {}, + "bridge_stop": {}, + "bridge_setup": {"lyrion_server": "127.0.0.1"} } EOF ;; @@ -660,6 +664,101 @@ EOF fi ;; + bridge_status) + json_init + + # Check Lyrion server + local lyrion_server=$(uci -q get lyrion-bridge.main.lyrion_server 2>/dev/null || echo "127.0.0.1") + local lyrion_port=$(uci -q get lyrion-bridge.main.lyrion_port 2>/dev/null || echo "9000") + local lyrion_online=0 + if curl -s "http://${lyrion_server}:${lyrion_port}/status.html" >/dev/null 2>&1; then + lyrion_online=1 + fi + + # Check Squeezelite + local squeezelite_running=0 + pgrep -f "squeezelite" >/dev/null 2>&1 && squeezelite_running=1 + + # Check FFmpeg bridge + local ffmpeg_running=0 + pgrep -f "ffmpeg-bridge.sh" >/dev/null 2>&1 && ffmpeg_running=1 + pgrep -f "ffmpeg.*lyrion" >/dev/null 2>&1 && ffmpeg_running=1 + + # Check Icecast mount + local icecast_host=$(uci -q get lyrion-bridge.icecast.host 2>/dev/null || echo "127.0.0.1") + local icecast_port=$(uci -q get lyrion-bridge.icecast.port 2>/dev/null || echo "8000") + local icecast_mount=$(uci -q get lyrion-bridge.icecast.mount 2>/dev/null || echo "/lyrion") + local mount_active=0 + local listeners=0 + local title="" + local artist="" + + local mount_status=$(curl -s "http://${icecast_host}:${icecast_port}/status-json.xsl" 2>/dev/null) + if [ -n "$mount_status" ]; then + local sources=$(echo "$mount_status" | jsonfilter -e '@.icestats.source' 2>/dev/null) + if echo "$sources" | grep -q "lyrion"; then + mount_active=1 + listeners=$(echo "$mount_status" | jsonfilter -e '@.icestats.source[@.listenurl="*lyrion*"].listeners' 2>/dev/null || echo "0") + fi + fi + + # Get now playing from Lyrion + if [ "$lyrion_online" = "1" ]; then + local np=$(curl -s "http://${lyrion_server}:${lyrion_port}/jsonrpc.js" \ + -d '{"id":1,"method":"slim.request","params":["",[\"status\",\"-\",1,\"tags:adl\"]]}' 2>/dev/null) + if [ -n "$np" ]; then + title=$(echo "$np" | jsonfilter -e '@.result.playlist_loop[0].title' 2>/dev/null || echo "") + artist=$(echo "$np" | jsonfilter -e '@.result.playlist_loop[0].artist' 2>/dev/null || echo "") + fi + fi + + json_add_boolean lyrion_online "$lyrion_online" + json_add_string lyrion_server "$lyrion_server" + json_add_boolean squeezelite_running "$squeezelite_running" + json_add_boolean ffmpeg_running "$ffmpeg_running" + json_add_boolean bridge_running "$ffmpeg_running" + json_add_boolean mount_active "$mount_active" + json_add_int listeners "$listeners" + json_add_string title "$title" + json_add_string artist "$artist" + json_add_string stream_url "http://${icecast_host}:${icecast_port}${icecast_mount}" + + json_dump + ;; + + bridge_start) + if [ -x /usr/sbin/lyrionstreamctl ]; then + /usr/sbin/lyrionstreamctl start >/dev/null 2>&1 + echo '{"result": "ok"}' + else + echo '{"result": "error", "error": "lyrionstreamctl not found"}' + fi + ;; + + bridge_stop) + if [ -x /usr/sbin/lyrionstreamctl ]; then + /usr/sbin/lyrionstreamctl stop >/dev/null 2>&1 + echo '{"result": "ok"}' + else + echo '{"result": "error", "error": "lyrionstreamctl not found"}' + fi + ;; + + bridge_setup) + read -r input + local lyrion_server=$(echo "$input" | jsonfilter -e '@.lyrion_server' 2>/dev/null) + lyrion_server=${lyrion_server:-127.0.0.1} + + if [ -x /usr/sbin/lyrionstreamctl ]; then + output=$(/usr/sbin/lyrionstreamctl setup "$lyrion_server" 2>&1) + local icecast_port=$(uci -q get lyrion-bridge.icecast.port 2>/dev/null || echo "8000") + local icecast_mount=$(uci -q get lyrion-bridge.icecast.mount 2>/dev/null || echo "/lyrion") + echo '{"result": "ok", "stream_url": "http://127.0.0.1:'"$icecast_port$icecast_mount"'"}' + else + echo '{"result": "error", "error": "lyrionstreamctl not found"}' + fi + ;; + *) echo '{"error": "unknown method"}' ;; diff --git a/package/secubox/luci-app-webradio/root/usr/share/luci/menu.d/luci-app-webradio.json b/package/secubox/luci-app-webradio/root/usr/share/luci/menu.d/luci-app-webradio.json index cb04bb92..b7ce76d9 100644 --- a/package/secubox/luci-app-webradio/root/usr/share/luci/menu.d/luci-app-webradio.json +++ b/package/secubox/luci-app-webradio/root/usr/share/luci/menu.d/luci-app-webradio.json @@ -65,5 +65,13 @@ "type": "view", "path": "webradio/security" } + }, + "admin/services/webradio/lyrion": { + "title": "Lyrion Bridge", + "order": 80, + "action": { + "type": "view", + "path": "webradio/lyrion" + } } } diff --git a/package/secubox/luci-app-webradio/root/usr/share/rpcd/acl.d/luci-app-webradio.json b/package/secubox/luci-app-webradio/root/usr/share/rpcd/acl.d/luci-app-webradio.json index 56888a4e..8c8015fd 100644 --- a/package/secubox/luci-app-webradio/root/usr/share/rpcd/acl.d/luci-app-webradio.json +++ b/package/secubox/luci-app-webradio/root/usr/share/rpcd/acl.d/luci-app-webradio.json @@ -3,13 +3,13 @@ "description": "Grant access to WebRadio configuration", "read": { "ubus": { - "luci.webradio": ["status", "listeners", "playlist", "logs", "schedules", "current_show", "list_jingles", "live_status", "list_audio_devices", "security_status"] + "luci.webradio": ["status", "listeners", "playlist", "logs", "schedules", "current_show", "list_jingles", "live_status", "list_audio_devices", "security_status", "bridge_status"] }, "uci": ["icecast", "ezstream", "webradio", "darkice"] }, "write": { "ubus": { - "luci.webradio": ["start", "stop", "restart", "skip", "reload", "generate_playlist", "upload", "add_schedule", "update_schedule", "delete_schedule", "generate_cron", "play_jingle", "live_start", "live_stop", "install_crowdsec", "generate_ssl_cert"] + "luci.webradio": ["start", "stop", "restart", "skip", "reload", "generate_playlist", "upload", "add_schedule", "update_schedule", "delete_schedule", "generate_cron", "play_jingle", "live_start", "live_stop", "install_crowdsec", "generate_ssl_cert", "bridge_start", "bridge_stop", "bridge_setup"] }, "uci": ["icecast", "ezstream", "webradio", "darkice"] } diff --git a/package/secubox/secubox-app-jitsi/files/usr/sbin/jitsctl b/package/secubox/secubox-app-jitsi/files/usr/sbin/jitsctl index 24276897..17ce8514 100644 --- a/package/secubox/secubox-app-jitsi/files/usr/sbin/jitsctl +++ b/package/secubox/secubox-app-jitsi/files/usr/sbin/jitsctl @@ -565,6 +565,58 @@ restore() { log "Restore complete" } +# ============================================================================ +# TURN Server Integration +# ============================================================================ + +setup_turn() { + local turn_domain="${1:-turn.secubox.in}" + + log "Setting up TURN server for Jitsi..." + + # Check if turnctl is available + if ! command -v turnctl >/dev/null 2>&1; then + error "turnctl not found. Install secubox-app-turn first." + echo " opkg install secubox-app-turn" + return 1 + fi + + # Setup TURN server for Jitsi + local jitsi_domain=$(uci -q get jitsi.main.domain || echo "meet.secubox.local") + turnctl setup-jitsi "$jitsi_domain" "$turn_domain" + + # Get the TURN credentials + local turn_secret=$(uci -q get turn.main.static_auth_secret) + local external_ip=$(uci -q get turn.main.external_ip) + + if [ -z "$turn_secret" ]; then + error "TURN server not configured. Run 'turnctl setup-jitsi' first." + return 1 + fi + + # Update Jitsi UCI config + uci set jitsi.turn=jitsi + uci set jitsi.turn.enabled='1' + uci set jitsi.turn.server="$turn_domain" + uci set jitsi.turn.use_secret='1' + uci set jitsi.turn.secret="$turn_secret" + uci commit jitsi + + # Update STUN servers + uci set jitsi.jvb.stun_servers="$turn_domain:3478" + uci commit jitsi + + # Regenerate config + generate_config + + log "TURN configured for Jitsi!" + echo "" + echo "TURN Server: $turn_domain" + echo "External IP: $external_ip" + echo "" + echo "Restart Jitsi to apply: jitsctl restart" +} + # ============================================================================ # Main # ============================================================================ @@ -596,6 +648,7 @@ Commands: configure-haproxy Add HAProxy vhost configure-fw Configure firewall rules + setup-turn [dom] Configure TURN server for NAT traversal Examples: jitsctl install @@ -655,6 +708,9 @@ case "$1" in configure-fw) configure_firewall ;; + setup-turn) + setup_turn "$2" + ;; -h|--help|help) show_help ;; diff --git a/package/secubox/secubox-app-lyrion-bridge/Makefile b/package/secubox/secubox-app-lyrion-bridge/Makefile new file mode 100644 index 00000000..8c8a0b4c --- /dev/null +++ b/package/secubox/secubox-app-lyrion-bridge/Makefile @@ -0,0 +1,44 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-app-lyrion-bridge +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=Gerald Kerma +PKG_LICENSE:=MIT + +include $(INCLUDE_DIR)/package.mk + +define Package/secubox-app-lyrion-bridge + SECTION:=secubox + CATEGORY:=SecuBox + TITLE:=SecuBox Lyrion to WebRadio Bridge + DEPENDS:=+secubox-app-squeezelite +secubox-app-webradio +ffmpeg + PKGARCH:=all +endef + +define Package/secubox-app-lyrion-bridge/description + Bridge service connecting Lyrion Music Server to WebRadio/Icecast. + Streams audio from Squeezelite player through FFmpeg to Icecast. + Provides lyrionstreamctl CLI for management. +endef + +define Package/secubox-app-lyrion-bridge/conffiles +/etc/config/lyrion-bridge +endef + +define Build/Compile +endef + +define Package/secubox-app-lyrion-bridge/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_DIR) $(1)/usr/lib/lyrion-bridge + $(INSTALL_CONF) ./files/etc/config/lyrion-bridge $(1)/etc/config/ + $(INSTALL_BIN) ./files/etc/init.d/lyrion-bridge $(1)/etc/init.d/ + $(INSTALL_BIN) ./files/usr/sbin/lyrionstreamctl $(1)/usr/sbin/ + $(INSTALL_BIN) ./files/usr/lib/lyrion-bridge/ffmpeg-bridge.sh $(1)/usr/lib/lyrion-bridge/ +endef + +$(eval $(call BuildPackage,secubox-app-lyrion-bridge)) diff --git a/package/secubox/secubox-app-lyrion-bridge/files/etc/config/lyrion-bridge b/package/secubox/secubox-app-lyrion-bridge/files/etc/config/lyrion-bridge new file mode 100644 index 00000000..fcfb1cf1 --- /dev/null +++ b/package/secubox/secubox-app-lyrion-bridge/files/etc/config/lyrion-bridge @@ -0,0 +1,27 @@ +config bridge 'main' + option enabled '0' + option lyrion_server '127.0.0.1' + option lyrion_port '9000' + option auto_start '1' + +config audio 'audio' + option input_fifo '/tmp/squeezelite.pcm' + option sample_rate '44100' + option channels '2' + option format 's16le' + +config icecast 'icecast' + option host '127.0.0.1' + option port '8000' + option mount '/lyrion' + option password '' + option bitrate '192' + option name 'Lyrion Stream' + option description 'Streaming from Lyrion Music Server' + option genre 'Various' + +config metadata 'metadata' + option sync_enabled '1' + option sync_interval '5' + option show_artist '1' + option show_album '1' diff --git a/package/secubox/secubox-app-lyrion-bridge/files/etc/init.d/lyrion-bridge b/package/secubox/secubox-app-lyrion-bridge/files/etc/init.d/lyrion-bridge new file mode 100644 index 00000000..90b54cc0 --- /dev/null +++ b/package/secubox/secubox-app-lyrion-bridge/files/etc/init.d/lyrion-bridge @@ -0,0 +1,61 @@ +#!/bin/sh /etc/rc.common + +START=96 +STOP=09 +USE_PROCD=1 + +BRIDGE_SCRIPT=/usr/lib/lyrion-bridge/ffmpeg-bridge.sh + +start_service() { + local enabled auto_start + + config_load lyrion-bridge + + config_get enabled main enabled '0' + [ "$enabled" != "1" ] && return 0 + + config_get auto_start main auto_start '1' + + # Ensure Squeezelite is configured for FIFO + local fifo_enabled=$(uci -q get squeezelite.streaming.fifo_output) + if [ "$fifo_enabled" != "1" ]; then + logger -t lyrion-bridge "Enabling Squeezelite FIFO output..." + uci set squeezelite.streaming.fifo_output='1' + uci commit squeezelite + /etc/init.d/squeezelite restart + sleep 2 + fi + + # Get Icecast password from webradio config if not set + local icecast_pass=$(uci -q get lyrion-bridge.icecast.password) + if [ -z "$icecast_pass" ]; then + icecast_pass=$(uci -q get webradio.main.source_password) + [ -n "$icecast_pass" ] && uci set lyrion-bridge.icecast.password="$icecast_pass" + fi + + procd_open_instance + procd_set_param command $BRIDGE_SCRIPT + procd_set_param respawn + procd_set_param stderr 1 + procd_set_param stdout 1 + procd_set_param pidfile /var/run/lyrion-bridge.pid + procd_close_instance + + logger -t lyrion-bridge "Bridge started" +} + +stop_service() { + # Kill ffmpeg processes related to bridge + pkill -f "ffmpeg.*lyrion" 2>/dev/null || true + pkill -f "ffmpeg-bridge.sh" 2>/dev/null || true + logger -t lyrion-bridge "Bridge stopped" +} + +reload_service() { + stop_service + start_service +} + +service_triggers() { + procd_add_reload_trigger "lyrion-bridge" +} diff --git a/package/secubox/secubox-app-lyrion-bridge/files/usr/lib/lyrion-bridge/ffmpeg-bridge.sh b/package/secubox/secubox-app-lyrion-bridge/files/usr/lib/lyrion-bridge/ffmpeg-bridge.sh new file mode 100644 index 00000000..91cb7dcd --- /dev/null +++ b/package/secubox/secubox-app-lyrion-bridge/files/usr/lib/lyrion-bridge/ffmpeg-bridge.sh @@ -0,0 +1,124 @@ +#!/bin/sh +# Lyrion to Icecast Bridge - FFmpeg Audio Pipeline +# Reads PCM from Squeezelite FIFO and streams to Icecast + +LOG_FILE="/var/log/lyrion-bridge.log" + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE" + logger -t lyrion-bridge "$1" +} + +uci_get() { uci -q get "lyrion-bridge.$1" 2>/dev/null || echo "$2"; } + +# Load configuration +INPUT_FIFO=$(uci_get audio.input_fifo "/tmp/squeezelite.pcm") +SAMPLE_RATE=$(uci_get audio.sample_rate "44100") +CHANNELS=$(uci_get audio.channels "2") +FORMAT=$(uci_get audio.format "s16le") + +ICECAST_HOST=$(uci_get icecast.host "127.0.0.1") +ICECAST_PORT=$(uci_get icecast.port "8000") +ICECAST_MOUNT=$(uci_get icecast.mount "/lyrion") +ICECAST_PASS=$(uci_get icecast.password "hackme") +BITRATE=$(uci_get icecast.bitrate "192") +STREAM_NAME=$(uci_get icecast.name "Lyrion Stream") +STREAM_DESC=$(uci_get icecast.description "Streaming from Lyrion Music Server") +STREAM_GENRE=$(uci_get icecast.genre "Various") + +METADATA_SYNC=$(uci_get metadata.sync_enabled "1") +METADATA_INTERVAL=$(uci_get metadata.sync_interval "5") + +log "Starting Lyrion to Icecast bridge..." +log "Input: $INPUT_FIFO (PCM $FORMAT, ${SAMPLE_RATE}Hz, ${CHANNELS}ch)" +log "Output: icecast://${ICECAST_HOST}:${ICECAST_PORT}${ICECAST_MOUNT} (MP3 ${BITRATE}kbps)" + +# Ensure FIFO exists +if [ ! -p "$INPUT_FIFO" ]; then + log "Creating FIFO: $INPUT_FIFO" + mkfifo "$INPUT_FIFO" +fi + +# Wait for Squeezelite to start writing to FIFO +log "Waiting for audio input..." +while [ ! -s "$INPUT_FIFO" ] && [ -p "$INPUT_FIFO" ]; do + sleep 1 +done + +# Build Icecast URL +ICECAST_URL="icecast://source:${ICECAST_PASS}@${ICECAST_HOST}:${ICECAST_PORT}${ICECAST_MOUNT}" + +# Metadata update function (background process) +update_metadata() { + local lyrion_host=$(uci_get main.lyrion_server "127.0.0.1") + local lyrion_port=$(uci_get main.lyrion_port "9000") + + while true; do + # Query Lyrion for current track info + local status=$(curl -s "http://${lyrion_host}:${lyrion_port}/jsonrpc.js" \ + -d '{"id":1,"method":"slim.request","params":["",["status","-",1,"tags:adl"]]}' 2>/dev/null) + + if [ -n "$status" ]; then + local title=$(echo "$status" | jsonfilter -e '@.result.playlist_loop[0].title' 2>/dev/null) + local artist=$(echo "$status" | jsonfilter -e '@.result.playlist_loop[0].artist' 2>/dev/null) + local album=$(echo "$status" | jsonfilter -e '@.result.playlist_loop[0].album' 2>/dev/null) + + if [ -n "$title" ]; then + local metadata="${artist:+$artist - }${title}" + # Update Icecast metadata via admin API + curl -s "http://admin:$(uci -q get webradio.main.admin_password)@${ICECAST_HOST}:${ICECAST_PORT}/admin/metadata?mount=${ICECAST_MOUNT}&mode=updinfo&song=$(echo "$metadata" | sed 's/ /%20/g')" >/dev/null 2>&1 + fi + fi + + sleep "$METADATA_INTERVAL" + done +} + +# Start metadata sync in background +if [ "$METADATA_SYNC" = "1" ]; then + update_metadata & + METADATA_PID=$! + log "Metadata sync started (PID: $METADATA_PID)" +fi + +# Cleanup on exit +cleanup() { + log "Stopping bridge..." + [ -n "$METADATA_PID" ] && kill $METADATA_PID 2>/dev/null + [ -n "$FFMPEG_PID" ] && kill $FFMPEG_PID 2>/dev/null + exit 0 +} +trap cleanup INT TERM + +# Main streaming loop +while true; do + log "Starting FFmpeg stream..." + + ffmpeg -re \ + -f $FORMAT -ar $SAMPLE_RATE -ac $CHANNELS \ + -i "$INPUT_FIFO" \ + -acodec libmp3lame -ab ${BITRATE}k -ar $SAMPLE_RATE -ac $CHANNELS \ + -f mp3 \ + -content_type audio/mpeg \ + "$ICECAST_URL" \ + 2>> "$LOG_FILE" & + + FFMPEG_PID=$! + log "FFmpeg started (PID: $FFMPEG_PID)" + + # Wait for FFmpeg to exit + wait $FFMPEG_PID + EXIT_CODE=$? + + log "FFmpeg exited with code $EXIT_CODE" + + # If exited normally (0 or 255), it means stream ended - wait and retry + if [ $EXIT_CODE -eq 0 ] || [ $EXIT_CODE -eq 255 ]; then + log "Stream ended, waiting for new audio..." + sleep 2 + else + # Error - wait longer before retry + log "Stream error, retrying in 5 seconds..." + sleep 5 + fi +done diff --git a/package/secubox/secubox-app-lyrion-bridge/files/usr/sbin/lyrionstreamctl b/package/secubox/secubox-app-lyrion-bridge/files/usr/sbin/lyrionstreamctl new file mode 100644 index 00000000..aaaf6a51 --- /dev/null +++ b/package/secubox/secubox-app-lyrion-bridge/files/usr/sbin/lyrionstreamctl @@ -0,0 +1,337 @@ +#!/bin/sh +# Lyrion Stream Controller - Bridge Management CLI + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { echo -e "${GREEN}[LyrionStream]${NC} $1"; } +warn() { echo -e "${YELLOW}[LyrionStream]${NC} $1"; } +error() { echo -e "${RED}[LyrionStream]${NC} $1" >&2; } + +uci_get() { uci -q get "lyrion-bridge.$1" 2>/dev/null || echo "$2"; } + +#--- Status --- +cmd_status() { + echo -e "${CYAN}=== Lyrion Stream Bridge Status ===${NC}" + + local enabled=$(uci_get main.enabled 0) + local lyrion_server=$(uci_get main.lyrion_server "127.0.0.1") + local lyrion_port=$(uci_get main.lyrion_port "9000") + + echo "Bridge Enabled: $([ "$enabled" = "1" ] && echo "Yes" || echo "No")" + echo "" + + # Check Lyrion + echo -e "${CYAN}Lyrion Server:${NC}" + if curl -s "http://${lyrion_server}:${lyrion_port}/status.html" >/dev/null 2>&1; then + echo -e " Status: ${GREEN}Online${NC} (${lyrion_server}:${lyrion_port})" + + # Get current playing info + local status=$(curl -s "http://${lyrion_server}:${lyrion_port}/jsonrpc.js" \ + -d '{"id":1,"method":"slim.request","params":["",["status","-",1,"tags:adl"]]}' 2>/dev/null) + + if [ -n "$status" ]; then + local title=$(echo "$status" | jsonfilter -e '@.result.playlist_loop[0].title' 2>/dev/null) + local artist=$(echo "$status" | jsonfilter -e '@.result.playlist_loop[0].artist' 2>/dev/null) + local mode=$(echo "$status" | jsonfilter -e '@.result.mode' 2>/dev/null) + + echo " Mode: $mode" + [ -n "$title" ] && echo " Now Playing: ${artist:+$artist - }$title" + fi + else + echo -e " Status: ${RED}Offline${NC}" + fi + + echo "" + + # Check Squeezelite + echo -e "${CYAN}Squeezelite Player:${NC}" + if pgrep -f "squeezelite" >/dev/null 2>&1; then + echo -e " Status: ${GREEN}Running${NC}" + local fifo=$(uci -q get squeezelite.streaming.fifo_path) + [ -p "$fifo" ] && echo " FIFO: $fifo (active)" || echo " FIFO: Not configured" + else + echo -e " Status: ${RED}Stopped${NC}" + fi + + echo "" + + # Check FFmpeg Bridge + echo -e "${CYAN}FFmpeg Bridge:${NC}" + if pgrep -f "ffmpeg-bridge.sh" >/dev/null 2>&1 || pgrep -f "ffmpeg.*lyrion" >/dev/null 2>&1; then + echo -e " Status: ${GREEN}Running${NC}" + else + echo -e " Status: ${YELLOW}Stopped${NC}" + fi + + echo "" + + # Check Icecast mount + echo -e "${CYAN}Icecast Output:${NC}" + local icecast_host=$(uci_get icecast.host "127.0.0.1") + local icecast_port=$(uci_get icecast.port "8000") + local icecast_mount=$(uci_get icecast.mount "/lyrion") + + local mount_status=$(curl -s "http://${icecast_host}:${icecast_port}/status-json.xsl" 2>/dev/null | \ + jsonfilter -e "@.icestats.source[@.listenurl='http://${icecast_host}:${icecast_port}${icecast_mount}']" 2>/dev/null) + + if [ -n "$mount_status" ]; then + local listeners=$(echo "$mount_status" | jsonfilter -e '@.listeners' 2>/dev/null || echo "0") + echo -e " Mount: ${GREEN}Active${NC} (${icecast_mount})" + echo " Listeners: $listeners" + echo " URL: http://${icecast_host}:${icecast_port}${icecast_mount}" + else + echo -e " Mount: ${YELLOW}Inactive${NC} (${icecast_mount})" + fi +} + +#--- Setup Full Pipeline --- +cmd_setup() { + local lyrion_server="${1:-127.0.0.1}" + + log "Setting up Lyrion → WebRadio bridge..." + + # Step 1: Configure Squeezelite + log "Step 1: Configuring Squeezelite..." + uci set squeezelite.main.enabled='1' + uci set squeezelite.main.server="$lyrion_server" + uci set squeezelite.streaming.fifo_output='1' + uci set squeezelite.streaming.fifo_path='/tmp/squeezelite.pcm' + uci commit squeezelite + + # Step 2: Configure bridge + log "Step 2: Configuring bridge..." + local icecast_pass=$(uci -q get webradio.main.source_password || echo "hackme") + uci set lyrion-bridge.main.enabled='1' + uci set lyrion-bridge.main.lyrion_server="$lyrion_server" + uci set lyrion-bridge.icecast.password="$icecast_pass" + uci commit lyrion-bridge + + # Step 3: Create FIFO + log "Step 3: Creating audio FIFO..." + local fifo_path=$(uci_get audio.input_fifo "/tmp/squeezelite.pcm") + [ -p "$fifo_path" ] || mkfifo "$fifo_path" + + # Step 4: Start services + log "Step 4: Starting services..." + /etc/init.d/squeezelite restart + sleep 2 + /etc/init.d/lyrion-bridge start + + log "Setup complete!" + echo "" + cmd_status +} + +#--- Quick Start --- +cmd_start() { + log "Starting Lyrion stream bridge..." + + # Ensure Squeezelite is running with FIFO + if ! pgrep -f "squeezelite" >/dev/null 2>&1; then + log "Starting Squeezelite..." + /etc/init.d/squeezelite start + sleep 2 + fi + + # Start bridge + /etc/init.d/lyrion-bridge start + + sleep 2 + if pgrep -f "ffmpeg-bridge.sh" >/dev/null 2>&1; then + log "Bridge started" + local icecast_port=$(uci_get icecast.port "8000") + local icecast_mount=$(uci_get icecast.mount "/lyrion") + echo "Stream URL: http://127.0.0.1:${icecast_port}${icecast_mount}" + else + error "Failed to start bridge" + return 1 + fi +} + +#--- Stop --- +cmd_stop() { + log "Stopping Lyrion stream bridge..." + /etc/init.d/lyrion-bridge stop + log "Bridge stopped" +} + +#--- Restart --- +cmd_restart() { + cmd_stop + sleep 1 + cmd_start +} + +#--- Enable/Disable --- +cmd_enable() { + uci set lyrion-bridge.main.enabled='1' + uci commit lyrion-bridge + /etc/init.d/lyrion-bridge enable + log "Bridge enabled" +} + +cmd_disable() { + uci set lyrion-bridge.main.enabled='0' + uci commit lyrion-bridge + /etc/init.d/lyrion-bridge disable + log "Bridge disabled" +} + +#--- Configure Icecast Settings --- +cmd_config() { + case "$1" in + mount) + if [ -n "$2" ]; then + uci set lyrion-bridge.icecast.mount="$2" + uci commit lyrion-bridge + log "Mount point set to: $2" + else + echo "Mount: $(uci_get icecast.mount '/lyrion')" + fi + ;; + bitrate) + if [ -n "$2" ]; then + uci set lyrion-bridge.icecast.bitrate="$2" + uci commit lyrion-bridge + log "Bitrate set to: ${2}kbps" + else + echo "Bitrate: $(uci_get icecast.bitrate '192')kbps" + fi + ;; + name) + shift + if [ -n "$1" ]; then + uci set lyrion-bridge.icecast.name="$*" + uci commit lyrion-bridge + log "Stream name set to: $*" + else + echo "Name: $(uci_get icecast.name 'Lyrion Stream')" + fi + ;; + server) + if [ -n "$2" ]; then + uci set lyrion-bridge.main.lyrion_server="$2" + uci commit lyrion-bridge + log "Lyrion server set to: $2" + else + echo "Lyrion Server: $(uci_get main.lyrion_server '127.0.0.1')" + fi + ;; + *) + echo "Usage: lyrionstreamctl config {mount|bitrate|name|server} [value]" + echo "" + echo "Current settings:" + echo " Lyrion Server: $(uci_get main.lyrion_server '127.0.0.1')" + echo " Mount Point: $(uci_get icecast.mount '/lyrion')" + echo " Bitrate: $(uci_get icecast.bitrate '192')kbps" + echo " Stream Name: $(uci_get icecast.name 'Lyrion Stream')" + ;; + esac +} + +#--- View Logs --- +cmd_logs() { + local lines="${1:-50}" + if [ -f /var/log/lyrion-bridge.log ]; then + tail -n "$lines" /var/log/lyrion-bridge.log + else + echo "No logs available" + fi +} + +#--- Expose via HAProxy --- +cmd_expose() { + local domain="$1" + + if [ -z "$domain" ]; then + error "Usage: lyrionstreamctl expose " + return 1 + fi + + local icecast_port=$(uci_get icecast.port "8000") + local icecast_mount=$(uci_get icecast.mount "/lyrion") + + log "Exposing Lyrion stream on $domain..." + + if command -v haproxyctl >/dev/null 2>&1; then + # Create backend for Icecast + haproxyctl backend add lyrion_stream 127.0.0.1 "$icecast_port" 2>/dev/null || true + # Create vhost + haproxyctl vhost add "$domain" lyrion_stream + haproxyctl reload + + log "Stream exposed at: https://$domain${icecast_mount}" + else + error "haproxyctl not available" + return 1 + fi +} + +#--- Help --- +cmd_help() { + cat < [options] + +${GREEN}Quick Start:${NC} + setup [lyrion-ip] Configure and start full pipeline + start Start the bridge + stop Stop the bridge + restart Restart the bridge + +${GREEN}Service Control:${NC} + status Show detailed status + enable Enable autostart + disable Disable autostart + +${GREEN}Configuration:${NC} + config Show all settings + config mount [path] Set Icecast mount point + config bitrate [kb] Set stream bitrate + config name [name] Set stream name + config server [ip] Set Lyrion server IP + +${GREEN}Operations:${NC} + expose Expose stream via HAProxy/SSL + logs [lines] View bridge logs + +${GREEN}Examples:${NC} + lyrionstreamctl setup 192.168.1.100 + lyrionstreamctl config bitrate 256 + lyrionstreamctl expose radio.secubox.in + +${GREEN}Architecture:${NC} + Lyrion Server → Squeezelite (FIFO) → FFmpeg → Icecast + +EOF +} + +#--- Main --- +case "$1" in + status) cmd_status ;; + setup) shift; cmd_setup "$@" ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + enable) cmd_enable ;; + disable) cmd_disable ;; + config) shift; cmd_config "$@" ;; + expose) shift; cmd_expose "$@" ;; + logs) shift; cmd_logs "$@" ;; + help|--help|-h|"") + cmd_help + ;; + *) + error "Unknown command: $1" + cmd_help + exit 1 + ;; +esac diff --git a/package/secubox/secubox-app-squeezelite/Makefile b/package/secubox/secubox-app-squeezelite/Makefile new file mode 100644 index 00000000..4bbbed64 --- /dev/null +++ b/package/secubox/secubox-app-squeezelite/Makefile @@ -0,0 +1,42 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-app-squeezelite +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=Gerald Kerma +PKG_LICENSE:=MIT + +include $(INCLUDE_DIR)/package.mk + +define Package/secubox-app-squeezelite + SECTION:=secubox + CATEGORY:=SecuBox + TITLE:=SecuBox Squeezelite Player + DEPENDS:=+squeezelite +alsa-lib + PKGARCH:=all +endef + +define Package/secubox-app-squeezelite/description + Virtual Squeezebox player for SecuBox. + Connects to Lyrion Music Server and can output to various sinks. + Provides squeezelitectl CLI for management. +endef + +define Package/secubox-app-squeezelite/conffiles +/etc/config/squeezelite +endef + +define Build/Compile +endef + +define Package/secubox-app-squeezelite/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_CONF) ./files/etc/config/squeezelite $(1)/etc/config/ + $(INSTALL_BIN) ./files/etc/init.d/squeezelite $(1)/etc/init.d/ + $(INSTALL_BIN) ./files/usr/sbin/squeezelitectl $(1)/usr/sbin/ +endef + +$(eval $(call BuildPackage,secubox-app-squeezelite)) diff --git a/package/secubox/secubox-app-squeezelite/files/etc/config/squeezelite b/package/secubox/secubox-app-squeezelite/files/etc/config/squeezelite new file mode 100644 index 00000000..91bf4443 --- /dev/null +++ b/package/secubox/secubox-app-squeezelite/files/etc/config/squeezelite @@ -0,0 +1,21 @@ +config squeezelite 'main' + option enabled '0' + option name 'SecuBox-Player' + option server '' + option server_port '3483' + option auto_discover '1' + option output 'default' + option mac '' + option model 'squeezelite' + +config audio 'audio' + option sample_rate '44100' + option buffer_size '2000' + option codec_buffer '2000' + option alsa_buffer '80' + option alsa_period '4' + +config streaming 'streaming' + option fifo_output '0' + option fifo_path '/tmp/squeezelite.pcm' + option visualizer '0' diff --git a/package/secubox/secubox-app-squeezelite/files/etc/init.d/squeezelite b/package/secubox/secubox-app-squeezelite/files/etc/init.d/squeezelite new file mode 100644 index 00000000..d8716703 --- /dev/null +++ b/package/secubox/secubox-app-squeezelite/files/etc/init.d/squeezelite @@ -0,0 +1,80 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 + +PROG=/usr/bin/squeezelite + +start_service() { + local enabled name server server_port auto_discover output mac model + local sample_rate buffer_size codec_buffer alsa_buffer alsa_period + local fifo_output fifo_path + + config_load squeezelite + + config_get enabled main enabled '0' + [ "$enabled" != "1" ] && return 0 + + config_get name main name 'SecuBox-Player' + config_get server main server '' + config_get server_port main server_port '3483' + config_get auto_discover main auto_discover '1' + config_get output main output 'default' + config_get mac main mac '' + config_get model main model 'squeezelite' + + config_get sample_rate audio sample_rate '44100' + config_get buffer_size audio buffer_size '2000' + config_get codec_buffer audio codec_buffer '2000' + config_get alsa_buffer audio alsa_buffer '80' + config_get alsa_period audio alsa_period '4' + + config_get fifo_output streaming fifo_output '0' + config_get fifo_path streaming fifo_path '/tmp/squeezelite.pcm' + + # Build command line + local cmd_args="-n $name -o $output" + + # Server connection + if [ -n "$server" ]; then + cmd_args="$cmd_args -s $server:$server_port" + fi + + # MAC address (auto-generate if not set) + if [ -n "$mac" ]; then + cmd_args="$cmd_args -m $mac" + fi + + # Model name + cmd_args="$cmd_args -M $model" + + # Audio settings + cmd_args="$cmd_args -r $sample_rate" + cmd_args="$cmd_args -b ${buffer_size}:${codec_buffer}" + cmd_args="$cmd_args -a ${alsa_buffer}:${alsa_period}" + + # FIFO output for streaming bridge + if [ "$fifo_output" = "1" ]; then + # Create FIFO if not exists + [ -p "$fifo_path" ] || mkfifo "$fifo_path" + cmd_args="$cmd_args -o $fifo_path" + fi + + procd_open_instance + procd_set_param command $PROG $cmd_args + procd_set_param respawn + procd_set_param stderr 1 + procd_set_param pidfile /var/run/squeezelite.pid + procd_close_instance + + logger -t squeezelite "Started: $name" +} + +stop_service() { + logger -t squeezelite "Stopped" +} + +service_triggers() { + procd_add_reload_trigger "squeezelite" +} diff --git a/package/secubox/secubox-app-squeezelite/files/usr/sbin/squeezelitectl b/package/secubox/secubox-app-squeezelite/files/usr/sbin/squeezelitectl new file mode 100644 index 00000000..3aa7c72f --- /dev/null +++ b/package/secubox/secubox-app-squeezelite/files/usr/sbin/squeezelitectl @@ -0,0 +1,281 @@ +#!/bin/sh +# Squeezelite Controller - SecuBox Virtual Player CLI + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { echo -e "${GREEN}[Squeezelite]${NC} $1"; } +warn() { echo -e "${YELLOW}[Squeezelite]${NC} $1"; } +error() { echo -e "${RED}[Squeezelite]${NC} $1" >&2; } + +uci_get() { uci -q get "squeezelite.$1" 2>/dev/null || echo "$2"; } + +#--- Status --- +cmd_status() { + echo -e "${CYAN}=== Squeezelite Player Status ===${NC}" + + local enabled=$(uci_get main.enabled 0) + local name=$(uci_get main.name "SecuBox-Player") + local server=$(uci_get main.server "") + local auto_discover=$(uci_get main.auto_discover 1) + + echo "Player Name: $name" + echo "Enabled: $([ "$enabled" = "1" ] && echo "Yes" || echo "No")" + + if pgrep -f "squeezelite" >/dev/null 2>&1; then + echo -e "Process: ${GREEN}Running${NC}" + local pid=$(pgrep -f "squeezelite" | head -1) + echo "PID: $pid" + else + echo -e "Process: ${RED}Stopped${NC}" + fi + + echo "" + if [ -n "$server" ]; then + echo "Server: $server" + elif [ "$auto_discover" = "1" ]; then + echo "Server: Auto-discover (Lyrion on network)" + else + echo "Server: Not configured" + fi + + # Check Lyrion availability + local lyrion_host=$(uci_get main.server "127.0.0.1") + if curl -s "http://${lyrion_host}:9000/status.html" >/dev/null 2>&1; then + echo -e "Lyrion Server: ${GREEN}Available${NC}" + else + echo -e "Lyrion Server: ${YELLOW}Not detected${NC}" + fi +} + +#--- Discover Lyrion --- +cmd_discover() { + log "Searching for Lyrion Music Server..." + + # Check localhost first + if curl -s "http://127.0.0.1:9000/status.html" >/dev/null 2>&1; then + log "Found Lyrion at 127.0.0.1:9000" + echo "127.0.0.1" + return 0 + fi + + # Check LXC container + local lxc_ip=$(lxc-info -n lyrion 2>/dev/null | grep "IP:" | awk '{print $2}' | head -1) + if [ -n "$lxc_ip" ] && curl -s "http://${lxc_ip}:9000/status.html" >/dev/null 2>&1; then + log "Found Lyrion in LXC at $lxc_ip:9000" + echo "$lxc_ip" + return 0 + fi + + # Network scan for Slim Protocol (port 3483) + for ip in $(ip route | grep -oE "192\.168\.[0-9]+\.[0-9]+" | head -1 | sed 's/\.[0-9]*$/.1/'); do + local subnet=$(echo $ip | sed 's/\.[0-9]*$//') + for host in $(seq 1 254); do + local target="${subnet}.${host}" + if nc -z -w 1 "$target" 3483 2>/dev/null; then + log "Found Lyrion at $target" + echo "$target" + return 0 + fi + done & + done + wait + + error "No Lyrion server found" + return 1 +} + +#--- Connect to Server --- +cmd_connect() { + local server="$1" + + if [ -z "$server" ]; then + # Auto-discover + server=$(cmd_discover 2>/dev/null) + if [ -z "$server" ]; then + error "No server specified and auto-discover failed" + return 1 + fi + fi + + log "Connecting to Lyrion at $server..." + + uci set squeezelite.main.server="$server" + uci set squeezelite.main.enabled='1' + uci commit squeezelite + + /etc/init.d/squeezelite restart + + sleep 2 + if pgrep -f "squeezelite" >/dev/null 2>&1; then + log "Connected to $server" + else + error "Failed to connect" + return 1 + fi +} + +#--- Disconnect --- +cmd_disconnect() { + log "Disconnecting..." + /etc/init.d/squeezelite stop + uci set squeezelite.main.enabled='0' + uci commit squeezelite + log "Disconnected" +} + +#--- Enable FIFO Output (for streaming) --- +cmd_fifo() { + case "$1" in + enable) + local path="${2:-/tmp/squeezelite.pcm}" + log "Enabling FIFO output to $path..." + uci set squeezelite.streaming.fifo_output='1' + uci set squeezelite.streaming.fifo_path="$path" + uci commit squeezelite + + # Create FIFO + [ -p "$path" ] || mkfifo "$path" + + log "FIFO enabled. Restart player to apply." + ;; + disable) + log "Disabling FIFO output..." + uci set squeezelite.streaming.fifo_output='0' + uci commit squeezelite + log "FIFO disabled. Restart player to apply." + ;; + status) + local enabled=$(uci_get streaming.fifo_output 0) + local path=$(uci_get streaming.fifo_path "/tmp/squeezelite.pcm") + echo "FIFO Output: $([ "$enabled" = "1" ] && echo "Enabled" || echo "Disabled")" + echo "FIFO Path: $path" + [ -p "$path" ] && echo "FIFO exists: Yes" || echo "FIFO exists: No" + ;; + *) + echo "Usage: squeezelitectl fifo {enable|disable|status} [path]" + ;; + esac +} + +#--- List Audio Devices --- +cmd_devices() { + echo -e "${CYAN}=== Audio Output Devices ===${NC}" + if command -v aplay >/dev/null 2>&1; then + aplay -L 2>/dev/null | grep -E "^(default|hw:|plughw:|sysdefault)" | head -20 + else + echo "alsa-utils not installed" + fi +} + +#--- Set Output Device --- +cmd_output() { + local device="$1" + + if [ -z "$device" ]; then + echo "Current output: $(uci_get main.output 'default')" + echo "" + echo "Available devices:" + cmd_devices + return + fi + + log "Setting output to: $device" + uci set squeezelite.main.output="$device" + uci commit squeezelite + log "Output set. Restart player to apply." +} + +#--- Service Control --- +cmd_start() { + /etc/init.d/squeezelite start +} + +cmd_stop() { + /etc/init.d/squeezelite stop +} + +cmd_restart() { + /etc/init.d/squeezelite restart +} + +cmd_enable() { + uci set squeezelite.main.enabled='1' + uci commit squeezelite + /etc/init.d/squeezelite enable + log "Squeezelite enabled" +} + +cmd_disable() { + uci set squeezelite.main.enabled='0' + uci commit squeezelite + /etc/init.d/squeezelite disable + log "Squeezelite disabled" +} + +#--- Help --- +cmd_help() { + cat < [options] + +${GREEN}Service Commands:${NC} + status Show player status + start Start player + stop Stop player + restart Restart player + enable Enable autostart + disable Disable autostart + +${GREEN}Connection:${NC} + discover Find Lyrion servers on network + connect [server] Connect to Lyrion (auto-discover if no server) + disconnect Disconnect from server + +${GREEN}Audio:${NC} + devices List audio output devices + output [device] Set/show output device + +${GREEN}Streaming:${NC} + fifo enable [path] Enable FIFO output for streaming bridge + fifo disable Disable FIFO output + fifo status Show FIFO status + +${GREEN}Examples:${NC} + squeezelitectl connect 192.168.1.100 + squeezelitectl fifo enable /tmp/lyrion.pcm + squeezelitectl output hw:0,0 + +EOF +} + +#--- Main --- +case "$1" in + status) cmd_status ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + enable) cmd_enable ;; + disable) cmd_disable ;; + discover) cmd_discover ;; + connect) shift; cmd_connect "$@" ;; + disconnect) cmd_disconnect ;; + devices) cmd_devices ;; + output) shift; cmd_output "$@" ;; + fifo) shift; cmd_fifo "$@" ;; + help|--help|-h|"") + cmd_help + ;; + *) + error "Unknown command: $1" + cmd_help + exit 1 + ;; +esac diff --git a/package/secubox/secubox-app-turn/Makefile b/package/secubox/secubox-app-turn/Makefile new file mode 100644 index 00000000..f70601f7 --- /dev/null +++ b/package/secubox/secubox-app-turn/Makefile @@ -0,0 +1,42 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-app-turn +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=Gerald Kerma +PKG_LICENSE:=MIT + +include $(INCLUDE_DIR)/package.mk + +define Package/secubox-app-turn + SECTION:=secubox + CATEGORY:=SecuBox + TITLE:=SecuBox TURN/STUN Server + DEPENDS:=+coturn + PKGARCH:=all +endef + +define Package/secubox-app-turn/description + TURN/STUN server for WebRTC NAT traversal. + Required for Jitsi Meet when direct P2P connections fail. + Provides turnctl CLI for management. +endef + +define Package/secubox-app-turn/conffiles +/etc/config/turn +endef + +define Build/Compile +endef + +define Package/secubox-app-turn/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_CONF) ./files/etc/config/turn $(1)/etc/config/ + $(INSTALL_BIN) ./files/etc/init.d/turn $(1)/etc/init.d/ + $(INSTALL_BIN) ./files/usr/sbin/turnctl $(1)/usr/sbin/ +endef + +$(eval $(call BuildPackage,secubox-app-turn)) diff --git a/package/secubox/secubox-app-turn/files/etc/config/turn b/package/secubox/secubox-app-turn/files/etc/config/turn new file mode 100644 index 00000000..0bcdbe22 --- /dev/null +++ b/package/secubox/secubox-app-turn/files/etc/config/turn @@ -0,0 +1,26 @@ +config server 'main' + option enabled '0' + option realm 'turn.secubox.in' + option listening_port '3478' + option tls_port '5349' + option min_port '49152' + option max_port '65535' + option external_ip '' + option use_auth_secret '1' + option static_auth_secret '' + option verbose '0' + +config ssl 'ssl' + option cert_path '/etc/ssl/turn/cert.pem' + option key_path '/etc/ssl/turn/key.pem' + option use_acme '1' + +config limits 'limits' + option total_quota '100' + option bps_capacity '0' + option user_quota '0' + option max_bps '0' + +config log 'log' + option log_file '/var/log/turnserver.log' + option syslog '1' diff --git a/package/secubox/secubox-app-turn/files/etc/init.d/turn b/package/secubox/secubox-app-turn/files/etc/init.d/turn new file mode 100644 index 00000000..efc8d95e --- /dev/null +++ b/package/secubox/secubox-app-turn/files/etc/init.d/turn @@ -0,0 +1,147 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 + +TURN_CONF=/var/run/turnserver.conf + +generate_config() { + local enabled realm listening_port tls_port min_port max_port + local external_ip use_auth_secret static_auth_secret verbose + local cert_path key_path + local total_quota bps_capacity user_quota max_bps + local log_file syslog + + config_load turn + + config_get enabled main enabled '0' + [ "$enabled" != "1" ] && return 1 + + config_get realm main realm 'turn.secubox.in' + config_get listening_port main listening_port '3478' + config_get tls_port main tls_port '5349' + config_get min_port main min_port '49152' + config_get max_port main max_port '65535' + config_get external_ip main external_ip '' + config_get use_auth_secret main use_auth_secret '1' + config_get static_auth_secret main static_auth_secret '' + config_get verbose main verbose '0' + + config_get cert_path ssl cert_path '/etc/ssl/turn/cert.pem' + config_get key_path ssl key_path '/etc/ssl/turn/key.pem' + + config_get total_quota limits total_quota '100' + config_get bps_capacity limits bps_capacity '0' + config_get user_quota limits user_quota '0' + config_get max_bps limits max_bps '0' + + config_get log_file log log_file '/var/log/turnserver.log' + config_get syslog log syslog '1' + + # Auto-detect external IP if not set + if [ -z "$external_ip" ]; then + external_ip=$(curl -s -4 https://ifconfig.me 2>/dev/null || curl -s -4 https://api.ipify.org 2>/dev/null) + fi + + # Generate secret if not set + if [ -z "$static_auth_secret" ]; then + static_auth_secret=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 32) + uci set turn.main.static_auth_secret="$static_auth_secret" + uci commit turn + logger -t turn "Generated new static auth secret" + fi + + cat > "$TURN_CONF" <> "$TURN_CONF" + echo "static-auth-secret=$static_auth_secret" >> "$TURN_CONF" + fi + + # External IP + [ -n "$external_ip" ] && echo "external-ip=$external_ip" >> "$TURN_CONF" + + # Port range + echo "min-port=$min_port" >> "$TURN_CONF" + echo "max-port=$max_port" >> "$TURN_CONF" + + # TLS certificates + if [ -f "$cert_path" ] && [ -f "$key_path" ]; then + echo "cert=$cert_path" >> "$TURN_CONF" + echo "pkey=$key_path" >> "$TURN_CONF" + fi + + # Limits + [ "$total_quota" != "0" ] && echo "total-quota=$total_quota" >> "$TURN_CONF" + [ "$bps_capacity" != "0" ] && echo "bps-capacity=$bps_capacity" >> "$TURN_CONF" + [ "$user_quota" != "0" ] && echo "user-quota=$user_quota" >> "$TURN_CONF" + [ "$max_bps" != "0" ] && echo "max-bps=$max_bps" >> "$TURN_CONF" + + # Logging + [ "$syslog" = "1" ] && echo "syslog" >> "$TURN_CONF" + [ -n "$log_file" ] && echo "log-file=$log_file" >> "$TURN_CONF" + [ "$verbose" = "1" ] && echo "verbose" >> "$TURN_CONF" + + # Additional hardening + cat >> "$TURN_CONF" <&2; } + +uci_get() { uci -q get "turn.$1" 2>/dev/null || echo "$2"; } + +#--- Status --- +cmd_status() { + echo -e "${CYAN}=== TURN Server Status ===${NC}" + + local enabled=$(uci_get main.enabled 0) + local realm=$(uci_get main.realm "turn.secubox.in") + local port=$(uci_get main.listening_port "3478") + local tls_port=$(uci_get main.tls_port "5349") + local external_ip=$(uci_get main.external_ip "") + + echo "Enabled: $([ "$enabled" = "1" ] && echo "Yes" || echo "No")" + echo "Realm: $realm" + echo "" + + if pgrep -f "turnserver" >/dev/null 2>&1; then + echo -e "Process: ${GREEN}Running${NC}" + local pid=$(pgrep -f "turnserver" | head -1) + echo "PID: $pid" + else + echo -e "Process: ${RED}Stopped${NC}" + fi + + echo "" + echo -e "${CYAN}Ports:${NC}" + echo " TURN/STUN (UDP/TCP): $port" + echo " TURN TLS: $tls_port" + + echo "" + echo -e "${CYAN}Network:${NC}" + if [ -n "$external_ip" ]; then + echo " External IP: $external_ip" + else + local detected=$(curl -s -4 https://ifconfig.me 2>/dev/null || echo "unknown") + echo " External IP: $detected (auto-detected)" + fi + + # Check if ports are open + echo "" + echo -e "${CYAN}Port Status:${NC}" + if grep -q ":0D92 " /proc/net/udp 2>/dev/null; then + echo -e " UDP $port: ${GREEN}Listening${NC}" + else + echo -e " UDP $port: ${YELLOW}Not listening${NC}" + fi + + if grep -q ":14E5 " /proc/net/tcp 2>/dev/null; then + echo -e " TCP $tls_port: ${GREEN}Listening${NC}" + else + echo -e " TCP $tls_port: ${YELLOW}Not listening${NC}" + fi +} + +#--- Setup TURN for Jitsi --- +cmd_setup_jitsi() { + local domain="${1:-jitsi.secubox.in}" + local turn_domain="${2:-turn.secubox.in}" + + log "Setting up TURN for Jitsi Meet..." + + # Enable TURN + uci set turn.main.enabled='1' + uci set turn.main.realm="$turn_domain" + + # Auto-detect external IP + local external_ip=$(curl -s -4 https://ifconfig.me 2>/dev/null) + if [ -n "$external_ip" ]; then + uci set turn.main.external_ip="$external_ip" + log "Detected external IP: $external_ip" + fi + + uci commit turn + + # Start TURN server + /etc/init.d/turn restart + sleep 2 + + # Get auth secret + local auth_secret=$(uci_get main.static_auth_secret "") + + log "TURN server configured!" + echo "" + echo -e "${CYAN}Jitsi Meet Configuration:${NC}" + echo "" + echo "Add to your Jitsi config.js:" + echo "" + echo " p2p: {" + echo " stunServers: [" + echo " { urls: 'stun:${turn_domain}:3478' }" + echo " ]" + echo " }," + echo "" + echo "Add to your Prosody config:" + echo "" + echo " turncredentials_secret = \"${auth_secret}\";" + echo " turncredentials = {" + echo " { type = \"stun\", host = \"${turn_domain}\", port = \"3478\" }," + echo " { type = \"turn\", host = \"${turn_domain}\", port = \"3478\", transport = \"udp\" }," + echo " { type = \"turns\", host = \"${turn_domain}\", port = \"5349\", transport = \"tcp\" }" + echo " };" + echo "" +} + +#--- Generate Credentials --- +cmd_credentials() { + local username="${1:-$(date +%s)}" + local ttl="${2:-86400}" + + local auth_secret=$(uci_get main.static_auth_secret "") + if [ -z "$auth_secret" ]; then + error "No auth secret configured. Run 'turnctl setup-jitsi' first." + return 1 + fi + + local realm=$(uci_get main.realm "turn.secubox.in") + local timestamp=$(($(date +%s) + ttl)) + local temp_username="${timestamp}:${username}" + + # HMAC-SHA1 credential generation + local password=$(echo -n "$temp_username" | openssl dgst -sha1 -hmac "$auth_secret" -binary | base64) + + echo -e "${CYAN}=== TURN Credentials ===${NC}" + echo "" + echo "Realm: $realm" + echo "Username: $temp_username" + echo "Password: $password" + echo "TTL: ${ttl}s (expires: $(date -d @$timestamp 2>/dev/null || date -r $timestamp))" + echo "" + echo "ICE Server config:" + echo "{" + echo " \"urls\": [\"turn:${realm}:3478\", \"turn:${realm}:3478?transport=tcp\"]," + echo " \"username\": \"${temp_username}\"," + echo " \"credential\": \"${password}\"" + echo "}" +} + +#--- Test TURN Server --- +cmd_test() { + local host="${1:-$(uci_get main.realm "turn.secubox.in")}" + + log "Testing TURN server at $host..." + + # Test STUN binding + echo "" + echo -e "${CYAN}STUN Test:${NC}" + if command -v stun-client >/dev/null 2>&1; then + stun-client "$host" 3478 2>&1 | head -5 + elif command -v nc >/dev/null 2>&1; then + if nc -u -z -w 2 "$host" 3478 2>/dev/null; then + echo -e " UDP 3478: ${GREEN}Reachable${NC}" + else + echo -e " UDP 3478: ${RED}Unreachable${NC}" + fi + + if nc -z -w 2 "$host" 5349 2>/dev/null; then + echo -e " TCP 5349: ${GREEN}Reachable${NC}" + else + echo -e " TCP 5349: ${RED}Unreachable${NC}" + fi + else + warn "No test tools available (stun-client or nc)" + fi +} + +#--- Expose via HAProxy --- +cmd_expose() { + local domain="${1:-turn.secubox.in}" + + log "Exposing TURN on $domain..." + + # TURN typically needs direct port access, not reverse proxy + # But we can expose the REST API or add DNS records + + if command -v dnsctl >/dev/null 2>&1; then + local external_ip=$(uci_get main.external_ip "") + if [ -z "$external_ip" ]; then + external_ip=$(curl -s -4 https://ifconfig.me 2>/dev/null) + fi + + if [ -n "$external_ip" ]; then + log "Adding DNS record: $domain -> $external_ip" + dnsctl record add "$domain" A "$external_ip" + fi + fi + + # Open firewall ports + log "Configuring firewall..." + + # Check if rules already exist + if ! uci -q get firewall.turn_stun >/dev/null 2>&1; then + uci add firewall rule + uci rename firewall.@rule[-1]='turn_stun' + uci set firewall.turn_stun.name='Allow-TURN-STUN' + uci set firewall.turn_stun.src='wan' + uci set firewall.turn_stun.dest_port='3478' + uci set firewall.turn_stun.proto='udp tcp' + uci set firewall.turn_stun.target='ACCEPT' + + uci add firewall rule + uci rename firewall.@rule[-1]='turn_tls' + uci set firewall.turn_tls.name='Allow-TURN-TLS' + uci set firewall.turn_tls.src='wan' + uci set firewall.turn_tls.dest_port='5349' + uci set firewall.turn_tls.proto='tcp' + uci set firewall.turn_tls.target='ACCEPT' + + uci add firewall rule + uci rename firewall.@rule[-1]='turn_relay' + uci set firewall.turn_relay.name='Allow-TURN-Relay' + uci set firewall.turn_relay.src='wan' + uci set firewall.turn_relay.dest_port='49152-65535' + uci set firewall.turn_relay.proto='udp' + uci set firewall.turn_relay.target='ACCEPT' + + uci commit firewall + /etc/init.d/firewall reload + log "Firewall rules added" + else + log "Firewall rules already exist" + fi + + log "TURN server exposed on $domain" +} + +#--- SSL Certificate --- +cmd_ssl() { + local domain="${1:-$(uci_get main.realm "turn.secubox.in")}" + + log "Setting up SSL for TURN server..." + + local cert_dir="/etc/ssl/turn" + mkdir -p "$cert_dir" + + # Try to use ACME cert from HAProxy + if [ -f "/etc/ssl/acme/${domain}.crt" ]; then + cp "/etc/ssl/acme/${domain}.crt" "$cert_dir/cert.pem" + cp "/etc/ssl/acme/${domain}.key" "$cert_dir/key.pem" + log "Using ACME certificate for $domain" + elif command -v acme.sh >/dev/null 2>&1; then + log "Requesting certificate via ACME..." + acme.sh --issue -d "$domain" --standalone --httpport 8888 || true + if [ -f "$HOME/.acme.sh/${domain}/${domain}.cer" ]; then + cp "$HOME/.acme.sh/${domain}/${domain}.cer" "$cert_dir/cert.pem" + cp "$HOME/.acme.sh/${domain}/${domain}.key" "$cert_dir/key.pem" + log "Certificate obtained" + fi + else + # Generate self-signed + log "Generating self-signed certificate..." + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "$cert_dir/key.pem" \ + -out "$cert_dir/cert.pem" \ + -subj "/CN=$domain" 2>/dev/null + warn "Using self-signed certificate (clients may need to trust it)" + fi + + uci set turn.ssl.cert_path="$cert_dir/cert.pem" + uci set turn.ssl.key_path="$cert_dir/key.pem" + uci commit turn + + # Restart to pick up new certs + /etc/init.d/turn restart + + log "SSL configured" +} + +#--- Service Control --- +cmd_start() { + /etc/init.d/turn start + log "TURN server started" +} + +cmd_stop() { + /etc/init.d/turn stop + log "TURN server stopped" +} + +cmd_restart() { + /etc/init.d/turn restart + log "TURN server restarted" +} + +cmd_enable() { + uci set turn.main.enabled='1' + uci commit turn + /etc/init.d/turn enable + log "TURN server enabled" +} + +cmd_disable() { + uci set turn.main.enabled='0' + uci commit turn + /etc/init.d/turn disable + log "TURN server disabled" +} + +#--- Logs --- +cmd_logs() { + local lines="${1:-50}" + local log_file=$(uci_get log.log_file "/var/log/turnserver.log") + + if [ -f "$log_file" ]; then + tail -n "$lines" "$log_file" + else + echo "No log file at $log_file" + echo "Checking syslog..." + logread | grep -i turn | tail -n "$lines" + fi +} + +#--- Help --- +cmd_help() { + cat < [options] + +${GREEN}Service Commands:${NC} + status Show server status + start Start TURN server + stop Stop TURN server + restart Restart TURN server + enable Enable autostart + disable Disable autostart + +${GREEN}Setup:${NC} + setup-jitsi [domain] [turn-domain] + Configure TURN for Jitsi Meet + ssl [domain] Setup SSL certificate + expose [domain] Configure DNS and firewall + +${GREEN}Operations:${NC} + credentials [user] [ttl] + Generate temp credentials (default: 24h) + test [host] Test TURN connectivity + logs [lines] View server logs + +${GREEN}Examples:${NC} + turnctl setup-jitsi jitsi.secubox.in turn.secubox.in + turnctl ssl turn.secubox.in + turnctl credentials webrtc-user 3600 + turnctl expose turn.secubox.in + +${GREEN}Ports Used:${NC} + 3478/udp,tcp STUN/TURN + 5349/tcp TURN over TLS + 49152-65535 Media relay (UDP) + +EOF +} + +#--- Main --- +case "$1" in + status) cmd_status ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + enable) cmd_enable ;; + disable) cmd_disable ;; + setup-jitsi) shift; cmd_setup_jitsi "$@" ;; + ssl) shift; cmd_ssl "$@" ;; + expose) shift; cmd_expose "$@" ;; + credentials) shift; cmd_credentials "$@" ;; + test) shift; cmd_test "$@" ;; + logs) shift; cmd_logs "$@" ;; + help|--help|-h|"") + cmd_help + ;; + *) + error "Unknown command: $1" + cmd_help + exit 1 + ;; +esac diff --git a/package/secubox/secubox-app-webradio/Makefile b/package/secubox/secubox-app-webradio/Makefile new file mode 100644 index 00000000..bb4e865b --- /dev/null +++ b/package/secubox/secubox-app-webradio/Makefile @@ -0,0 +1,43 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-app-webradio +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=Gerald Kerma +PKG_LICENSE:=MIT + +include $(INCLUDE_DIR)/package.mk + +define Package/secubox-app-webradio + SECTION:=secubox + CATEGORY:=SecuBox + TITLE:=SecuBox WebRadio Backend + DEPENDS:=+icecast +ezstream +alsa-utils +secubox-app-haproxy + PKGARCH:=all +endef + +define Package/secubox-app-webradio/description + Backend service controller for SecuBox WebRadio streaming. + Provides webradioctl CLI for managing Icecast/Ezstream/DarkIce. +endef + +define Package/secubox-app-webradio/conffiles +/etc/config/webradio +endef + +define Build/Compile +endef + +define Package/secubox-app-webradio/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_DIR) $(1)/usr/lib/webradio + $(INSTALL_CONF) ./files/etc/config/webradio $(1)/etc/config/ + $(INSTALL_BIN) ./files/etc/init.d/webradio $(1)/etc/init.d/ + $(INSTALL_BIN) ./files/usr/sbin/webradioctl $(1)/usr/sbin/ + $(INSTALL_DATA) ./files/usr/lib/webradio/*.sh $(1)/usr/lib/webradio/ +endef + +$(eval $(call BuildPackage,secubox-app-webradio)) diff --git a/package/secubox/secubox-app-webradio/files/etc/config/webradio b/package/secubox/secubox-app-webradio/files/etc/config/webradio new file mode 100644 index 00000000..7916a332 --- /dev/null +++ b/package/secubox/secubox-app-webradio/files/etc/config/webradio @@ -0,0 +1,44 @@ +config webradio 'main' + option enabled '0' + option name 'SecuBox Radio' + option description 'Community streaming radio' + option genre 'Various' + option port '8000' + option max_listeners '100' + option source_password 'hackme' + option admin_password 'admin123' + option relay_password 'relay123' + +config stream 'stream' + option enabled '1' + option name 'main' + option mount '/stream' + option format 'mp3' + option bitrate '128' + option samplerate '44100' + option channels '2' + +config playlist 'playlist' + option enabled '1' + option directory '/srv/webradio/music' + option shuffle '1' + option crossfade '3' + option jingle_interval '4' + option jingle_directory '/srv/webradio/jingles' + +config live 'live' + option enabled '0' + option device 'default' + option mount '/live' + option bitrate '192' + +config exposure 'exposure' + option domain '' + option ssl '1' + option tor '0' + option mesh '0' + +config security 'security' + option crowdsec '0' + option rate_limit '10' + option ban_duration '300' diff --git a/package/secubox/secubox-app-webradio/files/etc/init.d/webradio b/package/secubox/secubox-app-webradio/files/etc/init.d/webradio new file mode 100644 index 00000000..6c3f787e --- /dev/null +++ b/package/secubox/secubox-app-webradio/files/etc/init.d/webradio @@ -0,0 +1,78 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 + +CONF_DIR="/srv/webradio/config" +MUSIC_DIR="/srv/webradio/music" +JINGLE_DIR="/srv/webradio/jingles" +LOG_DIR="/var/log/webradio" +PLAYLIST_FILE="/tmp/webradio_playlist.m3u" + +start_service() { + local enabled + config_load webradio + config_get enabled main enabled '0' + + [ "$enabled" != "1" ] && return 0 + + # Ensure directories exist + mkdir -p "$CONF_DIR" "$MUSIC_DIR" "$JINGLE_DIR" "$LOG_DIR" + + # Generate configurations + /usr/sbin/webradioctl genconfig + + # Start Icecast + if [ -f "$CONF_DIR/icecast.xml" ]; then + procd_open_instance icecast + procd_set_param command /usr/bin/icecast -c "$CONF_DIR/icecast.xml" + procd_set_param respawn + procd_set_param stderr 1 + procd_set_param pidfile /var/run/icecast.pid + procd_close_instance + fi + + # Wait for Icecast to start + sleep 2 + + # Start Ezstream if playlist enabled + local playlist_enabled + config_get playlist_enabled playlist enabled '0' + if [ "$playlist_enabled" = "1" ] && [ -f "$CONF_DIR/ezstream.xml" ]; then + /usr/sbin/webradioctl playlist generate + procd_open_instance ezstream + procd_set_param command /usr/bin/ezstream -c "$CONF_DIR/ezstream.xml" + procd_set_param respawn + procd_set_param stderr 1 + procd_set_param pidfile /var/run/ezstream.pid + procd_close_instance + fi + + # Start DarkIce if live enabled + local live_enabled + config_get live_enabled live enabled '0' + if [ "$live_enabled" = "1" ] && [ -f "$CONF_DIR/darkice.cfg" ]; then + procd_open_instance darkice + procd_set_param command /usr/bin/darkice -c "$CONF_DIR/darkice.cfg" + procd_set_param respawn + procd_set_param stderr 1 + procd_set_param pidfile /var/run/darkice.pid + procd_close_instance + fi + + logger -t webradio "WebRadio started" +} + +stop_service() { + logger -t webradio "WebRadio stopped" +} + +reload_service() { + /usr/sbin/webradioctl genconfig + /usr/sbin/webradioctl playlist generate +} + +service_triggers() { + procd_add_reload_trigger "webradio" +} diff --git a/package/secubox/secubox-app-webradio/files/usr/lib/webradio/crowdsec-install.sh b/package/secubox/secubox-app-webradio/files/usr/lib/webradio/crowdsec-install.sh new file mode 100644 index 00000000..b1ab5bac --- /dev/null +++ b/package/secubox/secubox-app-webradio/files/usr/lib/webradio/crowdsec-install.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# Install CrowdSec scenarios for WebRadio/Icecast protection + +PARSER_DIR="/usr/share/crowdsec/parsers/s01-parse" +SCENARIO_DIR="/usr/share/crowdsec/scenarios" + +log() { echo "[CrowdSec-WebRadio] $1"; } + +install_parser() { + mkdir -p "$PARSER_DIR" + + cat > "$PARSER_DIR/icecast-logs.yaml" <<'EOF' +name: secubox/icecast-logs +description: "Parse Icecast access logs" +filter: "evt.Parsed.program == 'icecast'" +onsuccess: next_stage +grok: + pattern: '%{IP:source_ip} - - \[%{HTTPDATE:timestamp}\] "%{WORD:http_method} %{URIPATHPARAM:request} HTTP/%{NUMBER:http_version}" %{NUMBER:http_code} %{NUMBER:bytes_sent}' + apply_on: message +statics: + - meta: log_type + value: icecast_access + - meta: service + value: webradio +EOF + log "Installed icecast-logs parser" +} + +install_scenarios() { + mkdir -p "$SCENARIO_DIR" + + # Connection flood scenario + cat > "$SCENARIO_DIR/icecast-flood.yaml" <<'EOF' +type: leaky +name: secubox/icecast-flood +description: "Detect Icecast connection flooding" +filter: "evt.Meta.log_type == 'icecast_access'" +groupby: evt.Meta.source_ip +capacity: 20 +leakspeed: 10s +blackhole: 5m +labels: + service: webradio + type: flood + remediation: true +EOF + + # Bandwidth abuse scenario + cat > "$SCENARIO_DIR/icecast-bandwidth-abuse.yaml" <<'EOF' +type: leaky +name: secubox/icecast-bandwidth-abuse +description: "Detect excessive bandwidth consumption" +filter: "evt.Meta.log_type == 'icecast_access' && evt.Parsed.bytes_sent > 10000000" +groupby: evt.Meta.source_ip +capacity: 5 +leakspeed: 1m +blackhole: 10m +labels: + service: webradio + type: bandwidth_abuse + remediation: true +EOF + + log "Installed icecast scenarios" +} + +reload_crowdsec() { + if pgrep -f "crowdsec" >/dev/null 2>&1; then + /etc/init.d/crowdsec reload + log "CrowdSec reloaded" + else + log "CrowdSec not running" + fi +} + +# Main +log "Installing CrowdSec protection for WebRadio..." +install_parser +install_scenarios +reload_crowdsec +log "Installation complete" diff --git a/package/secubox/secubox-app-webradio/files/usr/lib/webradio/scheduler.sh b/package/secubox/secubox-app-webradio/files/usr/lib/webradio/scheduler.sh new file mode 100644 index 00000000..daba1153 --- /dev/null +++ b/package/secubox/secubox-app-webradio/files/usr/lib/webradio/scheduler.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# WebRadio Scheduler - Cron-based show automation +# Called by cron to switch between scheduled programs + +CONF_DIR="/srv/webradio/config" +LOG_DIR="/var/log/webradio" + +log() { + logger -t "webradio-scheduler" "$1" + echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_DIR/scheduler.log" +} + +uci_get() { uci -q get "webradio.$1" 2>/dev/null || echo "$2"; } + +# Get current day/hour +DAY=$(date +%u) # 1-7 (Mon-Sun) +HOUR=$(date +%H) + +# Find matching schedule +find_schedule() { + local schedules=$(uci show webradio 2>/dev/null | grep "=schedule$" | cut -d. -f2 | cut -d= -f1) + + for sched in $schedules; do + local enabled=$(uci_get "${sched}.enabled" "0") + [ "$enabled" != "1" ] && continue + + local days=$(uci_get "${sched}.days" "") + local start_hour=$(uci_get "${sched}.start_hour" "0") + local end_hour=$(uci_get "${sched}.end_hour" "24") + + # Check if current day matches + echo "$days" | grep -q "$DAY" || continue + + # Check if current hour is in range + if [ "$HOUR" -ge "$start_hour" ] && [ "$HOUR" -lt "$end_hour" ]; then + echo "$sched" + return 0 + fi + done + + echo "default" +} + +apply_schedule() { + local sched="$1" + + if [ "$sched" = "default" ]; then + log "No active schedule - using default playlist" + return 0 + fi + + local name=$(uci_get "${sched}.name" "Unknown") + local playlist_dir=$(uci_get "${sched}.playlist_dir" "") + local jingle=$(uci_get "${sched}.intro_jingle" "") + + log "Activating schedule: $name" + + # Play intro jingle if configured + if [ -n "$jingle" ] && [ -f "$jingle" ]; then + log "Playing intro jingle: $jingle" + # Signal ezstream to queue jingle + echo "$jingle" > /tmp/webradio_next_track + pkill -USR1 -f "ezstream" 2>/dev/null + fi + + # If schedule has specific playlist directory, regenerate + if [ -n "$playlist_dir" ] && [ -d "$playlist_dir" ]; then + log "Switching to playlist: $playlist_dir" + uci set webradio.playlist.directory="$playlist_dir" + uci commit webradio + /usr/sbin/webradioctl playlist generate + /etc/init.d/webradio reload + fi +} + +# Main +current_schedule=$(find_schedule) +log "Current schedule check: $current_schedule" + +# Check if schedule changed +LAST_SCHEDULE_FILE="/tmp/webradio_last_schedule" +last_schedule="" +[ -f "$LAST_SCHEDULE_FILE" ] && last_schedule=$(cat "$LAST_SCHEDULE_FILE") + +if [ "$current_schedule" != "$last_schedule" ]; then + apply_schedule "$current_schedule" + echo "$current_schedule" > "$LAST_SCHEDULE_FILE" +fi diff --git a/package/secubox/secubox-app-webradio/files/usr/sbin/webradioctl b/package/secubox/secubox-app-webradio/files/usr/sbin/webradioctl new file mode 100644 index 00000000..8b5bee47 --- /dev/null +++ b/package/secubox/secubox-app-webradio/files/usr/sbin/webradioctl @@ -0,0 +1,675 @@ +#!/bin/sh +# WebRadio Controller - SecuBox Backend CLI +# Manages Icecast, Ezstream, DarkIce streaming services + +set -e + +CONF_DIR="/srv/webradio/config" +MUSIC_DIR="/srv/webradio/music" +JINGLE_DIR="/srv/webradio/jingles" +PLAYLIST_FILE="/tmp/webradio_playlist.m3u" +ICECAST_XML="$CONF_DIR/icecast.xml" +EZSTREAM_XML="$CONF_DIR/ezstream.xml" +DARKICE_CFG="$CONF_DIR/darkice.cfg" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { echo -e "${GREEN}[WebRadio]${NC} $1"; } +warn() { echo -e "${YELLOW}[WebRadio]${NC} $1"; } +error() { echo -e "${RED}[WebRadio]${NC} $1" >&2; } + +uci_get() { uci -q get "webradio.$1" 2>/dev/null || echo "$2"; } + +#--- Status --- +cmd_status() { + echo -e "${CYAN}=== WebRadio Status ===${NC}" + + local enabled=$(uci_get main.enabled 0) + local name=$(uci_get main.name "SecuBox Radio") + local port=$(uci_get main.port 8000) + + echo "Station: $name" + echo "Enabled: $([ "$enabled" = "1" ] && echo "Yes" || echo "No")" + echo "" + + # Icecast status + if pgrep -f "icecast" >/dev/null 2>&1; then + echo -e "Icecast: ${GREEN}Running${NC} (port $port)" + # Get listener count + local listeners=$(curl -s "http://127.0.0.1:$port/status-json.xsl" 2>/dev/null | jsonfilter -e '@.icestats.source.listeners' 2>/dev/null || echo "0") + echo "Listeners: $listeners" + else + echo -e "Icecast: ${RED}Stopped${NC}" + fi + + # Ezstream status + if pgrep -f "ezstream" >/dev/null 2>&1; then + echo -e "Ezstream: ${GREEN}Running${NC} (playlist mode)" + else + echo -e "Ezstream: ${YELLOW}Stopped${NC}" + fi + + # DarkIce status + if pgrep -f "darkice" >/dev/null 2>&1; then + echo -e "DarkIce: ${GREEN}Running${NC} (live mode)" + else + echo -e "DarkIce: ${YELLOW}Stopped${NC}" + fi + + # Exposure status + local domain=$(uci_get exposure.domain "") + if [ -n "$domain" ]; then + echo "" + echo "Exposed at: https://$domain/" + fi +} + +#--- Generate Configs --- +cmd_genconfig() { + log "Generating configuration files..." + mkdir -p "$CONF_DIR" + + # Generate Icecast config + generate_icecast_config + + # Generate Ezstream config + generate_ezstream_config + + # Generate DarkIce config + generate_darkice_config + + log "Configuration files generated in $CONF_DIR" +} + +generate_icecast_config() { + local name=$(uci_get main.name "SecuBox Radio") + local port=$(uci_get main.port 8000) + local max_listeners=$(uci_get main.max_listeners 100) + local source_pass=$(uci_get main.source_password "hackme") + local admin_pass=$(uci_get main.admin_password "admin123") + local relay_pass=$(uci_get main.relay_password "relay123") + + cat > "$ICECAST_XML" < + SecuBox + admin@secubox.local + + $max_listeners + 5 + 524288 + 30 + 15 + 10 + 1 + 65535 + + + $source_pass + $relay_pass + admin + $admin_pass + + localhost + + $port + 0.0.0.0 + + + /stream + /silence.mp3 + 1 + $name + SecuBox Community Radio + Various + 0 + + + /live + $name - Live + 0 + + 1 + + /usr/share/icecast + /var/log/webradio + /usr/share/icecast/web + /usr/share/icecast/admin + /var/run/icecast.pid + + + access.log + error.log + 3 + + + 0 + + icecast + icecast + + + +EOF +} + +generate_ezstream_config() { + local port=$(uci_get main.port 8000) + local source_pass=$(uci_get main.source_password "hackme") + local format=$(uci_get stream.format "mp3") + local bitrate=$(uci_get stream.bitrate 128) + local samplerate=$(uci_get stream.samplerate 44100) + local channels=$(uci_get stream.channels 2) + local shuffle=$(uci_get playlist.shuffle 1) + + cat > "$EZSTREAM_XML" < + http://localhost:$port/stream + $source_pass + MP3 + $PLAYLIST_FILE + 0 + $shuffle + 0 + -1 + SecuBox Radio + https://secubox.in + Various + SecuBox Community Radio + $bitrate + $channels + $samplerate + 0 + +EOF +} + +generate_darkice_config() { + local port=$(uci_get main.port 8000) + local source_pass=$(uci_get main.source_password "hackme") + local device=$(uci_get live.device "default") + local bitrate=$(uci_get live.bitrate 192) + local samplerate=$(uci_get stream.samplerate 44100) + local channels=$(uci_get stream.channels 2) + + cat > "$DARKICE_CFG" < /tmp/music_files.txt + + # Shuffle if enabled (using awk for BusyBox compatibility) + if [ "$shuffle" = "1" ]; then + awk 'BEGIN{srand()} {print rand()"\t"$0}' /tmp/music_files.txt | sort -n | cut -f2- > /tmp/music_shuffled.txt + mv /tmp/music_shuffled.txt /tmp/music_files.txt + fi + + # Generate playlist with jingles + rm -f "$PLAYLIST_FILE" + local count=0 + while read -r file; do + echo "$file" >> "$PLAYLIST_FILE" + count=$((count + 1)) + + # Insert jingle every N tracks + if [ "$jingle_interval" -gt 0 ] && [ $((count % jingle_interval)) -eq 0 ]; then + local jingle=$(find "$jingle_dir" -type f -name "*.mp3" 2>/dev/null | sort -R | head -1) + [ -n "$jingle" ] && echo "$jingle" >> "$PLAYLIST_FILE" + fi + done < /tmp/music_files.txt + + rm -f /tmp/music_files.txt + + local total=$(wc -l < "$PLAYLIST_FILE" 2>/dev/null || echo 0) + log "Playlist generated: $total tracks" +} + +cmd_playlist_list() { + echo -e "${CYAN}=== Playlist ===${NC}" + if [ -f "$PLAYLIST_FILE" ]; then + awk '{print NR": "$0}' "$PLAYLIST_FILE" | head -20 + local total=$(wc -l < "$PLAYLIST_FILE") + echo "..." + echo "Total: $total tracks" + else + warn "No playlist generated. Run: webradioctl playlist generate" + fi +} + +cmd_playlist_add() { + local file="$1" + if [ -f "$file" ]; then + cp "$file" "$MUSIC_DIR/" + log "Added: $(basename "$file")" + else + error "File not found: $file" + return 1 + fi +} + +#--- Stream Mode (FFmpeg) --- +cmd_stream() { + case "$1" in + start) + cmd_stream_start + ;; + stop) + cmd_stream_stop + ;; + *) + cmd_stream_status + ;; + esac +} + +cmd_stream_start() { + if pgrep -f "ffmpeg.*icecast" >/dev/null 2>&1; then + warn "Stream already running" + return 0 + fi + + # Ensure playlist exists + if [ ! -f "$PLAYLIST_FILE" ] || [ ! -s "$PLAYLIST_FILE" ]; then + cmd_playlist_generate + fi + + if [ ! -s "$PLAYLIST_FILE" ]; then + error "No music files found in $MUSIC_DIR" + return 1 + fi + + local port=$(uci_get main.port 8000) + local source_pass=$(uci_get main.source_password "hackme") + local bitrate=$(uci_get stream.bitrate 128) + local name=$(uci_get main.name "SecuBox Radio") + + log "Starting stream to icecast..." + + # Create a loop script for continuous streaming + cat > /tmp/webradio_stream.sh << 'STREAMEOF' +#!/bin/sh +PLAYLIST="$1" +PORT="$2" +PASS="$3" +BITRATE="$4" +NAME="$5" + +while true; do + while read -r file; do + [ -f "$file" ] || continue + ffmpeg -re -i "$file" \ + -vn -acodec libmp3lame -ab ${BITRATE}k -ar 44100 -ac 2 \ + -content_type audio/mpeg \ + -f mp3 "icecast://source:${PASS}@127.0.0.1:${PORT}/stream" \ + 2>/var/log/webradio/ffmpeg.log + done < "$PLAYLIST" + # Re-shuffle for next loop (BusyBox compatible) + awk 'BEGIN{srand()} {print rand()"\t"$0}' "$PLAYLIST" | sort -n | cut -f2- > "${PLAYLIST}.tmp" && mv "${PLAYLIST}.tmp" "$PLAYLIST" +done +STREAMEOF + chmod +x /tmp/webradio_stream.sh + + nohup /tmp/webradio_stream.sh "$PLAYLIST_FILE" "$port" "$source_pass" "$bitrate" "$name" \ + >/dev/null 2>&1 & + + sleep 3 + if pgrep -f "webradio_stream.sh" >/dev/null 2>&1 || pgrep -f "ffmpeg" >/dev/null 2>&1; then + log "Stream started on http://127.0.0.1:$port/stream" + else + error "Failed to start stream - check /var/log/webradio/ffmpeg.log" + return 1 + fi +} + +cmd_stream_status() { + local port=$(uci_get main.port 8000) + if pgrep -f "webradio_stream.sh" >/dev/null 2>&1 || pgrep -f "ffmpeg" >/dev/null 2>&1; then + echo -e "FFmpeg Stream: ${GREEN}Running${NC}" + echo "URL: http://127.0.0.1:$port/stream" + # Get current listener count + local listeners=$(curl -s "http://127.0.0.1:$port/status-json.xsl" 2>/dev/null | jsonfilter -e '@.icestats.source.listeners' 2>/dev/null || echo "0") + echo "Listeners: $listeners" + else + echo -e "FFmpeg Stream: ${YELLOW}Stopped${NC}" + fi +} + +cmd_stream_stop() { + log "Stopping stream..." + pkill -f "webradio_stream.sh" 2>/dev/null || true + pkill -f "ffmpeg.*icecast" 2>/dev/null || true + log "Stream stopped" +} + +#--- Live Mode --- +cmd_live() { + case "$1" in + start) + cmd_live_start + ;; + stop) + cmd_live_stop + ;; + status) + cmd_live_status + ;; + devices) + cmd_live_devices + ;; + *) + cmd_live_status + ;; + esac +} + +cmd_live_start() { + if pgrep -f "darkice" >/dev/null 2>&1; then + warn "DarkIce already running" + return 0 + fi + + if [ ! -f "$DARKICE_CFG" ]; then + cmd_genconfig + fi + + log "Starting live broadcast..." + darkice -c "$DARKICE_CFG" & + sleep 1 + + if pgrep -f "darkice" >/dev/null 2>&1; then + log "Live broadcast started on /live" + else + error "Failed to start DarkIce" + return 1 + fi +} + +cmd_live_stop() { + log "Stopping live broadcast..." + pkill -f "darkice" 2>/dev/null || true + log "Live broadcast stopped" +} + +cmd_live_status() { + if pgrep -f "darkice" >/dev/null 2>&1; then + echo -e "DarkIce: ${GREEN}Running${NC}" + local device=$(uci_get live.device "default") + echo "Device: $device" + else + echo -e "DarkIce: ${YELLOW}Stopped${NC}" + fi +} + +cmd_live_devices() { + echo -e "${CYAN}=== Audio Devices ===${NC}" + if command -v arecord >/dev/null 2>&1; then + arecord -l 2>/dev/null || echo "No capture devices found" + else + echo "alsa-utils not installed" + fi +} + +#--- Exposure (Punk Model) --- +cmd_expose() { + local domain="$1" + local channel="${2:-dns}" # dns, tor, mesh, all + + if [ -z "$domain" ]; then + error "Usage: webradioctl expose [dns|tor|mesh|all]" + return 1 + fi + + local port=$(uci_get main.port 8000) + + log "Exposing WebRadio on $domain (channel: $channel)..." + + case "$channel" in + dns|all) + # Create HAProxy vhost + if command -v haproxyctl >/dev/null 2>&1; then + # Create backend + haproxyctl backend add webradio_stream 127.0.0.1 "$port" 2>/dev/null || true + # Create vhost + haproxyctl vhost add "$domain" webradio_stream + haproxyctl reload + log "DNS exposure: https://$domain/" + else + error "haproxyctl not available" + fi + ;; + esac + + case "$channel" in + tor|all) + # Add Tor hidden service + if command -v torctl >/dev/null 2>&1; then + torctl hidden-service add webradio "$port" + local onion=$(torctl hidden-service get webradio 2>/dev/null) + [ -n "$onion" ] && log "Tor exposure: http://$onion/" + else + warn "torctl not available - skipping Tor" + fi + ;; + esac + + case "$channel" in + mesh|all) + # Publish to mesh + if command -v vortexctl >/dev/null 2>&1; then + vortexctl mesh publish webradio "$domain" "$port" + log "Mesh exposure: Published to P2P network" + else + warn "vortexctl not available - skipping Mesh" + fi + ;; + esac + + # Save exposure config + uci set webradio.exposure.domain="$domain" + [ "$channel" = "tor" ] || [ "$channel" = "all" ] && uci set webradio.exposure.tor='1' + [ "$channel" = "mesh" ] || [ "$channel" = "all" ] && uci set webradio.exposure.mesh='1' + uci commit webradio + + log "Exposure complete!" +} + +cmd_unexpose() { + local domain=$(uci_get exposure.domain "") + + if [ -z "$domain" ]; then + warn "No exposure configured" + return 0 + fi + + log "Removing exposure for $domain..." + + # Remove HAProxy vhost + if command -v haproxyctl >/dev/null 2>&1; then + haproxyctl vhost del "$domain" 2>/dev/null || true + haproxyctl reload + fi + + # Remove Tor hidden service + if command -v torctl >/dev/null 2>&1; then + torctl hidden-service del webradio 2>/dev/null || true + fi + + # Remove from mesh + if command -v vortexctl >/dev/null 2>&1; then + vortexctl mesh unpublish webradio 2>/dev/null || true + fi + + # Clear config + uci set webradio.exposure.domain='' + uci set webradio.exposure.tor='0' + uci set webradio.exposure.mesh='0' + uci commit webradio + + log "Exposure removed" +} + +#--- Service Control --- +cmd_start() { + /etc/init.d/webradio start +} + +cmd_stop() { + /etc/init.d/webradio stop +} + +cmd_restart() { + /etc/init.d/webradio restart +} + +cmd_enable() { + uci set webradio.main.enabled='1' + uci commit webradio + /etc/init.d/webradio enable + log "WebRadio enabled" +} + +cmd_disable() { + uci set webradio.main.enabled='0' + uci commit webradio + /etc/init.d/webradio disable + log "WebRadio disabled" +} + +#--- Skip Track --- +cmd_skip() { + if pgrep -f "ezstream" >/dev/null 2>&1; then + pkill -USR1 -f "ezstream" + log "Skipped to next track" + else + warn "Ezstream not running" + fi +} + +#--- Help --- +cmd_help() { + cat < [options] + +${GREEN}Service Commands:${NC} + status Show service status + start Start WebRadio services + stop Stop WebRadio services + restart Restart services + enable Enable autostart + disable Disable autostart + +${GREEN}Configuration:${NC} + genconfig Generate Icecast/Ezstream/DarkIce configs + +${GREEN}Playlist:${NC} + playlist List current playlist + playlist generate Regenerate playlist from music directory + playlist add Add file to music directory + +${GREEN}Streaming:${NC} + stream Show stream status + stream start Start FFmpeg stream to Icecast + stream stop Stop streaming + skip Skip to next track + +${GREEN}Live Broadcast:${NC} + live Show live status + live start Start DarkIce live input + live stop Stop live broadcast + live devices List audio capture devices + +${GREEN}Exposure (Punk Model):${NC} + expose [channel] Expose radio (dns|tor|mesh|all) + unexpose Remove all exposure + +${GREEN}Examples:${NC} + webradioctl enable + webradioctl playlist generate + webradioctl stream start + webradioctl expose radio.secubox.in dns + webradioctl expose radio.secubox.in all + +EOF +} + +#--- Main --- +case "$1" in + status) cmd_status ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + enable) cmd_enable ;; + disable) cmd_disable ;; + genconfig) cmd_genconfig ;; + playlist) shift; cmd_playlist "$@" ;; + stream) shift; cmd_stream "$@" ;; + skip) cmd_skip ;; + live) shift; cmd_live "$@" ;; + expose) shift; cmd_expose "$@" ;; + unexpose) cmd_unexpose ;; + help|--help|-h|"") + cmd_help + ;; + *) + error "Unknown command: $1" + cmd_help + exit 1 + ;; +esac