feat: Add WebRadio, TURN server, and Lyrion streaming integration

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 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-21 17:46:54 +01:00
parent 05d12ab130
commit 6db547f7f8
31 changed files with 3542 additions and 4 deletions

View File

@ -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 <domain>` (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

View File

@ -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)"
]
}
}

View File

@ -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))

View File

@ -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
});

View File

@ -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

View File

@ -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 }
}
}
}

View File

@ -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"]
}
}
}

View File

@ -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
});

View File

@ -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"}'
;;

View File

@ -65,5 +65,13 @@
"type": "view",
"path": "webradio/security"
}
},
"admin/services/webradio/lyrion": {
"title": "Lyrion Bridge",
"order": 80,
"action": {
"type": "view",
"path": "webradio/lyrion"
}
}
}

View File

@ -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"]
}

View File

@ -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
;;

View File

@ -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 <contact@cybermind.fr>
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))

View File

@ -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'

View File

@ -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"
}

View File

@ -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

View File

@ -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 <domain>"
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 <<EOF
${CYAN}Lyrion Stream Controller - Bridge Management CLI${NC}
Usage: lyrionstreamctl <command> [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 <domain> 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

View File

@ -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 <contact@cybermind.fr>
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))

View File

@ -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'

View File

@ -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"
}

View File

@ -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 <<EOF
${CYAN}Squeezelite Controller - SecuBox Virtual Player CLI${NC}
Usage: squeezelitectl <command> [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

View File

@ -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 <contact@cybermind.fr>
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))

View File

@ -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'

View File

@ -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" <<EOF
# SecuBox TURN Server Configuration
# Generated by /etc/init.d/turn
listening-port=$listening_port
tls-listening-port=$tls_port
realm=$realm
fingerprint
lt-cred-mech
EOF
# Auth secret
if [ "$use_auth_secret" = "1" ]; then
echo "use-auth-secret" >> "$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" <<EOF
no-multicast-peers
no-cli
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.0.0.0-192.0.0.255
denied-peer-ip=192.0.2.0-192.0.2.255
denied-peer-ip=192.88.99.0-192.88.99.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=198.18.0.0-198.19.255.255
denied-peer-ip=198.51.100.0-198.51.100.255
denied-peer-ip=203.0.113.0-203.0.113.255
denied-peer-ip=240.0.0.0-255.255.255.255
EOF
return 0
}
start_service() {
generate_config || {
logger -t turn "TURN server disabled or config error"
return 0
}
procd_open_instance
procd_set_param command /usr/bin/turnserver -c "$TURN_CONF"
procd_set_param respawn
procd_set_param stderr 1
procd_set_param stdout 1
procd_set_param pidfile /var/run/turnserver.pid
procd_close_instance
logger -t turn "TURN server started"
}
stop_service() {
logger -t turn "TURN server stopped"
}
reload_service() {
stop
start
}
service_triggers() {
procd_add_reload_trigger "turn"
}

View File

@ -0,0 +1,391 @@
#!/bin/sh
# TURN Server Controller - SecuBox WebRTC NAT Traversal
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}[TURN]${NC} $1"; }
warn() { echo -e "${YELLOW}[TURN]${NC} $1"; }
error() { echo -e "${RED}[TURN]${NC} $1" >&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 <<EOF
${CYAN}TURN Server Controller - SecuBox WebRTC NAT Traversal${NC}
Usage: turnctl <command> [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

View File

@ -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 <contact@cybermind.fr>
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))

View File

@ -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'

View File

@ -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"
}

View File

@ -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"

View File

@ -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

View File

@ -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" <<EOF
<icecast>
<location>SecuBox</location>
<admin>admin@secubox.local</admin>
<limits>
<clients>$max_listeners</clients>
<sources>5</sources>
<queue-size>524288</queue-size>
<client-timeout>30</client-timeout>
<header-timeout>15</header-timeout>
<source-timeout>10</source-timeout>
<burst-on-connect>1</burst-on-connect>
<burst-size>65535</burst-size>
</limits>
<authentication>
<source-password>$source_pass</source-password>
<relay-password>$relay_pass</relay-password>
<admin-user>admin</admin-user>
<admin-password>$admin_pass</admin-password>
</authentication>
<hostname>localhost</hostname>
<listen-socket>
<port>$port</port>
<bind-address>0.0.0.0</bind-address>
</listen-socket>
<mount>
<mount-name>/stream</mount-name>
<fallback-mount>/silence.mp3</fallback-mount>
<fallback-override>1</fallback-override>
<stream-name>$name</stream-name>
<stream-description>SecuBox Community Radio</stream-description>
<genre>Various</genre>
<public>0</public>
</mount>
<mount>
<mount-name>/live</mount-name>
<stream-name>$name - Live</stream-name>
<public>0</public>
</mount>
<fileserve>1</fileserve>
<paths>
<basedir>/usr/share/icecast</basedir>
<logdir>/var/log/webradio</logdir>
<webroot>/usr/share/icecast/web</webroot>
<adminroot>/usr/share/icecast/admin</adminroot>
<pidfile>/var/run/icecast.pid</pidfile>
</paths>
<logging>
<accesslog>access.log</accesslog>
<errorlog>error.log</errorlog>
<loglevel>3</loglevel>
</logging>
<security>
<chroot>0</chroot>
<changeowner>
<user>icecast</user>
<group>icecast</group>
</changeowner>
</security>
</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" <<EOF
<ezstream>
<url>http://localhost:$port/stream</url>
<sourcepassword>$source_pass</sourcepassword>
<format>MP3</format>
<filename>$PLAYLIST_FILE</filename>
<playlist_program>0</playlist_program>
<shuffle>$shuffle</shuffle>
<stream_once>0</stream_once>
<reconnect_attempts>-1</reconnect_attempts>
<svrinfoname>SecuBox Radio</svrinfoname>
<svrinfourl>https://secubox.in</svrinfourl>
<svrinfogenre>Various</svrinfogenre>
<svrinfodescription>SecuBox Community Radio</svrinfodescription>
<svrinfobitrate>$bitrate</svrinfobitrate>
<svrinfochannels>$channels</svrinfochannels>
<svrinfosamplerate>$samplerate</svrinfosamplerate>
<svrinfopublic>0</svrinfopublic>
</ezstream>
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" <<EOF
[general]
duration = 0
bufferSecs = 5
reconnect = yes
[input]
device = $device
sampleRate = $samplerate
bitsPerSample = 16
channel = $channels
[icecast2-0]
bitrateMode = cbr
format = mp3
bitrate = $bitrate
server = localhost
port = $port
password = $source_pass
mountPoint = live
name = SecuBox Radio - Live
description = Live broadcast
genre = Live
public = no
EOF
}
#--- Playlist Management ---
cmd_playlist() {
case "$1" in
generate)
cmd_playlist_generate
;;
list)
cmd_playlist_list
;;
add)
shift
cmd_playlist_add "$@"
;;
*)
cmd_playlist_list
;;
esac
}
cmd_playlist_generate() {
log "Generating playlist..."
mkdir -p "$MUSIC_DIR"
local shuffle=$(uci_get playlist.shuffle 1)
local jingle_interval=$(uci_get playlist.jingle_interval 4)
local jingle_dir=$(uci_get playlist.jingle_directory "$JINGLE_DIR")
# Find all audio files
find "$MUSIC_DIR" -type f \( -name "*.mp3" -o -name "*.ogg" -o -name "*.flac" \) > /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 <domain> [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 <<EOF
${CYAN}WebRadio Controller - SecuBox Backend CLI${NC}
Usage: webradioctl <command> [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 <file> 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 <domain> [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